Files
kandev/apps/backend/internal/task/planws/errors.go
T
2026-09-17 10:43:28 +01:00

260 lines
12 KiB
Go

// Package planws holds the WebSocket contract shared by Kandev's two
// task-plan surfaces: the browser-facing handlers in internal/task/handlers
// and the agent-facing MCP handlers in internal/mcp/handlers.
//
// Both surfaces drive the same *service.PlanService and are expected to report
// the same failure the same way, but neither package imports the other, so
// until now each carried its own copy of the sentinel mapping. Defining it once
// here is what keeps the two from drifting; the surfaces themselves stay
// separate on purpose, because their request shapes and success payloads
// legitimately differ.
package planws
import (
"errors"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
func safePlanErrorResponse(msg *ws.Message, err error) (*ws.Message, bool, error) {
var safety *service.PlanSafetyError
if !errors.As(err, &safety) {
return nil, false, nil
}
details := map[string]interface{}{
"reason": safety.Code,
"operation": msg.Action,
"task_id": safety.TaskID,
"write_applied": false,
"next_action": safety.NextAction,
}
if safety.CurrentVersion != "" {
details["current_version"] = safety.CurrentVersion
}
if safety.CurrentRevisionVersion != "" {
details["current_revision_version"] = safety.CurrentRevisionVersion
}
if safety.CurrentRevisionNumber > 0 {
details["current_revision_number"] = safety.CurrentRevisionNumber
}
if safety.ReplacedRunes > 0 {
details["replaced_runes"] = safety.ReplacedRunes
details["new_runes"] = safety.NewRunes
}
code := ws.ErrorCodeInternalError
switch safety.Code {
case service.PlanErrorVersionRequired, service.PlanErrorTruncationRejected,
service.PlanErrorAppendTruncationFlag,
service.PlanErrorContentRequired, service.PlanErrorEditTextRequired, service.PlanErrorEditNotFound,
service.PlanErrorEditAmbiguous, service.PlanErrorRevisionVersionRequired:
code = ws.ErrorCodeValidation
case service.PlanErrorVersionConflict, service.PlanErrorRevisionChanged:
code = ws.ErrorCodeConflict
}
response, responseErr := ws.NewError(msg.ID, msg.Action, code, safety.Message, details)
return response, true, responseErr
}
// TaskIDRequest is the payload for the plan actions keyed only by task, so the
// wire shape is declared once rather than re-spelled per surface.
type TaskIDRequest struct {
TaskID string `json:"task_id"`
}
// mapping pairs a PlanService sentinel with the WebSocket code and message
// reported for it.
type mapping struct {
sentinel error
code string
message string
}
// The task-plan error vocabulary. Messages are the ones the frontend and the
// MCP tools already receive, so editing one now moves both surfaces together.
var (
taskIDRequired = mapping{service.ErrTaskIDRequired, ws.ErrorCodeValidation, "task_id is required"}
taskNotFound = mapping{repository.ErrTaskNotFound, ws.ErrorCodeNotFound, "Task not found"}
sessionIDRequired = mapping{service.ErrSessionIDRequired, ws.ErrorCodeValidation, "session_id is required"}
sessionTaskMismatch = mapping{service.ErrSessionTaskMismatch, ws.ErrorCodeValidation, "Session does not belong to task"}
planNotFound = mapping{service.ErrTaskPlanNotFound, ws.ErrorCodeNotFound, "Task plan not found"}
revisionIDRequired = mapping{service.ErrRevisionIDRequired, ws.ErrorCodeValidation, "revision_id is required"}
revisionNotFound = mapping{service.ErrRevisionNotFound, ws.ErrorCodeNotFound, "Revision not found"}
revisionTaskMismatch = mapping{service.ErrRevisionTaskMismatch, ws.ErrorCodeValidation, "Revision does not belong to task"}
planIDRequired = mapping{service.ErrPlanIDRequired, ws.ErrorCodeValidation, "plan_id is required"}
commentIDRequired = mapping{service.ErrPlanCommentIDRequired, ws.ErrorCodeValidation, "comment id is required"}
commentIDInvalid = mapping{service.ErrPlanCommentIDInvalid, ws.ErrorCodeValidation, "comment id must be a UUID"}
commentBodyRequired = mapping{service.ErrPlanCommentBodyRequired, ws.ErrorCodeValidation, "comment body is required"}
commentBodyTooLarge = mapping{service.ErrPlanCommentBodyTooLarge, ws.ErrorCodeValidation, "plan comment body is too large"}
commentTextTooLarge = mapping{service.ErrPlanCommentTextTooLarge, ws.ErrorCodeValidation, "plan comment selected text is too large"}
commentLimitExceeded = mapping{service.ErrPlanCommentLimitExceeded, ws.ErrorCodeValidation, "task plan comment collection is too large"}
commentVersionNeeded = mapping{service.ErrPlanCommentVersionNeeded, ws.ErrorCodeValidation, "expected_version must be positive"}
commentAnchorInvalid = mapping{service.ErrPlanCommentAnchorInvalid, ws.ErrorCodeValidation, "plan comment anchor is invalid"}
planCommentsChanged = mapping{service.ErrTaskPlanCommentsChanged, ws.ErrorCodePlanCommentsChanged, "Task plan comments changed"}
contentRequired = mapping{service.ErrContentRequired, ws.ErrorCodeValidation, "content is required"}
revisionCursorInvalid = mapping{service.ErrPlanRevisionCursorInvalid, ws.ErrorCodeValidation, "before_revision_number must be zero or a positive revision number"}
revisionLimitInvalid = mapping{service.ErrPlanRevisionLimitInvalid, ws.ErrorCodeValidation, "limit must be between 1 and 100"}
// appendFragmentWhitespaceOnly maps the whitespace-only fragment error.
appendFragmentWhitespaceOnly = mapping{
service.ErrPlanAppendFragmentWhitespaceOnly, ws.ErrorCodeValidation,
"append fragment must contain a non-whitespace character",
}
// planContentUnreadable reports a stored-plan read failure as a distinct
// code from planNotFound so the caller cannot mistake one for the other.
// The message is fixed rather than derived from err.Error(), so it never
// leaks the underlying storage failure.
planContentUnreadable = mapping{
service.ErrPlanContentReadFailed, ws.ErrorCodeInternalError,
"Could not read the current plan content; the append was not applied",
}
// allMappings is the vocabulary reachable by an action that can surface any
// plan or revision failure.
allMappings = []mapping{
taskIDRequired,
taskNotFound,
sessionIDRequired,
sessionTaskMismatch,
planNotFound,
revisionIDRequired,
revisionNotFound,
revisionTaskMismatch,
contentRequired,
revisionCursorInvalid,
revisionLimitInvalid,
appendFragmentWhitespaceOnly,
planContentUnreadable,
}
)
// errorResponse returns the shared response when err matches one of recognized,
// and otherwise an internal error carrying fallback verbatim. Each action
// passes only the sentinels its PlanService method can actually return, so a
// sentinel that would mean something different for that action does not get
// silently reclassified.
func errorResponse(msg *ws.Message, err error, fallback string, recognized []mapping) (*ws.Message, error) {
for _, m := range recognized {
if errors.Is(err, m.sentinel) {
return ws.NewError(msg.ID, msg.Action, m.code, m.message, nil)
}
}
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fallback, nil)
}
// contentTooLargeResponse reports service.PlanContentTooLargeError as a
// VALIDATION_ERROR. Unlike every other mapping, its message is computed from
// the request rather than fixed, so it is checked before the sentinel table
// walk instead of living in it. It is the only site that populates the
// structured details map: reason is a stable wire token the browser compares
// with ===, never localized or reworded, and limit/submitted let the browser
// render the rejection without re-deriving the ceiling.
func contentTooLargeResponse(msg *ws.Message, err error) (*ws.Message, bool, error) {
var sizeErr *service.PlanContentTooLargeError
if !errors.As(err, &sizeErr) {
return nil, false, nil
}
out, mapErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, sizeErr.Error(),
map[string]interface{}{
"reason": "plan_content_too_large",
"limit": sizeErr.Limit,
"submitted": sizeErr.Submitted,
"write_applied": false,
"next_action": "Reduce the submitted content below the limit and submit it again.",
})
return out, true, mapErr
}
// PlanCommentError maps task-plan comment validation, lookup, and conflict failures.
// A conflict carries the current snapshot so the client can reconcile immediately.
func PlanCommentError(msg *ws.Message, err error, snapshot interface{}) (*ws.Message, error) {
if errors.Is(err, service.ErrTaskPlanCommentsChanged) {
details := map[string]interface{}{}
if snapshot != nil {
details["snapshot"] = snapshot
}
return ws.NewError(msg.ID, msg.Action, planCommentsChanged.code, planCommentsChanged.message, details)
}
return errorResponse(msg, err, "Failed to update task plan comments", []mapping{
taskIDRequired,
planIDRequired,
commentIDRequired,
commentIDInvalid,
commentBodyRequired,
commentBodyTooLarge,
commentTextTooLarge,
commentLimitExceeded,
commentVersionNeeded,
commentAnchorInvalid,
taskNotFound,
planNotFound,
})
}
// CreateError maps a PlanService.CreatePlan failure.
//
// ErrTaskPlanNotFound is deliberately absent: CreatePlan only returns it when
// the plan vanishes between the write and the read-back, which is a server-side
// inconsistency rather than the caller naming a plan that does not exist. The
// repository's ErrTaskNotFound is different: it means the write target task
// did not exist and is safe to report as a not-found response.
func CreateError(msg *ws.Message, err error) (*ws.Message, error) {
if out, matched, mapErr := safePlanErrorResponse(msg, err); matched {
return out, mapErr
}
if out, matched, mapErr := contentTooLargeResponse(msg, err); matched {
return out, mapErr
}
return errorResponse(msg, err, "Failed to create task plan: "+err.Error(), []mapping{taskIDRequired, taskNotFound})
}
// GetError maps a PlanService.GetPlan failure. The fallback omits err.Error()
// because a read that fails below the sentinels is a storage fault with nothing
// actionable for the caller.
func GetError(msg *ws.Message, err error) (*ws.Message, error) {
return errorResponse(msg, err, "Failed to get task plan", []mapping{taskIDRequired})
}
// UpdateError maps a PlanService.UpdatePlan failure.
func UpdateError(msg *ws.Message, err error) (*ws.Message, error) {
if out, matched, mapErr := safePlanErrorResponse(msg, err); matched {
return out, mapErr
}
if out, matched, mapErr := contentTooLargeResponse(msg, err); matched {
return out, mapErr
}
return errorResponse(msg, err, "Failed to update task plan: "+err.Error(), []mapping{
taskIDRequired, taskNotFound, planNotFound,
contentRequired, appendFragmentWhitespaceOnly, planContentUnreadable,
})
}
// EditError maps the exact-edit service operation. It keeps the same safety
// details as create/update while retaining ordinary plan lookup errors.
func EditError(msg *ws.Message, err error) (*ws.Message, error) {
if out, matched, mapErr := safePlanErrorResponse(msg, err); matched {
return out, mapErr
}
if out, matched, mapErr := contentTooLargeResponse(msg, err); matched {
return out, mapErr
}
return errorResponse(msg, err, "Failed to edit task plan: "+err.Error(), []mapping{
taskIDRequired, taskNotFound, planNotFound, contentRequired,
})
}
// DeleteError maps a PlanService.DeletePlan failure.
func DeleteError(msg *ws.Message, err error) (*ws.Message, error) {
return errorResponse(msg, err, "Failed to delete task plan: "+err.Error(), []mapping{taskIDRequired, planNotFound})
}
// Error maps a failure from a plan action that can reach the whole sentinel
// vocabulary — implementation-started, revert, and the revision reads. fallback
// is the action's own summary; err.Error() is appended to it.
func Error(msg *ws.Message, err error, fallback string) (*ws.Message, error) {
if out, matched, mapErr := safePlanErrorResponse(msg, err); matched {
return out, mapErr
}
return errorResponse(msg, err, fallback+": "+err.Error(), allMappings)
}