Files
multica/server/internal/handler/workspace.go
T
731af7ccd6 MUL-7002: push runtime deletion invalidation to active daemons (#7969)
* fix(runtime): invalidate deleted active runtimes

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): separate invalidation delivery metrics

Co-authored-by: multica-agent <[email protected]>

* fix(runtime): complete invalidation metric separation

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-03 14:50:14 +08:00

1339 lines
50 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`)
var nonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9]`)
var workspaceSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
var issuePrefixPattern = regexp.MustCompile(`^[A-Z0-9]{1,10}$`)
// defaultIssuePrefixFromSlug derives a new workspace's default issue prefix
// from its slug: alphanumerics only, first 4 chars, uppercased.
// Examples: "acme" → "ACME", "front-end" → "FRON", "team-2" → "TEAM".
//
// The slug — not the name — is the derivation source on purpose (MUL-6050).
// Name is the one field in the create flow that accepts non-ASCII, so deriving
// an ASCII-only prefix from it forced every CJK/emoji-named workspace onto the
// same "WS" fallback. Slug is validated as `^[a-z0-9]+(-[a-z0-9]+)*$` and
// required, so it always yields a non-empty, workspace-specific prefix.
//
// Kept byte-for-byte in sync with the client-side preview in
// packages/views/onboarding/steps/step-workspace.tsx — what the user sees on
// the create screen has to be what the server would derive.
func defaultIssuePrefixFromSlug(slug string) string {
head := nonAlphanumeric.ReplaceAllString(slug, "")
if head == "" {
// Unreachable for a validated slug; keeps the function total so a
// future caller can never persist an empty prefix.
return "WS"
}
if len(head) > 4 {
head = head[:4]
}
return strings.ToUpper(head)
}
// legacyIssuePrefixFromName is the pre-MUL-6050 derivation: first 3 ASCII
// letters of the workspace name, uppercased, "WS" when the name has none.
//
// FROZEN — do not "fix" this to match defaultIssuePrefixFromSlug. Its only
// caller is getIssuePrefix's fallback for workspaces whose stored prefix is
// empty (rows predating the column). Issue identifiers are computed at read
// time from the current prefix, so changing what this returns would silently
// rewrite the identifier of every historical issue in those workspaces.
// Deliberate product decision: no backfill, existing workspaces stay as they
// are; only newly created ones follow the slug-derived rule.
func legacyIssuePrefixFromName(name string) string {
letters := nonAlpha.ReplaceAllString(name, "")
if len(letters) == 0 {
return "WS"
}
letters = strings.ToUpper(letters)
if len(letters) > 3 {
letters = letters[:3]
}
return letters
}
// normalizeIssuePrefix trims and uppercases a client-supplied issue prefix.
// An all-whitespace value means "not provided" and reports ("", true) so the
// caller can fall back to its default. Anything else must be 1-10 chars of
// A-Z0-9, mirroring the client-side guardrail in
// packages/views/settings/components/workspace-tab.tsx.
func normalizeIssuePrefix(raw string) (string, bool) {
prefix := strings.ToUpper(strings.TrimSpace(raw))
if prefix == "" {
return "", true
}
if !issuePrefixPattern.MatchString(prefix) {
return "", false
}
return prefix, true
}
const issuePrefixFormatError = "issue prefix must be 1-10 uppercase letters or digits"
type WorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
Settings any `json:"settings"`
Repos any `json:"repos"`
IssuePrefix string `json:"issue_prefix"`
AvatarURL *string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (h *Handler) workspaceToResponse(w db.Workspace) WorkspaceResponse {
var settings any
if w.Settings != nil {
json.Unmarshal(w.Settings, &settings)
}
if settings == nil {
settings = map[string]any{}
}
var repos any
if w.Repos != nil {
json.Unmarshal(w.Repos, &repos)
}
if repos == nil {
repos = []any{}
}
return WorkspaceResponse{
ID: uuidToString(w.ID),
Name: w.Name,
Slug: w.Slug,
Description: textToPtr(w.Description),
Context: textToPtr(w.Context),
Settings: settings,
Repos: repos,
IssuePrefix: w.IssuePrefix,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(w.AvatarUrl)),
CreatedAt: timestampToString(w.CreatedAt),
UpdatedAt: timestampToString(w.UpdatedAt),
}
}
type MemberResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
}
func memberToResponse(m db.Member) MemberResponse {
return MemberResponse{
ID: uuidToString(m.ID),
WorkspaceID: uuidToString(m.WorkspaceID),
UserID: uuidToString(m.UserID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
}
}
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaces, err := h.Queries.ListWorkspaces(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list workspaces")
return
}
resp := make([]WorkspaceResponse, len(workspaces))
for i, ws := range workspaces {
resp[i] = h.workspaceToResponse(ws)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
if !ok {
return
}
ws, err := h.Queries.GetWorkspace(r.Context(), idUUID)
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
writeJSON(w, http.StatusOK, h.workspaceToResponse(ws))
}
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
IssuePrefix *string `json:"issue_prefix"`
}
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
// Self-host gate (#3433): when the operator has set
// DISABLE_WORKSPACE_CREATION=true, no caller — including existing
// workspace owners — may create additional workspaces. The frontend
// hides every "Create workspace" affordance via /api/config, but the
// 403 here is the only authoritative check.
if h.cfg.DisableWorkspaceCreation {
writeError(w, http.StatusForbidden, "workspace creation is disabled for this instance")
return
}
var req CreateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.Name = strings.TrimSpace(req.Name)
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
if !workspaceSlugPattern.MatchString(req.Slug) {
writeError(w, http.StatusBadRequest, "slug must contain only lowercase letters, numbers, and hyphens")
return
}
if isReservedSlug(req.Slug) {
writeError(w, http.StatusBadRequest, "slug is reserved")
return
}
// Resolve the prefix before opening the transaction: an invalid one is a
// 400 that should never cost a connection.
issuePrefix := ""
if req.IssuePrefix != nil {
prefix, ok := normalizeIssuePrefix(*req.IssuePrefix)
if !ok {
writeError(w, http.StatusBadRequest, issuePrefixFormatError)
return
}
issuePrefix = prefix
}
if issuePrefix == "" {
issuePrefix = defaultIssuePrefixFromSlug(req.Slug)
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
Name: req.Name,
Slug: req.Slug,
Description: ptrToText(req.Description),
Context: ptrToText(req.Context),
IssuePrefix: issuePrefix,
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "workspace slug already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
return
}
_, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{
WorkspaceID: ws.ID,
UserID: parseUUID(userID),
Role: "owner",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to add owner: "+err.Error())
return
}
// Seed the 7 built-in issue statuses inside the same transaction, so a
// workspace is never visible without its status catalog — an issue cannot
// be created before its status can be resolved. (MUL-6243)
if err := issuestatus.Ensure(r.Context(), qtx, ws.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to seed issue statuses: "+err.Error())
return
}
// NOTE: CreateWorkspace deliberately does NOT mark the user as
// onboarded. The `onboarded_at` flag is owned by CompleteOnboarding
// (Step 3 of the flow) and by AcceptInvitation (invitee joining an
// existing workspace). This decouples "the user has a workspace"
// from "the user has finished setup"; the workspace-layer route
// gate (web layout / desktop App.tsx overlay) redirects un-onboarded
// users back to /onboarding instead.
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create workspace")
return
}
wsID := uuidToString(ws.ID)
// "Is this the user's first workspace?" is derived in PostHog by looking
// at whether they have a prior workspace_created event, not stamped at
// emit time. Stamping here would race under concurrent creates without
// a schema change, and the event stream answers the question exactly.
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.WorkspaceCreated(userID, wsID))
h.notifyDaemonWorkspacesChanged(userID)
slog.Info("workspace created", append(logger.RequestAttrs(r), "workspace_id", wsID, "name", ws.Name, "slug", ws.Slug)...)
writeJSON(w, http.StatusCreated, h.workspaceToResponse(ws))
}
type UpdateWorkspaceRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Context *string `json:"context"`
Settings any `json:"settings"`
Repos any `json:"repos"`
IssuePrefix *string `json:"issue_prefix"`
AvatarURL *string `json:"avatar_url"`
}
type workspaceRepoRef struct {
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
func validateAndNormalizeWorkspaceRepos(value any) ([]byte, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var repos []workspaceRepoRef
if err := json.Unmarshal(raw, &repos); err != nil {
return nil, fmt.Errorf("repos must be an array of repository objects: %w", err)
}
normalized := make([]workspaceRepoRef, 0, len(repos))
seen := make(map[string]struct{}, len(repos))
for i, repo := range repos {
repo.URL = strings.TrimSpace(repo.URL)
repo.Description = strings.TrimSpace(repo.Description)
if repo.URL == "" {
return nil, fmt.Errorf("repos[%d]: url is required", i)
}
if !isValidGitRepoURL(repo.URL) {
return nil, fmt.Errorf("repos[%d]: url must be a valid http(s) or ssh git URL", i)
}
if _, ok := seen[repo.URL]; ok {
continue
}
seen[repo.URL] = struct{}{}
normalized = append(normalized, repo)
}
out, err := json.Marshal(normalized)
if err != nil {
return nil, err
}
return out, nil
}
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
id := workspaceIDFromURL(r, "id")
idUUID, ok := parseUUIDOrBadRequest(w, id, "workspace id")
if !ok {
return
}
var req UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateWorkspaceParams{
ID: idUUID,
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
params.Name = pgtype.Text{String: name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Context != nil {
params.Context = pgtype.Text{String: *req.Context, Valid: true}
}
if req.Settings != nil {
s, _ := json.Marshal(req.Settings)
params.Settings = s
}
if req.Repos != nil {
reposJSON, err := validateAndNormalizeWorkspaceRepos(req.Repos)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
params.Repos = reposJSON
}
if req.IssuePrefix != nil {
prefix, ok := normalizeIssuePrefix(*req.IssuePrefix)
if !ok {
writeError(w, http.StatusBadRequest, issuePrefixFormatError)
return
}
if prefix != "" {
params.IssuePrefix = pgtype.Text{String: prefix, Valid: true}
}
}
if req.AvatarURL != nil {
// Read the stored value so an unchanged re-send skips revalidation —
// this handler is the one avatar writer that doesn't already have the
// row in hand.
var current string
if existing, err := h.Queries.GetWorkspace(r.Context(), idUUID); err == nil {
current = existing.AvatarUrl.String
}
accepted, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, current)
if !ok {
return
}
params.AvatarUrl = pgtype.Text{String: accepted, Valid: true}
}
ws, err := h.Queries.UpdateWorkspace(r.Context(), params)
if err != nil {
slog.Warn("update workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", id)...)
writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error())
return
}
slog.Info("workspace updated", append(logger.RequestAttrs(r), "workspace_id", id)...)
userID := requestUserID(r)
h.publish(protocol.EventWorkspaceUpdated, uuidToString(ws.ID), "member", userID, map[string]any{"workspace": h.workspaceToResponse(ws)})
// A rename changes what daemons display; a settings edit changes how they
// behave — the GitHub master switch and the Co-authored-by toggle are read
// from this JSONB. Daemons cache settings and have no other way to learn
// they moved, so both edits have to wake every member's daemons.
if req.Name != nil || req.Settings != nil {
if members, err := h.Queries.ListMembers(r.Context(), ws.ID); err == nil {
userIDs := make([]string, 0, len(members))
for _, member := range members {
userIDs = append(userIDs, uuidToString(member.UserID))
}
h.notifyDaemonWorkspacesChanged(userIDs...)
}
}
writeJSON(w, http.StatusOK, h.workspaceToResponse(ws))
}
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
workspaceID := chi.URLParam(r, "id")
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
if !ok {
return
}
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
}
resp := make([]MemberResponse, len(members))
for i, m := range members {
resp[i] = memberToResponse(m)
}
writeJSON(w, http.StatusOK, resp)
}
type MemberWithUserResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
CreatedAt string `json:"created_at"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
}
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
members, err := h.Queries.ListMembersWithUser(r.Context(), wsUUID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list members")
return
}
resp := make([]MemberWithUserResponse, len(members))
for i, m := range members {
resp[i] = MemberWithUserResponse{
ID: uuidToString(m.ID),
WorkspaceID: uuidToString(m.WorkspaceID),
UserID: uuidToString(m.UserID),
Role: m.Role,
CreatedAt: timestampToString(m.CreatedAt),
Name: m.UserName,
Email: m.UserEmail,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(m.UserAvatarUrl)),
}
}
writeJSON(w, http.StatusOK, resp)
}
type CreateMemberRequest struct {
Email string `json:"email"`
Role string `json:"role"`
}
func (h *Handler) memberWithUserResponse(member db.Member, user db.User) MemberWithUserResponse {
return MemberWithUserResponse{
ID: uuidToString(member.ID),
WorkspaceID: uuidToString(member.WorkspaceID),
UserID: uuidToString(member.UserID),
Role: member.Role,
CreatedAt: timestampToString(member.CreatedAt),
Name: user.Name,
Email: user.Email,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(user.AvatarUrl)),
}
}
func normalizeMemberRole(role string) (string, bool) {
if role == "" {
return "member", true
}
role = strings.TrimSpace(role)
switch role {
case "owner", "admin", "member":
return role, true
default:
return "", false
}
}
type UpdateMemberRequest struct {
Role string `json:"role"`
}
func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
memberID := chi.URLParam(r, "memberId")
memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
if !ok {
return
}
target, err := h.Queries.GetMember(r.Context(), memberUUID)
if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
writeError(w, http.StatusNotFound, "member not found")
return
}
var req UpdateMemberRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.Role) == "" {
writeError(w, http.StatusBadRequest, "role is required")
return
}
role, valid := normalizeMemberRole(req.Role)
if !valid {
writeError(w, http.StatusBadRequest, "invalid member role")
return
}
if (target.Role == "owner" || role == "owner") && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if target.Role == "owner" && role != "owner" {
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update member")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
updatedMember, err := h.Queries.UpdateMemberRole(r.Context(), db.UpdateMemberRoleParams{
ID: target.ID,
Role: role,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update member")
return
}
h.MembershipCache.Invalidate(r.Context(), uuidToString(target.UserID), workspaceID)
user, err := h.Queries.GetUser(r.Context(), updatedMember.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load member")
return
}
userID := requestUserID(r)
h.publish(protocol.EventMemberUpdated, uuidToString(requester.WorkspaceID), "member", userID, map[string]any{
"member": h.memberWithUserResponse(updatedMember, user),
})
writeJSON(w, http.StatusOK, h.memberWithUserResponse(updatedMember, user))
}
func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
memberID := chi.URLParam(r, "memberId")
memberUUID, ok := parseUUIDOrBadRequest(w, memberID, "member id")
if !ok {
return
}
target, err := h.Queries.GetMember(r.Context(), memberUUID)
if err != nil || uuidToString(target.WorkspaceID) != uuidToString(requester.WorkspaceID) {
writeError(w, http.StatusNotFound, "member not found")
return
}
if target.Role == "owner" && requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
if target.Role == "owner" {
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete member")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
requesterUserID := requestUserID(r)
result, err := h.revokeAndRemoveMember(r.Context(), target.WorkspaceID, target.UserID, target.ID, parseUUID(requesterUserID))
if err != nil {
slog.Warn("delete member failed", append(logger.RequestAttrs(r), "error", err, "member_id", memberID, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete member")
return
}
h.settleMemberCapacityRelease(r.Context(), uuid.UUID(target.WorkspaceID.Bytes), uuid.UUID(target.ID.Bytes))
h.MembershipCache.Invalidate(r.Context(), uuidToString(target.UserID), workspaceID)
wsIDStr := uuidToString(requester.WorkspaceID)
logRevocation(result, wsIDStr, uuidToString(target.UserID))
h.publishRevocation(r.Context(), result, wsIDStr, "member", requesterUserID)
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(target.ID), "workspace_id", workspaceID, "user_id", uuidToString(target.UserID))...)
h.publish(protocol.EventMemberRemoved, wsIDStr, "member", requesterUserID, map[string]any{
"member_id": uuidToString(target.ID),
"workspace_id": wsIDStr,
"user_id": uuidToString(target.UserID),
})
h.notifyDaemonWorkspacesChanged(uuidToString(target.UserID))
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
if member.Role == "owner" {
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
return
}
if countOwners(members) <= 1 {
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
return
}
}
result, err := h.revokeAndRemoveMember(r.Context(), member.WorkspaceID, member.UserID, member.ID, member.UserID)
if err != nil {
slog.Warn("leave workspace failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
return
}
h.settleMemberCapacityRelease(r.Context(), uuid.UUID(member.WorkspaceID.Bytes), uuid.UUID(member.ID.Bytes))
h.MembershipCache.Invalidate(r.Context(), uuidToString(member.UserID), workspaceID)
userID := requestUserID(r)
logRevocation(result, workspaceID, uuidToString(member.UserID))
h.publishRevocation(r.Context(), result, workspaceID, "member", userID)
slog.Info("member removed", append(logger.RequestAttrs(r), "member_id", uuidToString(member.ID), "workspace_id", workspaceID, "user_id", uuidToString(member.UserID))...)
h.publish(protocol.EventMemberRemoved, workspaceID, "member", userID, map[string]any{
"member_id": uuidToString(member.ID),
"workspace_id": workspaceID,
"user_id": uuidToString(member.UserID),
})
h.notifyDaemonWorkspacesChanged(uuidToString(member.UserID))
w.WriteHeader(http.StatusNoContent)
}
// workspaceDeleteLockTimeout bounds every lock wait inside the workspace
// teardown transaction.
//
// The teardown waits on three kinds of lock: the workspace row (FOR UPDATE),
// every chat_session row in the workspace (FOR UPDATE), and the global
// advisory lock 4246 that serialises it against the task_usage_hourly rollup.
// The advisory lock is the dangerous one — it is global and batch-held. A
// rollup tick may hold it for as long as its 25 min scheduler RunTimeout, the
// backfill commands (cmd/backfill_task_usage_hourly, cmd/backfill_codex_usage_cache)
// hold it for an entire run, and before migration 272 a tick whose query was
// cancelled leaked it for the remaining life of its pooled connection.
//
// Waiting on that lock without a cap is what the user sees as "delete does
// nothing": the request never returns, and no layer above it times out — the
// browser/Electron fetch in packages/core/api/client.ts has no deadline and
// the delete dialog stays in its "Deleting…" state forever (MUL-5983). A
// bounded wait turns the same contention into a retryable error.
//
// 10 s is far above the millisecond-scale waits an uncontended teardown sees,
// and short enough to fail while the user is still watching the dialog.
const workspaceDeleteLockTimeout = 10 * time.Second
// workspaceDeleteLockTimeoutOverride, when non-zero, replaces
// workspaceDeleteLockTimeout for the duration of a test. Never read outside
// effectiveWorkspaceDeleteLockTimeout — see workspace_delete_lock_test.go.
var workspaceDeleteLockTimeoutOverride time.Duration
func effectiveWorkspaceDeleteLockTimeout() time.Duration {
if workspaceDeleteLockTimeoutOverride > 0 {
return workspaceDeleteLockTimeoutOverride
}
return workspaceDeleteLockTimeout
}
// isRetryableLockFailure reports whether err is one of the two transient lock
// outcomes a teardown can hit: lock_not_available (55P03), which is what
// `SET LOCAL lock_timeout` raises when a wait exceeds its budget, and
// deadlock_detected (40P01), which the owner-row locks can hit against a
// concurrent task update that takes the same locks in the opposite order (task
// row first, then its owner). Neither says anything about the workspace itself:
// the transaction rolled back untouched and the caller can retry.
func isRetryableLockFailure(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "55P03" || pgErr.Code == "40P01"
}
return false
}
// failWorkspaceDelete logs a failed teardown step and writes the response for
// it. A lock timeout or deadlock is transient and retryable, so it answers 503
// with a message the delete dialog can show; every other failure stays a 500.
func failWorkspaceDelete(w http.ResponseWriter, r *http.Request, workspaceID, step string, err error) {
attrs := append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID, "step", step)
if isRetryableLockFailure(err) {
slog.Warn("workspace delete blocked by lock contention", attrs...)
writeError(w, http.StatusServiceUnavailable, "workspace deletion is temporarily blocked by another operation, please try again")
return
}
slog.Warn("workspace delete step failed", attrs...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
}
// workspaceDeleteTaskPageSize bounds one task page: the ids held in this process,
// the array parameter sent back to Postgres, and the row count of a single
// DELETE. A whole workspace's task set is not bounded by anything — one busy
// agent can own millions of historical rows — so it must never be materialized at
// once (MUL-5999 review).
const workspaceDeleteTaskPageSize = 1000
// workspaceDeleteOwnerPageSize bounds owner enumeration the same way. A workspace
// with a very large agent or issue set must not have its whole id list held here
// either.
const workspaceDeleteOwnerPageSize = 500
// workspaceDeleteVerifyPasses caps how many times a single owner may be swept.
//
// Pass 1 removes what is there; pass 2 confirms the owner came back empty. This is
// a cheap self-check, NOT the concurrency guarantee: a task inserted after the
// final empty scan and before COMMIT would not be caught by any number of passes.
// What closes that window is the in-transaction fence on task writes
// (migration 284) plus the workspace row lock teardown already holds. The passes
// exist so that anything unexpected — a path that somehow bypassed the fence — is
// either cleaned up or turns into a failed, rolled-back teardown rather than a
// tenant with rows left behind.
const workspaceDeleteVerifyPasses = 3
// deleteWorkspaceTasks removes every agent_task_queue row a workspace owns,
// together with the rows that hang off those tasks, in bounded keyset pages.
//
// Shape, and why:
//
// - One owner key per query. agent_task_queue has no workspace_id and all three
// ownership paths must stay (nothing enforces that a task's agent, runtime and
// issue share a workspace), but combining them in one statement — as the
// previous three-way OR, as a UNION of joins, or as `= ANY(array)` over a
// whole workspace's agents — leaves the planner estimating a proportional
// share of the global table and choosing a Seq Scan.
// - Keyset pages, so the SCAN is bounded and not just the result. Each page is
// an index range scan that resumes where the previous one stopped. Bounding
// only the result (`ORDER BY id LIMIT n` with no id in the index) puts a Sort
// over the owner's whole task set in front of every page, which costs roughly
// O(N² / page) for a busy owner.
// - Row locks, not a read snapshot. Each page takes FOR UPDATE, so under READ
// COMMITTED Postgres re-checks ownership after acquiring each lock: a task a
// concurrent commit already moved elsewhere is skipped rather than deleted on
// a stale claim, and a task we locked cannot move before we delete it.
// - Children first, in the application layer. DetachTaskBatchReferences breaks
// inbound references and DeleteTaskBatch removes task_usage, task_message,
// task_token, the two outbound card tables and chat_draft_restore before the
// task rows themselves. The legacy FK cascades stay a safety net only.
//
// Late arrivals are handled by the fence, not here: migration 284 makes every task
// write take FOR KEY SHARE on its owners' workspace rows, which conflicts with the
// FOR UPDATE that LockWorkspaceForDelete holds for this whole transaction.
func deleteWorkspaceTasks(ctx context.Context, qtx *db.Queries, workspaceID pgtype.UUID) error {
ownerKinds := []struct {
label string
ownerFirstPage func(context.Context, int32) ([]pgtype.UUID, error)
ownerPage func(context.Context, pgtype.UUID, int32) ([]pgtype.UUID, error)
taskFirstPage func(context.Context, pgtype.UUID, int32) ([]pgtype.UUID, error)
taskPage func(context.Context, pgtype.UUID, pgtype.UUID, int32) ([]pgtype.UUID, error)
sweepAgent bool
}{
{
label: "agent",
ownerFirstPage: func(ctx context.Context, limit int32) ([]pgtype.UUID, error) {
return qtx.ListWorkspaceAgentIDFirstPage(ctx, db.ListWorkspaceAgentIDFirstPageParams{
WorkspaceID: workspaceID, Limit: limit,
})
},
ownerPage: func(ctx context.Context, cursor pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListWorkspaceAgentIDPage(ctx, db.ListWorkspaceAgentIDPageParams{
WorkspaceID: workspaceID, ID: cursor, Limit: limit,
})
},
taskFirstPage: func(ctx context.Context, owner pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListTaskIDsByAgentFirstPage(ctx, db.ListTaskIDsByAgentFirstPageParams{
AgentID: owner, Limit: limit,
})
},
taskPage: func(ctx context.Context, owner, cursor pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListTaskIDsByAgentPage(ctx, db.ListTaskIDsByAgentPageParams{
AgentID: owner, ID: cursor, Limit: limit,
})
},
// The third explicit task_token path. workspace_id (leaf data) and
// task_id (DeleteTaskBatch) leave one shape uncovered: a token whose
// own workspace_id points at a neighbour while its agent lives here.
sweepAgent: true,
},
{
label: "issue",
ownerFirstPage: func(ctx context.Context, limit int32) ([]pgtype.UUID, error) {
return qtx.ListWorkspaceIssueIDFirstPage(ctx, db.ListWorkspaceIssueIDFirstPageParams{
WorkspaceID: workspaceID, Limit: limit,
})
},
ownerPage: func(ctx context.Context, cursor pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListWorkspaceIssueIDPage(ctx, db.ListWorkspaceIssueIDPageParams{
WorkspaceID: workspaceID, ID: cursor, Limit: limit,
})
},
taskFirstPage: func(ctx context.Context, owner pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListTaskIDsByIssueFirstPage(ctx, db.ListTaskIDsByIssueFirstPageParams{
IssueID: owner, Limit: limit,
})
},
taskPage: func(ctx context.Context, owner, cursor pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListTaskIDsByIssuePage(ctx, db.ListTaskIDsByIssuePageParams{
IssueID: owner, ID: cursor, Limit: limit,
})
},
},
{
label: "runtime",
ownerFirstPage: func(ctx context.Context, limit int32) ([]pgtype.UUID, error) {
return qtx.ListWorkspaceRuntimeIDFirstPage(ctx, db.ListWorkspaceRuntimeIDFirstPageParams{
WorkspaceID: workspaceID, Limit: limit,
})
},
ownerPage: func(ctx context.Context, cursor pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListWorkspaceRuntimeIDPage(ctx, db.ListWorkspaceRuntimeIDPageParams{
WorkspaceID: workspaceID, ID: cursor, Limit: limit,
})
},
taskFirstPage: func(ctx context.Context, owner pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListTaskIDsByRuntimeFirstPage(ctx, db.ListTaskIDsByRuntimeFirstPageParams{
RuntimeID: owner, Limit: limit,
})
},
taskPage: func(ctx context.Context, owner, cursor pgtype.UUID, limit int32) ([]pgtype.UUID, error) {
return qtx.ListTaskIDsByRuntimePage(ctx, db.ListTaskIDsByRuntimePageParams{
RuntimeID: owner, ID: cursor, Limit: limit,
})
},
},
}
for _, kind := range ownerKinds {
owners, err := kind.ownerFirstPage(ctx, workspaceDeleteOwnerPageSize)
if err != nil {
return fmt.Errorf("list workspace %ss: %w", kind.label, err)
}
for len(owners) > 0 {
for _, ownerID := range owners {
if err := sweepTasksForOwner(ctx, kind.label, ownerID,
kind.taskFirstPage, kind.taskPage,
qtx.DetachTaskBatchReferences, qtx.DeleteTaskBatch); err != nil {
return err
}
if kind.sweepAgent {
if err := qtx.DeleteTaskTokensByAgent(ctx, ownerID); err != nil {
return fmt.Errorf("delete task tokens by agent: %w", err)
}
}
}
if owners, err = kind.ownerPage(ctx, owners[len(owners)-1], workspaceDeleteOwnerPageSize); err != nil {
return fmt.Errorf("list workspace %ss: %w", kind.label, err)
}
}
}
return nil
}
// sweepTasksForOwner deletes one owner's tasks, then re-verifies that owner.
//
// Each pass walks the owner's tasks by keyset and deletes each page. A pass that
// deletes nothing means the owner is clean. A later pass that still finds tasks
// means something reached agent_task_queue without going through the fence, which
// is logged and retried; exhausting the passes fails the whole teardown rather
// than committing a workspace with tasks left behind.
func sweepTasksForOwner(
ctx context.Context,
label string,
ownerID pgtype.UUID,
taskFirstPage func(context.Context, pgtype.UUID, int32) ([]pgtype.UUID, error),
taskPage func(context.Context, pgtype.UUID, pgtype.UUID, int32) ([]pgtype.UUID, error),
detach func(context.Context, []pgtype.UUID) error,
deletePage func(context.Context, []pgtype.UUID) error,
) error {
for pass := 1; pass <= workspaceDeleteVerifyPasses; pass++ {
deleted := 0
taskIDs, err := taskFirstPage(ctx, ownerID, workspaceDeleteTaskPageSize)
if err != nil {
return fmt.Errorf("list tasks by %s: %w", label, err)
}
for len(taskIDs) > 0 {
if err := detach(ctx, taskIDs); err != nil {
return fmt.Errorf("detach task page references: %w", err)
}
if err := deletePage(ctx, taskIDs); err != nil {
return fmt.Errorf("delete task page: %w", err)
}
deleted += len(taskIDs)
if taskIDs, err = taskPage(ctx, ownerID, taskIDs[len(taskIDs)-1], workspaceDeleteTaskPageSize); err != nil {
return fmt.Errorf("list tasks by %s: %w", label, err)
}
}
if deleted == 0 {
return nil
}
if pass > 1 {
slog.Warn("workspace delete swept tasks that appeared after the owner was fenced",
"owner_kind", label, "owner_id", uuidToString(ownerID), "pass", pass, "deleted", deleted)
}
}
return fmt.Errorf("tasks kept appearing for %s %s after %d sweep passes; refusing to commit a partial teardown",
label, uuidToString(ownerID), workspaceDeleteVerifyPasses)
}
// lockWorkspaceTaskOwners locks every row through which a task can belong to this
// workspace.
//
// Defence in depth on top of the real fence. The fence that closes the
// insert-after-sweep window is migration 284's lock_task_owner_rows, a predicate
// every ownership-writing statement calls in its own WHERE clause: it takes
// FOR KEY SHARE on the workspace rows behind the task's owners and then on the
// owner rows themselves, which conflicts with the FOR UPDATE that
// LockWorkspaceForDelete holds for this whole transaction. Called from the writing
// statement, so it is in the writer's transaction by construction, and independent
// of the legacy foreign keys.
//
// These owner-row locks add a second, narrower barrier — they also block
// ownership-changing UPDATEs of tasks that already exist — and cost nothing to
// hold, since teardown deletes these very rows a few steps later. They are taken
// set-based and return no ids, so a workspace with a huge owner set costs no
// memory here.
//
// The wait is bounded by SET LOCAL lock_timeout, and taking these locks can
// deadlock against a concurrent task update that locks in the opposite order
// (task row, then owner); both outcomes surface as a retryable 503.
func lockWorkspaceTaskOwners(ctx context.Context, qtx *db.Queries, workspaceID pgtype.UUID) error {
if err := qtx.LockWorkspaceTaskOwnerAgents(ctx, workspaceID); err != nil {
return fmt.Errorf("lock workspace agents: %w", err)
}
if err := qtx.LockWorkspaceTaskOwnerIssues(ctx, workspaceID); err != nil {
return fmt.Errorf("lock workspace issues: %w", err)
}
if err := qtx.LockWorkspaceTaskOwnerRuntimes(ctx, workspaceID); err != nil {
return fmt.Errorf("lock workspace runtimes: %w", err)
}
return nil
}
func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := workspaceIDFromURL(r, "id")
// Defense in depth: the route is already gated by the
// RequireWorkspaceRoleFromURL("owner") middleware, but we re-check here
// so that the handler is safe regardless of how it gets wired up
// (direct calls in tests, future router refactors, etc.).
requester, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return
}
if requester.Role != "owner" {
writeError(w, http.StatusForbidden, "insufficient permissions")
return
}
// Invalidate membership cache for all workspace members before deletion.
// After CASCADE deletes the member rows, cache entries become harmless
// orphans (downstream lookups for the deleted workspace will fail), but
// proactive invalidation prevents any stale-access window up to TTL.
var affectedUserIDs []string
if members, err := h.Queries.ListMembers(r.Context(), requester.WorkspaceID); err == nil {
affectedUserIDs = make([]string, 0, len(members))
for _, m := range members {
userID := uuidToString(m.UserID)
h.MembershipCache.Invalidate(r.Context(), userID, workspaceID)
affectedUserIDs = append(affectedUserIDs, userID)
}
}
// The teardown runs in one transaction so the chat_session row locks below
// are still held when DeleteWorkspace sweeps chat_draft_restore. Without
// them, FinalizeDeferredCancelledChat could commit a restore for one of
// these sessions after the sweep's snapshot was taken: the session cascades
// away, the restore has no FK to follow it (MUL-3515) and no reaper, and the
// user's prompt is stranded forever (#5219). The finalizer takes the same
// lock before inserting, so it either blocks until the session is gone and
// skips the insert, or commits first and the sweep sees its row.
//
// The workspace row is locked first, because the session locks only cover
// sessions that already exist: a CreateChatSession committing inside the
// delete window would otherwise slip in a session nobody locked, and its
// restore would outlive the cascade the same way. Holding the workspace row
// FOR UPDATE blocks that insert on its workspace FK (FOR KEY SHARE).
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
slog.Warn("begin workspace delete tx failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
var sourceContextAttachmentURLs []string
var sourceContextIntentURLs []string
// SET LOCAL is transaction-scoped, so pgxpool hands this connection back
// out with the default (unbounded) lock_timeout after COMMIT / ROLLBACK.
// It caps waiting for a lock, not the teardown work itself — deleting a
// large workspace is allowed to take as long as it takes.
lockTimeoutMs := int(effectiveWorkspaceDeleteLockTimeout() / time.Millisecond)
if _, err := tx.Exec(r.Context(), fmt.Sprintf("SET LOCAL lock_timeout = %d", lockTimeoutMs)); err != nil {
failWorkspaceDelete(w, r, workspaceID, "set lock timeout", err)
return
}
if _, err := qtx.LockWorkspaceForDelete(r.Context(), requester.WorkspaceID); err != nil {
failWorkspaceDelete(w, r, workspaceID, "lock workspace", err)
return
}
// Take a best-effort snapshot for post-commit daemon invalidation. Runtime
// registration does not participate in the workspace delete lock protocol,
// so PR1 retains the heartbeat lookup as the correctness fallback for a
// registration that races this snapshot.
runtimeIDs, err := qtx.ListAgentRuntimeIDsByWorkspace(r.Context(), requester.WorkspaceID)
if err != nil {
failWorkspaceDelete(w, r, workspaceID, "list runtimes", err)
return
}
if _, err := qtx.LockChatSessionsByWorkspace(r.Context(), requester.WorkspaceID); err != nil {
failWorkspaceDelete(w, r, workspaceID, "lock chat sessions", err)
return
}
if sourceContextAttachmentURLs, err = qtx.ListSourceContextAttachmentURLsByWorkspace(r.Context(), requester.WorkspaceID); err != nil {
failWorkspaceDelete(w, r, workspaceID, "list source context attachment objects", err)
return
}
if sourceContextIntentURLs, err = qtx.ListSourceContextObjectIntentURLsByWorkspace(r.Context(), requester.WorkspaceID); err != nil {
failWorkspaceDelete(w, r, workspaceID, "list source context pending objects", err)
return
}
// Keep the relationship graph in the application layer. Each step is a
// set-based delete scoped by workspace_id; the legacy cascades remain only
// as an expand-phase safety net until a later schema contract.
ctx := r.Context()
deleteSteps := []struct {
name string
run func() error
}{
{
name: "set teardown mode",
run: func() error { return qtx.SetWorkspaceTeardownMode(ctx) },
},
{
// Fences task enqueue / reassignment for the rest of the
// transaction. Must run before anything sweeps tasks — see
// lockWorkspaceTaskOwners.
name: "lock task owners",
run: func() error { return lockWorkspaceTaskOwners(ctx, qtx, requester.WorkspaceID) },
},
{
name: "prepare relationship graph",
run: func() error { return qtx.PrepareWorkspaceDeletionLinks(ctx, requester.WorkspaceID) },
},
{
// These FK-free intents deliberately survive the transaction so the
// worker can release every invitation/member seat after local rows
// disappear. Skipped for self-hosted/unmanaged deployments.
name: "prepare seat capacity release",
run: func() error {
if !h.seatCapacityEnabled() {
return nil
}
if err := qtx.DeleteSeatCapacityConfirmIntentsForWorkspaceDeletion(ctx, requester.WorkspaceID); err != nil {
return err
}
if err := qtx.PrepareSeatCapacityOperationReleasesForWorkspaceDeletion(ctx, requester.WorkspaceID); err != nil {
return err
}
if err := qtx.PrepareSeatCapacityInvitationReleasesForWorkspaceDeletion(ctx, requester.WorkspaceID); err != nil {
return err
}
return qtx.PrepareSeatCapacityMemberReleasesForWorkspaceDeletion(ctx, requester.WorkspaceID)
},
},
{
name: "delete chat pins",
run: func() error { return qtx.DeleteChatPinnedAgentsByWorkspace(ctx, requester.WorkspaceID) },
},
{
// This is the first stage that touches usage rollups. Keep the
// global rollup lock out of relationship preparation so unrelated
// workspaces skip the shortest possible rollup window. This wait
// is bounded by workspaceDeleteLockTimeout — 4246 is held by
// batch jobs, so an unbounded wait here is what hangs the delete.
name: "lock task usage rollup",
run: func() error { return qtx.LockTaskUsageRollupForWorkspaceDelete(ctx) },
},
{
// Bounded batches, after the rollup lock because it deletes
// task_usage. Replaces both the task-keyed arms of the old leaf
// data statement and the separate whole-workspace task delete.
name: "delete tasks",
run: func() error { return deleteWorkspaceTasks(ctx, qtx, requester.WorkspaceID) },
},
{
name: "delete leaf data",
run: func() error { return qtx.DeleteWorkspaceLeafData(ctx, requester.WorkspaceID) },
},
{
name: "delete autopilot runs",
run: func() error { return qtx.DeleteWorkspaceAutopilotRuns(ctx, requester.WorkspaceID) },
},
{
name: "delete autopilot quota reservations",
run: func() error { return qtx.DeleteWorkspaceAutopilotQuotaReservations(ctx, requester.WorkspaceID) },
},
{
name: "delete autopilot quota periods",
run: func() error { return qtx.DeleteWorkspaceAutopilotQuotaPeriods(ctx, requester.WorkspaceID) },
},
{
name: "delete chat messages",
run: func() error { return qtx.DeleteWorkspaceChatMessages(ctx, requester.WorkspaceID) },
},
{
name: "delete communication roots",
run: func() error { return qtx.DeleteWorkspaceCommunicationRoots(ctx, requester.WorkspaceID) },
},
{
name: "delete comments",
run: func() error { return qtx.DeleteWorkspaceComments(ctx, requester.WorkspaceID) },
},
// Keep source-context object intents after the workspace row is gone.
// They are the durable retry ledger for an upload that began before the
// workspace lock but completed after this transaction's immediate object
// deletion pass. With no attachment left, the reconciler will delete the
// object and then the intent; deleting the ledger here would make that
// crash window permanently leak the late object.
{
name: "delete source context attachments",
run: func() error { return qtx.DeleteSourceContextAttachmentsByWorkspace(ctx, requester.WorkspaceID) },
},
{
name: "delete source contexts",
run: func() error {
_, err := qtx.DeleteIssueSourceContextsForWorkspace(ctx, requester.WorkspaceID)
return err
},
},
{
name: "delete issue roots",
run: func() error { return qtx.DeleteWorkspaceIssueRoots(ctx, requester.WorkspaceID) },
},
{
// issue_status carries no foreign key by project rule, so its rows
// are swept explicitly. Placed after the issue deletes so no issue
// row outlives the catalog its status key resolves against.
// (MUL-6243)
name: "delete issue statuses",
run: func() error {
return qtx.DeleteIssueStatusEntriesForWorkspace(ctx, requester.WorkspaceID)
},
},
{
name: "delete autopilot children",
run: func() error { return qtx.DeleteWorkspaceAutopilotChildren(ctx, requester.WorkspaceID) },
},
{
name: "delete autopilots",
run: func() error { return qtx.DeleteWorkspaceAutopilots(ctx, requester.WorkspaceID) },
},
{
name: "delete pull requests",
run: func() error { return qtx.DeleteWorkspacePullRequests(ctx, requester.WorkspaceID) },
},
{
name: "delete integrations",
run: func() error { return qtx.DeleteWorkspaceConnections(ctx, requester.WorkspaceID) },
},
{
name: "delete squads and skills",
run: func() error { return qtx.DeleteWorkspaceSquadsAndSkills(ctx, requester.WorkspaceID) },
},
{
name: "delete plugin data",
run: func() error { return qtx.DeleteWorkspacePluginData(ctx, requester.WorkspaceID) },
},
{
name: "delete agents",
run: func() error { return qtx.DeleteWorkspaceAgents(ctx, requester.WorkspaceID) },
},
{
name: "delete runtimes and projects",
run: func() error { return qtx.DeleteWorkspaceRuntimesAndProjects(ctx, requester.WorkspaceID) },
},
{
name: "delete administration data",
run: func() error { return qtx.DeleteWorkspaceAdministration(ctx, requester.WorkspaceID) },
},
{
// At this point workspaceMember has resolved → workspaceID is a
// valid UUID, so reuse the resolved value. The existing final
// statement also sweeps any expand-phase compatibility leftovers.
name: "delete workspace",
run: func() error { return qtx.DeleteWorkspace(ctx, requester.WorkspaceID) },
},
}
for _, step := range deleteSteps {
if err := step.run(); err != nil {
failWorkspaceDelete(w, r, workspaceID, step.name, err)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("commit workspace delete failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...)
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
return
}
for _, runtimeID := range runtimeIDs {
h.NotifyRuntimeGone(uuidToString(runtimeID))
}
h.deleteS3Objects(r.Context(), append(sourceContextAttachmentURLs, sourceContextIntentURLs...))
slog.Info("workspace deleted", append(logger.RequestAttrs(r), "workspace_id", workspaceID)...)
h.publish(protocol.EventWorkspaceDeleted, workspaceID, "member", requestUserID(r), map[string]any{
"workspace_id": workspaceID,
})
h.notifyDaemonWorkspacesChanged(affectedUserIDs...)
w.WriteHeader(http.StatusNoContent)
}