836 lines
34 KiB
Go
836 lines
34 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/entityrefs"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/orchestrator"
|
|
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
// queueErrorCodeEntryNotFound is surfaced when an edit/remove targets an entry
|
|
// that has already been drained (atomic-take won the race).
|
|
queueErrorCodeEntryNotFound = "entry_not_found"
|
|
queueErrorCodeSessionBusy = "session_busy"
|
|
queueErrorCodeNotPromptable = "session_not_promptable"
|
|
queueErrorCodeSendNowQueueEmpty = "queue_empty"
|
|
queueErrorCodeSendNowQueueChanged = "queue_changed"
|
|
queueErrorCodeSendNowConflict = "send_now_conflict"
|
|
queueErrorCodeSendNowTurnChanged = "turn_changed"
|
|
queueErrorCodeSendNowAttachmentOverflow = "send_now_attachment_overflow"
|
|
queueErrorCodeSendNowReferenceOverflow = "send_now_reference_overflow"
|
|
// queueErrorCodeMergeReferenceOverflow is surfaced when a merge would push
|
|
// the combined entity references past the per-message cap; the merge is
|
|
// rejected atomically instead of dropping persisted references.
|
|
queueErrorCodeMergeReferenceOverflow = "merge_reference_overflow"
|
|
queueInvalidReferences = "Invalid entity references"
|
|
queueAccessDenied = "Session not found"
|
|
|
|
// Payload field names — extracted to satisfy goconst (≥3 occurrences).
|
|
fieldSessionID = "session_id"
|
|
fieldEntryID = "entry_id"
|
|
fieldQueueSize = "queue_size"
|
|
fieldMax = "max"
|
|
)
|
|
|
|
// QueueService is the surface the handlers depend on. Real implementation lives
|
|
// in messagequeue.Service.
|
|
type QueueService interface {
|
|
QueueMessageWithMetadata(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment, metadata map[string]interface{}) (*messagequeue.QueuedMessage, error)
|
|
AppendContent(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []messagequeue.MessageAttachment) (*messagequeue.QueuedMessage, bool, error)
|
|
GetEntry(ctx context.Context, sessionID, entryID string) (*messagequeue.QueuedMessage, error)
|
|
UpdateMessageWithMetadata(ctx context.Context, sessionID, entryID, content string, attachments []messagequeue.MessageAttachment, metadataUpdates map[string]interface{}, queuedBy string) error
|
|
RemoveEntry(ctx context.Context, sessionID, entryID string) error
|
|
MergeIntoAbove(ctx context.Context, sessionID, entryID, queuedBy string) (*messagequeue.QueuedMessage, error)
|
|
CancelAll(ctx context.Context, sessionID string) (int, error)
|
|
GetStatus(ctx context.Context, sessionID string) *messagequeue.QueueStatus
|
|
}
|
|
|
|
// QueueDrainer drains a single queued entry when the session is promptable.
|
|
type QueueDrainer interface {
|
|
DrainQueuedMessage(ctx context.Context, sessionID string) (bool, error)
|
|
}
|
|
|
|
// QueueSendNowDispatcher is implemented by the orchestrator service. It is
|
|
// kept separate from QueueDrainer so queue-focused handlers can retain their
|
|
// small test doubles while the new action gets the replacement-turn contract.
|
|
type QueueSendNowDispatcher interface {
|
|
SendQueuedNow(ctx context.Context, sessionID, scope, entryID string) (int, error)
|
|
}
|
|
|
|
// QueueAccessAuthorizer scopes queue reads and mutations to visible sessions.
|
|
type QueueAccessAuthorizer interface {
|
|
AuthorizeSessionAccess(ctx context.Context, sessionID string) error
|
|
AuthorizeTaskSessionAccess(ctx context.Context, taskID, sessionID string) error
|
|
}
|
|
|
|
// QueueAttachmentClaimer binds staged file descriptors to the task/session
|
|
// after a queue entry has been durably accepted.
|
|
type QueueAttachmentClaimer interface {
|
|
ClaimMessageAttachments(ctx context.Context, taskID, sessionID string, attachments []v1.MessageAttachment) error
|
|
}
|
|
|
|
type QueueAttachmentReleaser interface {
|
|
ReleaseMessageAttachments(ctx context.Context, taskID, sessionID string, attachments []v1.MessageAttachment) error
|
|
}
|
|
|
|
type queueEntryTaker interface {
|
|
TakeQueuedEntry(context.Context, string, string) (*messagequeue.QueuedMessage, bool, error)
|
|
}
|
|
|
|
// QueueHandlers handles WebSocket message-queue operations.
|
|
type QueueHandlers struct {
|
|
queueService QueueService
|
|
queueDrainer QueueDrainer
|
|
queueDispatcher QueueSendNowDispatcher
|
|
accessAuthorizer QueueAccessAuthorizer
|
|
eventBus bus.EventBus
|
|
logger *logger.Logger
|
|
referenceValidator entityrefs.SubmissionValidator
|
|
attachmentClaimer QueueAttachmentClaimer
|
|
}
|
|
|
|
// SetAttachmentClaimer wires the task attachment registry into queue adds.
|
|
// It is optional so queue-focused tests and non-task consumers remain small.
|
|
func (h *QueueHandlers) SetAttachmentClaimer(claimer QueueAttachmentClaimer) {
|
|
h.attachmentClaimer = claimer
|
|
}
|
|
|
|
// NewQueueHandlers creates a new QueueHandlers instance.
|
|
func NewQueueHandlers(
|
|
queueService QueueService,
|
|
eventBus bus.EventBus,
|
|
log *logger.Logger,
|
|
queueDrainer QueueDrainer,
|
|
accessAuthorizer QueueAccessAuthorizer,
|
|
validators ...entityrefs.SubmissionValidator,
|
|
) *QueueHandlers {
|
|
var referenceValidator entityrefs.SubmissionValidator
|
|
if len(validators) > 0 {
|
|
referenceValidator = validators[0]
|
|
}
|
|
handlers := &QueueHandlers{
|
|
queueService: queueService,
|
|
queueDrainer: queueDrainer,
|
|
accessAuthorizer: accessAuthorizer,
|
|
eventBus: eventBus,
|
|
logger: log.WithFields(zap.String("component", "queue-handlers")),
|
|
referenceValidator: referenceValidator,
|
|
}
|
|
if dispatcher, ok := queueDrainer.(QueueSendNowDispatcher); ok {
|
|
handlers.queueDispatcher = dispatcher
|
|
}
|
|
return handlers
|
|
}
|
|
|
|
// RegisterHandlers registers queue handlers with the dispatcher.
|
|
func (h *QueueHandlers) RegisterHandlers(d *ws.Dispatcher) {
|
|
d.RegisterFunc(ws.ActionMessageQueueAdd, h.wsQueueMessage)
|
|
d.RegisterFunc(ws.ActionMessageQueueCancel, h.wsCancelAll)
|
|
d.RegisterFunc(ws.ActionMessageQueueGet, h.wsGetQueueStatus)
|
|
d.RegisterFunc(ws.ActionMessageQueueUpdate, h.wsUpdateMessage)
|
|
d.RegisterFunc(ws.ActionMessageQueueAppend, h.wsAppendToQueue)
|
|
d.RegisterFunc(ws.ActionMessageQueueDrain, h.wsDrainQueue)
|
|
d.RegisterFunc(ws.ActionMessageQueueSendNow, h.wsSendNow)
|
|
d.RegisterFunc(ws.ActionMessageQueueRemove, h.wsRemoveEntry)
|
|
d.RegisterFunc(ws.ActionMessageQueueMerge, h.wsMergeIntoAbove)
|
|
}
|
|
|
|
type wsQueueMessageRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
TaskID string `json:"task_id"`
|
|
Content string `json:"content"`
|
|
Model string `json:"model,omitempty"`
|
|
PlanMode bool `json:"plan_mode,omitempty"`
|
|
Attachments []messagequeue.MessageAttachment `json:"attachments,omitempty"`
|
|
ContextFiles []v1.ContextFileMeta `json:"context_files,omitempty"`
|
|
EntityReferences []v1.EntityReference `json:"entity_references,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsQueueMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsQueueMessageRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if req.TaskID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
|
|
}
|
|
if denied := h.authorizeTaskSession(ctx, msg, req.TaskID, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if req.Content == "" && len(req.Attachments) == 0 {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil)
|
|
}
|
|
if invalid := firstInvalidDeliveryMode(req.Attachments); invalid >= 0 {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment delivery_mode must be prompt or path",
|
|
map[string]interface{}{"attachment_index": invalid})
|
|
}
|
|
if invalid := firstInvalidAttachment(req.Attachments); invalid >= 0 {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment metadata is invalid",
|
|
map[string]interface{}{"attachment_index": invalid})
|
|
}
|
|
if messagequeue.IsReservedQueuedBy(req.UserID) {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, reservedIdentityError(req.UserID), nil)
|
|
}
|
|
references, err := h.validateSubmittedReferences(ctx, req.SessionID, req.TaskID, req.EntityReferences)
|
|
if err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil)
|
|
}
|
|
req.EntityReferences = references
|
|
|
|
// Default empty user_id to QueuedByUser so the entry has a non-empty owner;
|
|
// the UpdateMessage handler relies on this so its filter against agent
|
|
// entries (queued_by="agent") is always meaningful.
|
|
queuedBy := req.UserID
|
|
if queuedBy == "" {
|
|
queuedBy = messagequeue.QueuedByUser
|
|
}
|
|
metadata := orchestrator.NewUserMessageMeta().
|
|
WithContextFiles(req.ContextFiles).
|
|
WithEntityReferences(req.EntityReferences).
|
|
ToMap()
|
|
queued, err := h.queueService.QueueMessageWithMetadata(ctx, req.SessionID, req.TaskID, req.Content, req.Model, queuedBy, req.PlanMode, req.Attachments, metadata)
|
|
if err != nil {
|
|
if errors.Is(err, messagequeue.ErrQueueFull) {
|
|
status := h.queueService.GetStatus(ctx, req.SessionID)
|
|
return ws.NewError(msg.ID, msg.Action, messagequeue.QueueFullErrorCode, "Queue is full",
|
|
map[string]interface{}{
|
|
fieldQueueSize: status.Count,
|
|
fieldMax: status.Max,
|
|
})
|
|
}
|
|
h.logger.Error("failed to queue message", zap.Error(err))
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue message", nil)
|
|
}
|
|
if h.attachmentClaimer != nil && len(req.Attachments) > 0 {
|
|
if err := h.attachmentClaimer.ClaimMessageAttachments(ctx, req.TaskID, req.SessionID, queueAttachmentsToV1(req.Attachments)); err != nil {
|
|
if rollbackErr := h.rollbackQueuedAttachmentClaim(ctx, req.SessionID, queued.ID); rollbackErr != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to roll back queued attachment", nil)
|
|
}
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Attachment is no longer available", nil)
|
|
}
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, queued)
|
|
}
|
|
|
|
type wsCancelAllRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsCancelAll(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsCancelAllRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
|
|
removed, err := h.queueService.CancelAll(ctx, req.SessionID)
|
|
if err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
|
fieldSessionID: req.SessionID,
|
|
"removed": removed,
|
|
})
|
|
}
|
|
|
|
type wsDrainQueueRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsDrainQueue(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsDrainQueueRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if h.queueDrainer == nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Queue drain is unavailable", nil)
|
|
}
|
|
|
|
drained, err := h.queueDrainer.DrainQueuedMessage(ctx, req.SessionID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, orchestrator.ErrAgentPromptInProgress):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSessionBusy, "Session is busy", nil)
|
|
case errors.Is(err, orchestrator.ErrSessionNotPromptable):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeNotPromptable, "Session is not ready for input", nil)
|
|
default:
|
|
h.logger.Error("failed to drain queued message", zap.String(fieldSessionID, req.SessionID), zap.Error(err))
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to drain queued message", nil)
|
|
}
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
|
fieldSessionID: req.SessionID,
|
|
"drained": drained,
|
|
})
|
|
}
|
|
|
|
type wsSendNowRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
Scope string `json:"scope"`
|
|
EntryID string `json:"entry_id,omitempty"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsSendNow(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsSendNowRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if validation := validateSendNowRequest(req); validation != "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, validation, nil)
|
|
}
|
|
if h.queueDispatcher == nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Queue send-now is unavailable", nil)
|
|
}
|
|
|
|
sentCount, err := h.queueDispatcher.SendQueuedNow(ctx, req.SessionID, req.Scope, req.EntryID)
|
|
if err != nil {
|
|
return h.sendNowErrorResponse(msg, req.SessionID, err)
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
|
fieldSessionID: req.SessionID,
|
|
"dispatched": true,
|
|
"sent_count": sentCount,
|
|
})
|
|
}
|
|
|
|
func validateSendNowRequest(req wsSendNowRequest) string {
|
|
switch {
|
|
case req.Scope != orchestrator.QueueSendNowScopeEntry && req.Scope != orchestrator.QueueSendNowScopeAll:
|
|
return "scope must be entry or all"
|
|
case req.Scope == orchestrator.QueueSendNowScopeEntry && req.EntryID == "":
|
|
return "entry_id is required for entry scope"
|
|
case req.Scope == orchestrator.QueueSendNowScopeAll && req.EntryID != "":
|
|
return "entry_id is not allowed for all scope"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (h *QueueHandlers) sendNowErrorResponse(msg *ws.Message, sessionID string, err error) (*ws.Message, error) {
|
|
switch {
|
|
case errors.Is(err, orchestrator.ErrSendNowEntryNotFound):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry is no longer pending", nil)
|
|
case errors.Is(err, orchestrator.ErrSendNowQueueEmpty):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowQueueEmpty, "Queue is empty", nil)
|
|
case errors.Is(err, orchestrator.ErrSendNowQueueChanged):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowQueueChanged, "Queue changed before Send Now could start", nil)
|
|
case errors.Is(err, orchestrator.ErrSendNowConflict):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowConflict, "Another cancellation or Send Now operation is in progress", nil)
|
|
case errors.Is(err, orchestrator.ErrSendNowTurnChanged):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowTurnChanged, "The active turn changed before Send Now could start", nil)
|
|
case errors.Is(err, messagequeue.ErrSendNowAttachmentOverflow):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowAttachmentOverflow, "Combined attachments exceed the message limits", nil)
|
|
case errors.Is(err, messagequeue.ErrSendNowReferenceOverflow):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeSendNowReferenceOverflow, "Combined entity references exceed the message limit", nil)
|
|
case errors.Is(err, orchestrator.ErrSessionNotPromptable):
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeNotPromptable, "Session is not ready for input", nil)
|
|
default:
|
|
h.logger.Error("failed to send queued message now", zap.String(fieldSessionID, sessionID), zap.Error(err))
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to send queued message now", nil)
|
|
}
|
|
}
|
|
|
|
type wsGetQueueStatusRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsGetQueueStatus(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsGetQueueStatusRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
|
|
status := h.queueService.GetStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, status)
|
|
}
|
|
|
|
type wsUpdateMessageRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
EntryID string `json:"entry_id"`
|
|
Content string `json:"content"`
|
|
Attachments []messagequeue.MessageAttachment `json:"attachments,omitempty"`
|
|
EntityReferences []v1.EntityReference `json:"entity_references,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsUpdateMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsUpdateMessageRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
if req.SessionID == "" {
|
|
// Required so publishStatus can broadcast the post-update list to other
|
|
// connected clients; without it they'd be left with a stale view.
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if req.EntryID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "entry_id is required", nil)
|
|
}
|
|
if req.Content == "" && len(req.Attachments) == 0 {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content or attachments are required", nil)
|
|
}
|
|
if invalid := firstInvalidDeliveryMode(req.Attachments); invalid >= 0 {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment delivery_mode must be prompt or path",
|
|
map[string]interface{}{"attachment_index": invalid})
|
|
}
|
|
if invalid := firstInvalidAttachment(req.Attachments); invalid >= 0 {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "attachment metadata is invalid",
|
|
map[string]interface{}{"attachment_index": invalid})
|
|
}
|
|
|
|
// Reject any client-supplied identity that would impersonate the agent.
|
|
// Without this guard a hostile WS client could send user_id="agent" to
|
|
// satisfy the `WHERE queued_by = ?` filter on inter-task entries and
|
|
// overwrite their content. The reserved sentinel must be settable only
|
|
// from the inter-task dispatch path inside the backend.
|
|
if messagequeue.IsReservedQueuedBy(req.UserID) {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, reservedIdentityError(req.UserID), nil)
|
|
}
|
|
referencesProvided := req.EntityReferences != nil
|
|
references, err := h.validateSubmittedReferences(ctx, req.SessionID, "", req.EntityReferences)
|
|
if err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, queueInvalidReferences, nil)
|
|
}
|
|
req.EntityReferences = references
|
|
// Default empty user_id to QueuedByUser so the UpdateContent guard always
|
|
// runs against a non-empty owner. Agent entries (queued_by="agent") then
|
|
// fail the filter, mirroring the canEdit UI gate at the WS layer.
|
|
queuedBy := req.UserID
|
|
if queuedBy == "" {
|
|
queuedBy = messagequeue.QueuedByUser
|
|
}
|
|
var metadataUpdates map[string]interface{}
|
|
if referencesProvided {
|
|
var referenceMetadata interface{}
|
|
if len(req.EntityReferences) > 0 {
|
|
referenceMetadata = req.EntityReferences
|
|
}
|
|
metadataUpdates = map[string]interface{}{messagequeue.MetadataEntityReferences: referenceMetadata}
|
|
}
|
|
var previous *messagequeue.QueuedMessage
|
|
var releaseClaims QueueAttachmentReleaser
|
|
var newlyAdded []messagequeue.MessageAttachment
|
|
if h.attachmentClaimer != nil {
|
|
var err error
|
|
previous, err = h.queueService.GetEntry(ctx, req.SessionID, req.EntryID)
|
|
if err != nil {
|
|
if errors.Is(err, messagequeue.ErrEntryNotFound) {
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry was already drained or not owned by caller", nil)
|
|
}
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
|
}
|
|
newlyAdded = newlyAddedQueueAttachments(previous.Attachments, req.Attachments)
|
|
if err := h.attachmentClaimer.ClaimMessageAttachments(ctx, previous.TaskID, req.SessionID, queueAttachmentsToV1(newlyAdded)); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Attachment is no longer available", nil)
|
|
}
|
|
releaseClaims, _ = h.attachmentClaimer.(QueueAttachmentReleaser)
|
|
}
|
|
if err := h.queueService.UpdateMessageWithMetadata(ctx, req.SessionID, req.EntryID, req.Content, req.Attachments, metadataUpdates, queuedBy); err != nil {
|
|
if releaseClaims != nil && previous != nil {
|
|
if releaseErr := releaseClaims.ReleaseMessageAttachments(ctx, previous.TaskID, req.SessionID, queueAttachmentsToV1(newlyAdded)); releaseErr != nil {
|
|
h.logger.Warn("failed to release attachments after queue update failure", zap.Error(releaseErr))
|
|
}
|
|
}
|
|
if errors.Is(err, messagequeue.ErrEntryNotFound) {
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry was already drained or not owned by caller", nil)
|
|
}
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
|
}
|
|
if releaseClaims != nil && previous != nil {
|
|
if superseded := supersededQueueAttachments(previous.Attachments, req.Attachments); len(superseded) > 0 {
|
|
if err := releaseClaims.ReleaseMessageAttachments(ctx, previous.TaskID, req.SessionID, queueAttachmentsToV1(superseded)); err != nil {
|
|
h.logger.Warn("failed to release superseded queue attachments", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{fieldEntryID: req.EntryID})
|
|
}
|
|
|
|
func newlyAddedQueueAttachments(previous, replacement []messagequeue.MessageAttachment) []messagequeue.MessageAttachment {
|
|
retained := make(map[string]struct{}, len(previous))
|
|
for _, attachment := range previous {
|
|
if attachment.AttachmentID != "" {
|
|
retained[attachment.AttachmentID] = struct{}{}
|
|
}
|
|
}
|
|
var newlyAdded []messagequeue.MessageAttachment
|
|
for _, attachment := range replacement {
|
|
if attachment.AttachmentID == "" {
|
|
continue
|
|
}
|
|
if _, ok := retained[attachment.AttachmentID]; !ok {
|
|
newlyAdded = append(newlyAdded, attachment)
|
|
}
|
|
}
|
|
return newlyAdded
|
|
}
|
|
|
|
func supersededQueueAttachments(previous, replacement []messagequeue.MessageAttachment) []messagequeue.MessageAttachment {
|
|
retained := make(map[string]struct{}, len(replacement))
|
|
for _, attachment := range replacement {
|
|
if attachment.AttachmentID != "" {
|
|
retained[attachment.AttachmentID] = struct{}{}
|
|
}
|
|
}
|
|
var superseded []messagequeue.MessageAttachment
|
|
for _, attachment := range previous {
|
|
if attachment.AttachmentID == "" {
|
|
continue
|
|
}
|
|
if _, ok := retained[attachment.AttachmentID]; !ok {
|
|
superseded = append(superseded, attachment)
|
|
}
|
|
}
|
|
return superseded
|
|
}
|
|
|
|
func (h *QueueHandlers) validateSubmittedReferences(
|
|
ctx context.Context,
|
|
sessionID, taskID string,
|
|
references []v1.EntityReference,
|
|
) ([]v1.EntityReference, error) {
|
|
if len(references) == 0 {
|
|
return nil, nil
|
|
}
|
|
if h.referenceValidator == nil {
|
|
return nil, entityrefs.ErrUnauthorizedReference
|
|
}
|
|
return h.referenceValidator.ValidateForSubmission(ctx, sessionID, taskID, references)
|
|
}
|
|
|
|
func (h *QueueHandlers) rollbackQueuedAttachmentClaim(ctx context.Context, sessionID, entryID string) error {
|
|
if err := h.queueService.RemoveEntry(ctx, sessionID, entryID); err == nil {
|
|
return nil
|
|
} else {
|
|
h.logger.Error("failed to remove queue entry after attachment claim failure",
|
|
zap.String("entry_id", entryID), zap.Error(err))
|
|
}
|
|
taker, ok := h.queueService.(queueEntryTaker)
|
|
if !ok {
|
|
return errors.New("queue service cannot atomically remove a queued entry")
|
|
}
|
|
_, _, err := taker.TakeQueuedEntry(ctx, sessionID, entryID)
|
|
return err
|
|
}
|
|
|
|
func firstInvalidDeliveryMode(attachments []messagequeue.MessageAttachment) int {
|
|
for i, att := range attachments {
|
|
if att.DeliveryMode != "" && att.DeliveryMode != "prompt" && att.DeliveryMode != "path" {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func firstInvalidAttachment(attachments []messagequeue.MessageAttachment) int {
|
|
if len(attachments) > models.MaxMessageAttachmentCount {
|
|
return models.MaxMessageAttachmentCount
|
|
}
|
|
var total int64
|
|
for i, attachment := range attachments {
|
|
if attachment.Type != "image" && attachment.Type != "audio" && attachment.Type != "resource" {
|
|
return i
|
|
}
|
|
bytes, valid := attachmentPayloadBytes(attachment)
|
|
if !valid {
|
|
return i
|
|
}
|
|
total += bytes
|
|
if total > models.MaxMessageAttachmentBytes {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func attachmentPayloadBytes(attachment messagequeue.MessageAttachment) (int64, bool) {
|
|
if attachment.AttachmentID != "" {
|
|
if attachment.Data != "" || attachment.Name == "" || attachment.MimeType == "" {
|
|
return 0, false
|
|
}
|
|
if attachment.SizeBytes < 0 || attachment.SizeBytes > models.MaxMessageAttachmentBytes {
|
|
return 0, false
|
|
}
|
|
return attachment.SizeBytes, true
|
|
}
|
|
if attachment.Data == "" || len(attachment.Data) > 10*1024*1024 {
|
|
return 0, false
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(attachment.Data)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return int64(len(decoded)), true
|
|
}
|
|
|
|
func queueAttachmentsToV1(attachments []messagequeue.MessageAttachment) []v1.MessageAttachment {
|
|
if len(attachments) == 0 {
|
|
return nil
|
|
}
|
|
converted := make([]v1.MessageAttachment, 0, len(attachments))
|
|
for _, attachment := range attachments {
|
|
converted = append(converted, v1.MessageAttachment{
|
|
AttachmentID: attachment.AttachmentID,
|
|
Type: attachment.Type,
|
|
Data: attachment.Data,
|
|
MimeType: attachment.MimeType,
|
|
Name: attachment.Name,
|
|
SizeBytes: attachment.SizeBytes,
|
|
DeliveryMode: attachment.DeliveryMode,
|
|
})
|
|
}
|
|
return converted
|
|
}
|
|
|
|
type wsRemoveEntryRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
EntryID string `json:"entry_id"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsRemoveEntry(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsRemoveEntryRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
if req.SessionID == "" {
|
|
// Required so publishStatus can broadcast the post-removal list.
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if req.EntryID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "entry_id is required", nil)
|
|
}
|
|
|
|
if err := h.queueService.RemoveEntry(ctx, req.SessionID, req.EntryID); err != nil {
|
|
if errors.Is(err, messagequeue.ErrEntryNotFound) {
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry is no longer pending", nil)
|
|
}
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{fieldEntryID: req.EntryID})
|
|
}
|
|
|
|
// wsMergeIntoAboveRequest is the payload for ActionMessageQueueMerge: the
|
|
// session whose queue is modified and the id of the entry to fold into the
|
|
// entry directly above it. user_id is forwarded for ownership checks and is
|
|
// optional (the server defaults to the reserved "user" identity).
|
|
type wsMergeIntoAboveRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
EntryID string `json:"entry_id"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
}
|
|
|
|
// wsMergeIntoAbove handles ActionMessageQueueMerge, folding the referenced
|
|
// queued entry into the entry above it and broadcasting the updated queue.
|
|
func (h *QueueHandlers) wsMergeIntoAbove(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsMergeIntoAboveRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
if req.SessionID == "" {
|
|
// Required so publishStatus can broadcast the post-merge list.
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if denied := h.authorizeSession(ctx, msg, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if req.EntryID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "entry_id is required", nil)
|
|
}
|
|
if messagequeue.IsReservedQueuedBy(req.UserID) {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, reservedIdentityError(req.UserID), nil)
|
|
}
|
|
// Default empty user_id to QueuedByUser so the merge ownership guard runs
|
|
// against a non-empty owner, mirroring wsUpdateMessage.
|
|
queuedBy := req.UserID
|
|
if queuedBy == "" {
|
|
queuedBy = messagequeue.QueuedByUser
|
|
}
|
|
|
|
merged, err := h.queueService.MergeIntoAbove(ctx, req.SessionID, req.EntryID, queuedBy)
|
|
if err != nil {
|
|
if errors.Is(err, messagequeue.ErrEntryNotFound) {
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeEntryNotFound, "Queue entry was already drained or not owned by caller", nil)
|
|
}
|
|
if errors.Is(err, messagequeue.ErrNoMergeTarget) {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "No mergeable message above this entry", nil)
|
|
}
|
|
if errors.Is(err, messagequeue.ErrMergeReferenceOverflow) {
|
|
return ws.NewError(msg.ID, msg.Action, queueErrorCodeMergeReferenceOverflow, err.Error(), nil)
|
|
}
|
|
h.logger.Error("failed to merge queued message", zap.Error(err))
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to merge queued message", nil)
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{fieldEntryID: merged.ID})
|
|
}
|
|
|
|
type wsAppendToQueueRequest struct {
|
|
SessionID string `json:"session_id"`
|
|
TaskID string `json:"task_id"`
|
|
Content string `json:"content"`
|
|
Model string `json:"model,omitempty"`
|
|
PlanMode bool `json:"plan_mode,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
}
|
|
|
|
func (h *QueueHandlers) wsAppendToQueue(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req wsAppendToQueueRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
|
}
|
|
|
|
if req.SessionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
|
}
|
|
if req.TaskID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
|
|
}
|
|
if denied := h.authorizeTaskSession(ctx, msg, req.TaskID, req.SessionID); denied != nil {
|
|
return denied, nil
|
|
}
|
|
if req.Content == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
|
|
}
|
|
if messagequeue.IsReservedQueuedBy(req.UserID) {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, reservedIdentityError(req.UserID), nil)
|
|
}
|
|
|
|
queuedBy := req.UserID
|
|
if queuedBy == "" {
|
|
queuedBy = messagequeue.QueuedByUser
|
|
}
|
|
queued, appended, err := h.queueService.AppendContent(ctx, req.SessionID, req.TaskID, req.Content, req.Model, queuedBy, req.PlanMode, nil)
|
|
if err != nil {
|
|
if errors.Is(err, messagequeue.ErrQueueFull) {
|
|
status := h.queueService.GetStatus(ctx, req.SessionID)
|
|
return ws.NewError(msg.ID, msg.Action, messagequeue.QueueFullErrorCode, "Queue is full",
|
|
map[string]interface{}{
|
|
fieldQueueSize: status.Count,
|
|
fieldMax: status.Max,
|
|
})
|
|
}
|
|
h.logger.Error("failed to append to queue", zap.Error(err))
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to queue message", nil)
|
|
}
|
|
|
|
h.publishStatus(ctx, req.SessionID)
|
|
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
|
|
fieldEntryID: queued.ID,
|
|
"was_append": appended,
|
|
})
|
|
}
|
|
|
|
func reservedIdentityError(queuedBy string) string {
|
|
if queuedBy == messagequeue.QueuedByAgent {
|
|
return "user_id may not impersonate the agent identity"
|
|
}
|
|
return "user_id may not impersonate a reserved identity"
|
|
}
|
|
|
|
func (h *QueueHandlers) authorizeSession(ctx context.Context, msg *ws.Message, sessionID string) *ws.Message {
|
|
if h.accessAuthorizer == nil {
|
|
return queueAccessDeniedResponse(msg)
|
|
}
|
|
if err := h.accessAuthorizer.AuthorizeSessionAccess(ctx, sessionID); err != nil {
|
|
return queueAccessDeniedResponse(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *QueueHandlers) authorizeTaskSession(
|
|
ctx context.Context,
|
|
msg *ws.Message,
|
|
taskID, sessionID string,
|
|
) *ws.Message {
|
|
if h.accessAuthorizer == nil {
|
|
return queueAccessDeniedResponse(msg)
|
|
}
|
|
if err := h.accessAuthorizer.AuthorizeTaskSessionAccess(ctx, taskID, sessionID); err != nil {
|
|
return queueAccessDeniedResponse(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func queueAccessDeniedResponse(msg *ws.Message) *ws.Message {
|
|
response, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, queueAccessDenied, nil)
|
|
return response
|
|
}
|
|
|
|
// publishStatus emits the latest QueueStatus on the event bus so the frontend
|
|
// updates its store after every mutation.
|
|
func (h *QueueHandlers) publishStatus(ctx context.Context, sessionID string) {
|
|
if h.eventBus == nil {
|
|
return
|
|
}
|
|
status := h.queueService.GetStatus(ctx, sessionID)
|
|
_ = h.eventBus.Publish(ctx, events.MessageQueueStatusChanged, bus.NewEvent(
|
|
events.MessageQueueStatusChanged,
|
|
"queue-handlers",
|
|
map[string]interface{}{
|
|
fieldSessionID: sessionID,
|
|
"entries": status.Entries,
|
|
"count": status.Count,
|
|
fieldMax: status.Max,
|
|
},
|
|
))
|
|
}
|