// 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" ) // 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"} // 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, 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, }) 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 := 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 := 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, }) } // 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) { return errorResponse(msg, err, fallback+": "+err.Error(), allMappings) }