Files
kandev/apps/backend/internal/task/service/create_task_references.go
T

139 lines
5.9 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.
//
// Dependency edges are removed first. blocked_by is written one edge at a
// time, so a failure on the second entry leaves the first already persisted,
// and task_blockers predates the tasks foreign key — nothing cascades. Rolling
// back only the task row would trade an orphan task for an orphan edge
// pointing at a task that no longer exists.
func (s *Service) rollbackPartialTask(ctx context.Context, taskID string, cause error) error {
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
s.deleteDependencyEdgesForTask(rollbackCtx, taskID)
if err := s.tasks.DeleteTask(rollbackCtx, taskID); err != nil {
s.logger.Error("rollback delete failed; task left in inconsistent state",
zap.String("task_id", taskID), zap.Error(err))
}
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
}