Files
kandev/apps/backend/internal/task/service/create_task_references.go
T
Clément Fiere 01b0dedd6c fix: harden task archive lifecycle recovery (#3651)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-14 16:34:25 +01:00

147 lines
6.1 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"time"
"go.uber.org/zap"
taskrepo "github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
)
// ErrTaskReferenceNotFound marks a create rejected because a caller-supplied
// reference (a repository, a blocker) does not resolve.
//
// It exists so transports can classify the failure as VALIDATION rather than
// INTERNAL_ERROR. A bad id in the request is the caller's to fix, and telling
// them only "Failed to create task" — as this path used to — leaves the actual
// cause visible nowhere but the backend log, which most callers cannot read.
var ErrTaskReferenceNotFound = errors.New("task reference not found")
// unknownRepositoryReferenceError describes a repositories[i].repository_id
// that does not resolve, naming both the field and the offending value.
//
// The hint is not decoration: a task's repositories[] entry exposes BOTH an
// `id` (the task_repositories join row) and a `repository_id` (the repository
// itself), and passing the former is the mistake this error most often
// reports.
func unknownRepositoryReferenceError(index int, repositoryID string) error {
return fmt.Errorf(
"%w: unknown repositories[%d].repository_id %q; no repository with that id exists in this workspace. "+
"If you copied it from a task's repositories[] entry, pass repository_id (the repository) rather than id (the task-repository link)",
ErrTaskReferenceNotFound, index, repositoryID,
)
}
// unknownBlockerReferenceError describes a blocked_by entry that does not
// resolve to a task in this workspace.
func unknownBlockerReferenceError(index int, blockerID string) error {
return fmt.Errorf(
"%w: unknown blocked_by[%d] %q; no task with that id exists in this workspace",
ErrTaskReferenceNotFound, index, blockerID,
)
}
// validateBlockerReferences checks every blocked_by id BEFORE the task row is
// inserted.
//
// AddBlocker remains the authoritative validator for dependency edges (see
// AddDependency's single-validator contract); this is not a second one. It
// resolves only what can be known without the new task existing — existence,
// authorization, and workspace agreement — so the request is rejected while
// there is still nothing to roll back. Cycle detection is deliberately absent:
// a task that does not exist yet has no outgoing edges and cannot close a
// cycle, and self-reference is impossible against an id not yet assigned.
func (s *Service) validateBlockerReferences(ctx context.Context, req *CreateTaskRequest) error {
if len(req.BlockedBy) == 0 {
return nil
}
if s.blockers == nil {
return ErrDependencyRepositoryUnavailable
}
for index, blockerID := range req.BlockedBy {
if blockerID == "" {
return fmt.Errorf("%w: blocked_by[%d] is empty", ErrTaskReferenceNotFound, index)
}
if err := s.authorizeTaskID(ctx, blockerID); err != nil {
// Denials surface as not-found so a caller cannot probe for the
// existence of another user's task, matching authorizeDependencyPair.
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return unknownBlockerReferenceError(index, blockerID)
}
return err
}
blocker, err := s.tasks.GetTask(ctx, blockerID)
if err != nil {
if errors.Is(err, taskrepo.ErrTaskNotFound) {
return unknownBlockerReferenceError(index, blockerID)
}
return fmt.Errorf("resolve blocked_by[%d]: %w", index, err)
}
if blocker == nil {
return unknownBlockerReferenceError(index, blockerID)
}
if req.WorkspaceID != "" && blocker.WorkspaceID != "" && blocker.WorkspaceID != req.WorkspaceID {
return fmt.Errorf(
"%w: blocked_by[%d] %q belongs to a different workspace",
ErrTaskReferenceNotFound, index, blockerID,
)
}
}
return nil
}
// rollbackPartialTask deletes a task whose post-insert writes failed, and
// returns the original error to surface.
//
// Pre-insert validation closes the ordinary case; this closes the TOCTOU
// remainder, where a reference was valid when checked and gone by the time it
// was written. It mirrors the rollback the MCP create handler already performs
// for remote-contribution association and workspace-policy attach. A failed
// rollback is logged, never substituted for the original error: the caller
// needs to know why the create failed, not why the cleanup did.
// Cleanup is detached from caller cancellation but remains bounded, because a
// canceled request can be the reason finalization failed in the first place.
//
// Rollback removes dependency edges for every path. The legacy direct-delete
// fallback removes them before the task row; lifecycle deletion performs the
// task mutation first, then the same edge cleanup runs against the committed
// result. task_blockers predates the tasks foreign key, so nothing cascades.
func (s *Service) rollbackPartialTask(ctx context.Context, taskID string, cause error) error {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
var deleteErr error
if s.taskLifecycleCoordinator != nil {
deleteErr = s.DeleteTaskWithLifecycle(rollbackCtx, taskID)
} else {
s.deleteDependencyEdgesForTask(rollbackCtx, taskID)
deleteErr = s.tasks.DeleteTask(rollbackCtx, taskID)
}
if deleteErr != nil {
s.logger.Error("rollback delete failed; task left in inconsistent state",
zap.String("task_id", taskID), zap.Error(deleteErr))
}
s.deleteDependencyEdgesForTask(rollbackCtx, taskID)
if deleteErr != nil {
if releaser, ok := s.workspacePolicyAttacher.(WorkspacePolicyMembershipReleaser); ok {
if err := releaser.ReleaseWorkspacePolicy(rollbackCtx, taskID, "create_rollback"); err != nil {
s.logger.Warn("rollback workspace membership cleanup failed",
zap.String("task_id", taskID), zap.Error(err))
}
}
}
return cause
}
// classifyRepositoryResolutionError converts a repository lookup failure into
// the caller-facing reference error, leaving anything else untouched.
func classifyRepositoryResolutionError(index int, repositoryID string, err error) error {
if errors.Is(err, repoerrors.ErrRepositoryNotFound) {
return unknownRepositoryReferenceError(index, repositoryID)
}
return err
}