Files
kandev/apps/backend/internal/task/planws/errors.go
T
João Salavisa d6e2c358da refactor(task): share task-plan WS error mapping across both surfaces (#2465)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:38:14 +01:00

104 lines
4.6 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/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"}
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"}
// allMappings is the vocabulary reachable by an action that can surface any
// plan or revision failure.
allMappings = []mapping{
taskIDRequired,
sessionIDRequired,
sessionTaskMismatch,
planNotFound,
revisionIDRequired,
revisionNotFound,
revisionTaskMismatch,
}
)
// 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)
}
// 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.
func CreateError(msg *ws.Message, err error) (*ws.Message, error) {
return errorResponse(msg, err, "Failed to create task plan: "+err.Error(), []mapping{taskIDRequired})
}
// 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) {
return errorResponse(msg, err, "Failed to update task plan: "+err.Error(), []mapping{taskIDRequired, planNotFound})
}
// 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)
}