Files
kandev/apps/backend/internal/plugins/conversation_handlers.go
T
2026-09-17 10:39:30 +01:00

909 lines
30 KiB
Go

package plugins
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/internal/sysprompt"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
)
const conversationNoStore = "no-store"
// ConversationReader is the narrow task-service surface needed by browser
// plugin conversation reads. The task service applies workspace authorization.
type ConversationReader interface {
GetTaskSession(context.Context, string) (*taskmodels.TaskSession, error)
ListMessagesPaginated(
context.Context,
taskservice.ListMessagesRequest,
) ([]*taskmodels.Message, bool, error)
ListTurnsBySession(context.Context, string) ([]*taskmodels.Turn, error)
}
type conversationSourceReader interface {
ReadConversationRevision(context.Context, string) (taskmodels.ConversationRevision, error)
ReadConversationMessagesPage(context.Context, taskmodels.ConversationMessagePageRequest) (taskmodels.ConversationMessagePage, error)
ReadConversationTurnsPage(context.Context, taskmodels.ConversationTurnPageRequest) (taskmodels.ConversationTurnPage, error)
}
type conversationError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
type conversationErrorEnvelope struct {
Error conversationError `json:"error"`
}
type conversationBindingResponse struct {
BindingToken string `json:"bindingToken"`
Generation int64 `json:"generation"`
ExpiresAt time.Time `json:"expiresAt"`
}
type conversationMessageDTO struct {
ID string `json:"id"`
TaskID *string `json:"taskId"`
SessionID string `json:"sessionId"`
TurnID *string `json:"turnId,omitempty"`
AuthorType string `json:"authorType"`
Type string `json:"type"`
Content string `json:"content"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
PromptIndex *int `json:"promptIndex,omitempty"`
SenderTaskID *string `json:"senderTaskId,omitempty"`
}
func loadFallbackTurns(ctx context.Context, reader ConversationReader, sessionID, taskID string) ([]*taskmodels.Turn, error) {
turns, err := reader.ListTurnsBySession(ctx, sessionID)
if err != nil {
return nil, err
}
return filterTurnsByTask(turns, taskID), nil
}
func filterTurnsByTask(turns []*taskmodels.Turn, taskID string) []*taskmodels.Turn {
if taskID == "" {
return turns
}
filtered := make([]*taskmodels.Turn, 0, len(turns))
for _, turn := range turns {
if turn.TaskID == taskID {
filtered = append(filtered, turn)
}
}
return filtered
}
type conversationMessagesResponse struct {
Messages []conversationMessageDTO `json:"messages"`
HasMore bool `json:"hasMore"`
Cursor *string `json:"cursor"`
}
type conversationTurnDTO struct {
ID string `json:"id"`
TaskID *string `json:"taskId"`
SessionID string `json:"sessionId"`
StartedAt string `json:"startedAt"`
CompletedAt *string `json:"completedAt,omitempty"`
UpdatedAt string `json:"updatedAt"`
}
type conversationTurnsResponse struct {
Turns []conversationTurnDTO `json:"turns"`
}
type conversationSourceMessagesResponse struct {
Messages []conversationMessageDTO `json:"messages"`
HasMore bool `json:"hasMore"`
Cursor *string `json:"cursor"`
Epoch string `json:"epoch"`
Revision int64 `json:"revision,string"`
}
type conversationSourceTurnsResponse struct {
Turns []conversationTurnDTO `json:"turns"`
HasMore bool `json:"hasMore"`
Cursor *string `json:"cursor"`
Epoch string `json:"epoch"`
Revision int64 `json:"revision,string"`
}
type conversationSourceRevisionResponse struct {
Epoch string `json:"epoch"`
Revision int64 `json:"revision,string"`
}
type conversationContinuationRenewRequest struct {
Cursor string `json:"cursor"`
SnapshotToken string `json:"snapshot_token"`
}
type conversationContinuationRenewResponse struct {
Cursor string `json:"cursor"`
SnapshotToken string `json:"snapshot_token"`
Cutoff int64 `json:"cutoff"`
Fingerprint string `json:"fingerprint"`
ExpiresAt time.Time `json:"expires_at"`
}
type conversationMessageQuery struct {
limit int
sort string
authorType string
authors []string
taskID *string
cursor string
}
func registerConversationRoutes(api *gin.RouterGroup, ctrl *Controller) {
api.GET("/:id/conversation/binding", ctrl.conversationBinding)
api.GET("/:id/conversation/v2/task-sessions/:sessionId/messages", ctrl.conversationSourceMessages)
api.GET("/:id/conversation/v2/task-sessions/:sessionId/turns", ctrl.conversationSourceTurns)
api.GET("/:id/conversation/v2/task-sessions/:sessionId/revision", ctrl.conversationSourceRevision)
api.GET("/:id/conversation/task-sessions/:sessionId/messages", ctrl.conversationMessages)
api.GET("/:id/conversation/task-sessions/:sessionId/turns", ctrl.conversationTurns)
api.POST("/:id/conversation/continuation/renew", ctrl.conversationContinuationRenew)
}
func (c *Controller) conversationSourceMessages(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
record, identity, ok := c.authorizeConversationRequest(ctx)
if !ok || !c.validBinding(ctx, record, identity.UserID) {
return
}
reader, ok := c.conversationReader.(conversationSourceReader)
if !ok {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
query, expected, cursorID, ok := c.parseSourceRequest(ctx, record, identity.UserID, true)
if !ok {
return
}
sessionID := ctx.Param("sessionId")
page, err := reader.ReadConversationMessagesPage(ctx.Request.Context(), taskmodels.ConversationMessagePageRequest{
SessionID: sessionID,
TaskID: query.taskID,
Authors: query.authors,
Sort: query.sort,
CursorID: cursorID,
Limit: query.limit,
})
if !c.writeConversationSourceReadError(ctx, err) {
return
}
if expected != nil && page.Revision != *expected {
writeConversationError(ctx, http.StatusConflict, "reconciliation_required", "conversation revision changed", true)
return
}
response := conversationSourceMessagesResponse{
Messages: make([]conversationMessageDTO, 0, len(page.Messages)),
HasMore: page.HasMore,
Epoch: c.svc.conversationEpoch,
Revision: page.Revision,
}
for _, message := range page.Messages {
response.Messages = append(response.Messages, conversationMessageModelToDTO(message))
}
if page.HasMore {
cursor, mintErr := c.conversationTokens.mintSourceCursor(
record.ID, identity.UserID, conversationGeneration(record.InstalledAt), sessionID,
query.taskID, query.sort, query.authors, page.CursorID, c.svc.conversationEpoch, query.limit,
)
if mintErr != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
response.Cursor = &cursor
}
ctx.JSON(http.StatusOK, response)
}
func (c *Controller) conversationSourceTurns(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
record, identity, ok := c.authorizeConversationRequest(ctx)
if !ok || !c.validBinding(ctx, record, identity.UserID) {
return
}
reader, ok := c.conversationReader.(conversationSourceReader)
if !ok {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
query, expected, cursorID, ok := c.parseSourceRequest(ctx, record, identity.UserID, false)
if !ok {
return
}
sessionID := ctx.Param("sessionId")
page, err := reader.ReadConversationTurnsPage(ctx.Request.Context(), taskmodels.ConversationTurnPageRequest{
SessionID: sessionID,
TaskID: query.taskID,
Sort: query.sort,
CursorID: cursorID,
Limit: query.limit,
})
if !c.writeConversationSourceReadError(ctx, err) {
return
}
if expected != nil && page.Revision != *expected {
writeConversationError(ctx, http.StatusConflict, "reconciliation_required", "conversation revision changed", true)
return
}
response := conversationSourceTurnsResponse{
Turns: make([]conversationTurnDTO, 0, len(page.Turns)),
HasMore: page.HasMore,
Epoch: c.svc.conversationEpoch,
Revision: page.Revision,
}
for _, turn := range page.Turns {
response.Turns = append(response.Turns, conversationTurnModelToDTO(turn))
}
if page.HasMore {
cursor, mintErr := c.conversationTokens.mintSourceCursor(
record.ID, identity.UserID, conversationGeneration(record.InstalledAt), sessionID,
query.taskID, query.sort, nil, page.CursorID, c.svc.conversationEpoch, query.limit,
)
if mintErr != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
response.Cursor = &cursor
}
ctx.JSON(http.StatusOK, response)
}
func (c *Controller) conversationSourceRevision(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
record, identity, ok := c.authorizeConversationRequest(ctx)
if !ok || !c.validBinding(ctx, record, identity.UserID) {
return
}
reader, ok := c.conversationReader.(conversationSourceReader)
if !ok {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
revision, err := reader.ReadConversationRevision(ctx.Request.Context(), ctx.Param("sessionId"))
if !c.writeConversationSourceReadError(ctx, err) {
return
}
if !revision.Exists {
writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false)
return
}
ctx.JSON(http.StatusOK, conversationSourceRevisionResponse{Epoch: c.svc.conversationEpoch, Revision: revision.Revision})
}
func (c *Controller) parseSourceRequest(ctx *gin.Context, record *store.Record, userID string, allowAuthors bool) (conversationMessageQuery, *int64, string, bool) {
query, ok := parseConversationMessageQuery(ctx)
if !ok {
return conversationMessageQuery{}, nil, "", false
}
if !allowAuthors && len(query.authors) > 0 {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "author_type is not supported for turns", false)
return conversationMessageQuery{}, nil, "", false
}
var expected *int64
if raw, exists := ctx.GetQuery("expected_revision"); exists {
revision, err := strconv.ParseInt(raw, 10, 64)
if err != nil || revision < 0 {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "expected_revision must be a nonnegative integer", false)
return conversationMessageQuery{}, nil, "", false
}
expected = &revision
}
sessionID := ctx.Param("sessionId")
if query.taskID != nil && c.conversationReader != nil && !c.validateSourceTaskQuery(ctx, sessionID, *query.taskID) {
return conversationMessageQuery{}, nil, "", false
}
if query.cursor == "" {
return query, expected, "", true
}
lastID, ok := c.parseSourceCursor(ctx, record, userID, query)
return query, expected, lastID, ok
}
func (c *Controller) validateSourceTaskQuery(ctx *gin.Context, sessionID, taskID string) bool {
session, err := c.conversationReader.GetTaskSession(ctx.Request.Context(), sessionID)
if err != nil {
return c.writeConversationSourceReadError(ctx, err)
}
if session == nil || session.ID != sessionID {
writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false)
return false
}
if taskID != session.TaskID {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false)
return false
}
return true
}
func (c *Controller) parseSourceCursor(ctx *gin.Context, record *store.Record, userID string, query conversationMessageQuery) (string, bool) {
sessionID := ctx.Param("sessionId")
claims, err := c.conversationTokens.parse(query.cursor)
if err != nil || claims.Kind != tokenKindCursor || claims.PluginID != record.ID ||
claims.UserID != userID || claims.Generation != conversationGeneration(record.InstalledAt) ||
claims.SessionID != sessionID || !equalOptionalString(claims.TaskID, query.taskID) ||
claims.Sort != query.sort || !equalStrings(claims.Authors, query.authors) ||
claims.Epoch != c.svc.conversationEpoch || claims.PageSize != query.limit || claims.LastID == "" {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid cursor", false)
return "", false
}
return claims.LastID, true
}
func (c *Controller) writeConversationSourceReadError(ctx *gin.Context, err error) bool {
if err == nil {
return true
}
if errors.Is(err, taskmodels.ErrTaskSessionNotFound) {
writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false)
return false
}
if errors.Is(err, taskmodels.ErrConversationCursorStale) {
writeConversationError(ctx, http.StatusConflict, "reconciliation_required", "conversation cursor is stale", true)
return false
}
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return false
}
func (c *Controller) conversationBinding(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
identity, ok := authn.IdentityFromContext(ctx.Request.Context())
if !ok {
writeConversationError(ctx, http.StatusUnauthorized, "unauthenticated", "authentication required", false)
return
}
record, ok := c.conversationRecord(ctx.Param("id"))
if !ok {
writeConversationError(ctx, http.StatusNotFound, "not_found", "plugin not found", false)
return
}
generation := conversationGeneration(record.InstalledAt)
token, expiresAt, err := c.conversationTokens.mintBinding(record.ID, identity.UserID, generation)
if err != nil {
c.log.Error("mint plugin conversation binding", zap.Error(err))
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
ctx.JSON(http.StatusOK, conversationBindingResponse{
BindingToken: token,
Generation: generation,
ExpiresAt: expiresAt,
})
}
func (c *Controller) conversationRecord(pluginID string) (*store.Record, bool) {
record, err := c.svc.Get(pluginID)
if err != nil || record.Status != StatusActive || !record.Capabilities.CanRead("messages") {
return nil, false
}
return record, true
}
//nolint:cyclop,goconst // This handler owns authorization, snapshot validation, pagination, and cursor minting.
func (c *Controller) conversationMessages(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
record, identity, ok := c.authorizeConversationRequest(ctx)
if !ok || !c.validBinding(ctx, record, identity.UserID) {
return
}
query, ok := parseConversationMessageQuery(ctx)
if !ok {
return
}
if c.conversationReader == nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
sessionID := ctx.Param("sessionId")
snapshot, ok := c.validSnapshot(ctx, record, identity.UserID, sessionID)
if !ok {
return
}
session, err := c.conversationReader.GetTaskSession(ctx.Request.Context(), sessionID)
if err != nil {
if !c.writeConversationSourceReadError(ctx, err) {
return
}
}
if session == nil || session.ID != sessionID {
writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false)
return
} else if query.taskID != nil && *query.taskID != session.TaskID {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false)
return
}
request := taskservice.ListMessagesRequest{
TaskSessionID: sessionID,
Limit: query.limit,
Sort: query.sort,
AuthorType: query.authorType,
// Parity with the journal branch: multi-author filters and an
// explicit task_id must narrow the fallback page the same way.
AuthorTypes: query.authors,
TaskID: optionalStringValue(query.taskID),
}
if !c.applyConversationMessageCursor(ctx, record, identity.UserID, sessionID, query, snapshot, &request) {
return
}
messages, hasMore, err := c.conversationReader.ListMessagesPaginated(ctx.Request.Context(), request)
if err != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
response, ok := c.buildConversationMessagesResponse(
ctx,
record,
identity.UserID,
sessionID,
query,
snapshot,
messages,
hasMore,
)
if !ok {
return
}
ctx.JSON(http.StatusOK, response)
}
func (c *Controller) applyConversationMessageCursor(
ctx *gin.Context,
record *store.Record,
userID string,
sessionID string,
query conversationMessageQuery,
snapshot conversationTokenClaims,
request *taskservice.ListMessagesRequest,
) bool {
if query.cursor == "" {
return true
}
claims, err := c.conversationTokens.parse(query.cursor)
if err != nil || !validConversationMessageCursor(claims, record, userID, sessionID, query, snapshot) {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid cursor", false)
return false
}
if query.sort == "asc" {
request.After = claims.LastID
} else {
request.Before = claims.LastID
}
return true
}
func validConversationMessageCursor(
claims conversationTokenClaims,
record *store.Record,
userID string,
sessionID string,
query conversationMessageQuery,
snapshot conversationTokenClaims,
) bool {
return claims.Kind == tokenKindCursor &&
claims.PluginID == record.ID &&
claims.UserID == userID &&
claims.Generation == conversationGeneration(record.InstalledAt) &&
claims.SessionID == sessionID &&
equalOptionalString(claims.TaskID, query.taskID) &&
claims.Sort == query.sort &&
equalStrings(claims.Authors, query.authors) &&
claims.LastID != "" &&
claims.Cutoff == snapshot.Cutoff &&
claims.Fingerprint == snapshot.Fingerprint
}
func (c *Controller) buildConversationMessagesResponse(
ctx *gin.Context,
record *store.Record,
userID string,
sessionID string,
query conversationMessageQuery,
snapshot conversationTokenClaims,
messages []*taskmodels.Message,
hasMore bool,
) (conversationMessagesResponse, bool) {
response := conversationMessagesResponse{
Messages: make([]conversationMessageDTO, 0, len(messages)),
HasMore: hasMore,
}
for _, message := range messages {
response.Messages = append(response.Messages, conversationMessageModelToDTO(message))
}
if !hasMore {
return response, true
}
if len(messages) == 0 {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service returned an invalid page", true)
return conversationMessagesResponse{}, false
}
cursor, err := c.conversationTokens.mintCursor(
record.ID,
userID,
conversationGeneration(record.InstalledAt),
sessionID,
query.taskID,
query.sort,
query.authors,
messages[len(messages)-1].ID,
snapshot.Cutoff,
snapshot.Fingerprint,
)
if err != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return conversationMessagesResponse{}, false
}
response.Cursor = &cursor
return response, true
}
//nolint:cyclop // Turn lookup supports primary, task-scoped, and fallback sources.
func (c *Controller) conversationTurns(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
record, identity, ok := c.authorizeConversationRequest(ctx)
if !ok || !c.validBinding(ctx, record, identity.UserID) {
return
}
if c.conversationReader == nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
sessionID := ctx.Param("sessionId")
_, ok = c.validSnapshot(ctx, record, identity.UserID, sessionID)
if !ok {
return
}
var taskID *string
if rawTaskID, exists := ctx.GetQuery("task_id"); exists && rawTaskID != nullJSONValue {
if rawTaskID == "" {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false)
return
}
taskID = &rawTaskID
}
session, err := c.conversationReader.GetTaskSession(ctx.Request.Context(), sessionID)
if err != nil {
if !c.writeConversationSourceReadError(ctx, err) {
return
}
}
sessionFound := session != nil && session.ID == sessionID
if !sessionFound {
writeConversationError(ctx, http.StatusNotFound, "not_found", "task session not found", false)
return
} else if taskID != nil && *taskID != session.TaskID {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "task_id does not match the task session", false)
return
}
var scopedTaskID *string
if taskID != nil {
scopedTaskID = taskID
} else if sessionFound {
scopedTaskID = &session.TaskID
}
// The source repository remains authoritative. The task scope is already
// validated against the session above, so filter the source result before
// mapping it to the public DTO.
turns, err := loadFallbackTurns(ctx.Request.Context(), c.conversationReader, sessionID, optionalStringValue(scopedTaskID))
if err != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
response := conversationTurnsResponse{Turns: make([]conversationTurnDTO, 0, len(turns))}
for _, turn := range turns {
response.Turns = append(response.Turns, conversationTurnModelToDTO(turn))
}
ctx.JSON(http.StatusOK, response)
}
func (c *Controller) conversationContinuationRenew(ctx *gin.Context) {
ctx.Header("Cache-Control", conversationNoStore)
record, identity, ok := c.authorizeConversationRequest(ctx)
if !ok || !c.validBinding(ctx, record, identity.UserID) {
return
}
var request conversationContinuationRenewRequest
if err := ctx.ShouldBindJSON(&request); err != nil ||
request.Cursor == "" ||
request.SnapshotToken == "" {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "cursor and snapshot_token are required", false)
return
}
cursorClaims, cursorErr := c.conversationTokens.parse(request.Cursor)
snapshotClaims, snapshotErr := c.conversationTokens.parse(request.SnapshotToken)
if !validContinuationClaims(cursorClaims, snapshotClaims, cursorErr, snapshotErr) {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid continuation", false)
return
}
if cursorClaims.PluginID != record.ID ||
cursorClaims.UserID != identity.UserID ||
cursorClaims.Generation != conversationGeneration(record.InstalledAt) {
writeConversationError(ctx, http.StatusConflict, "upstream_failure", "conversation generation changed", true)
return
}
cursor, expiresAt, err := c.conversationTokens.renew(cursorClaims)
if err != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
snapshotToken, _, err := c.conversationTokens.renew(snapshotClaims)
if err != nil {
writeConversationError(ctx, http.StatusInternalServerError, "upstream_failure", "conversation service unavailable", true)
return
}
ctx.JSON(http.StatusOK, conversationContinuationRenewResponse{
Cursor: cursor,
SnapshotToken: snapshotToken,
Cutoff: cursorClaims.Cutoff,
Fingerprint: cursorClaims.Fingerprint,
ExpiresAt: expiresAt,
})
}
func (c *Controller) authorizeConversationRequest(
ctx *gin.Context,
) (*store.Record, authn.Identity, bool) {
identity, ok := authn.IdentityFromContext(ctx.Request.Context())
if !ok {
writeConversationError(ctx, http.StatusUnauthorized, "unauthenticated", "authentication required", false)
return nil, authn.Identity{}, false
}
record, ok := c.conversationRecord(ctx.Param("id"))
if !ok {
writeConversationError(ctx, http.StatusNotFound, "not_found", "plugin not found", false)
return nil, authn.Identity{}, false
}
return record, identity, true
}
func (c *Controller) validBinding(ctx *gin.Context, record *store.Record, userID string) bool {
claims, err := c.conversationTokens.parse(ctx.GetHeader("X-Kandev-Plugin-Binding"))
if err != nil ||
claims.Kind != tokenKindBinding ||
claims.PluginID != record.ID ||
claims.UserID != userID ||
claims.Generation != conversationGeneration(record.InstalledAt) {
writeConversationError(ctx, http.StatusUnauthorized, "unauthenticated", "invalid conversation binding", false)
return false
}
return true
}
func (c *Controller) validSnapshot(
ctx *gin.Context,
record *store.Record,
userID string,
sessionID string,
) (conversationTokenClaims, bool) {
claims, err := c.conversationTokens.parse(ctx.GetHeader("X-Kandev-Snapshot-Token"))
if err != nil ||
claims.Kind != tokenKindSnapshot ||
claims.PluginID != record.ID ||
claims.UserID != userID ||
claims.Generation != conversationGeneration(record.InstalledAt) ||
claims.SessionID != sessionID {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "invalid snapshot token", false)
return conversationTokenClaims{}, false
}
return claims, true
}
func parseConversationMessageQuery(ctx *gin.Context) (conversationMessageQuery, bool) {
limit, ok := parseConversationMessageLimit(ctx)
if !ok {
return conversationMessageQuery{}, false
}
sortOrder, ok := parseConversationMessageSort(ctx)
if !ok {
return conversationMessageQuery{}, false
}
authors, authorType, ok := parseConversationMessageAuthors(ctx)
if !ok {
return conversationMessageQuery{}, false
}
taskID, ok := parseOptionalConversationQuery(ctx, "task_id")
if !ok {
return conversationMessageQuery{}, false
}
cursor, ok := parseOptionalConversationQuery(ctx, "cursor")
if !ok {
return conversationMessageQuery{}, false
}
if taskID != nil && *taskID == nullJSONValue {
taskID = nil
}
return conversationMessageQuery{
limit: limit, sort: sortOrder, authorType: authorType,
authors: authors, taskID: taskID, cursor: optionalStringValue(cursor),
}, true
}
func parseConversationMessageLimit(ctx *gin.Context) (int, bool) {
raw, exists := ctx.GetQuery("limit")
if !exists {
return 20, true
}
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 100 {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "limit must be between 1 and 100", false)
return 0, false
}
return limit, true
}
func parseConversationMessageSort(ctx *gin.Context) (string, bool) {
raw, exists := ctx.GetQuery("sort")
if !exists {
return "desc", true
}
if raw != "asc" && raw != "desc" {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "sort must be asc or desc", false)
return "", false
}
return raw, true
}
func parseConversationMessageAuthors(ctx *gin.Context) ([]string, string, bool) {
authors := ctx.QueryArray("author_type")
if len(authors) > 2 {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "unsupported author_type", false)
return nil, "", false
}
seen := make(map[string]struct{}, len(authors))
for _, author := range authors {
if author != string(taskmodels.MessageAuthorUser) && author != string(taskmodels.MessageAuthorAgent) {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "unsupported author_type", false)
return nil, "", false
}
if _, duplicate := seen[author]; duplicate {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", "duplicate author_type", false)
return nil, "", false
}
seen[author] = struct{}{}
}
if len(authors) == 1 {
return authors, authors[0], true
}
return authors, "", true
}
func parseOptionalConversationQuery(ctx *gin.Context, name string) (*string, bool) {
raw, exists := ctx.GetQuery(name)
if !exists {
return nil, true
}
if raw == "" {
writeConversationError(ctx, http.StatusBadRequest, "invalid_query", name+" must not be empty", false)
return nil, false
}
return &raw, true
}
func optionalStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func conversationMessageModelToDTO(message *taskmodels.Message) conversationMessageDTO {
messageType := string(message.Type)
if messageType == "" {
messageType = string(taskmodels.MessageTypeMessage)
}
updatedAt := message.UpdatedAt
if updatedAt.IsZero() {
updatedAt = message.CreatedAt
}
dto := conversationMessageDTO{
ID: message.ID,
TaskID: nonEmptyStringPointer(message.TaskID),
SessionID: message.TaskSessionID,
TurnID: nonEmptyStringPointer(message.TurnID),
AuthorType: string(message.AuthorType),
Type: messageType,
Content: sysprompt.StripSystemContent(message.Content),
CreatedAt: message.CreatedAt.UTC().Format(time.RFC3339Nano),
UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano),
}
if message.PromptIndex > 0 {
dto.PromptIndex = &message.PromptIndex
}
if senderTaskID, ok := message.Metadata["sender_task_id"].(string); ok && senderTaskID != "" {
dto.SenderTaskID = &senderTaskID
}
return dto
}
func conversationTurnModelToDTO(turn *taskmodels.Turn) conversationTurnDTO {
updatedAt := turn.UpdatedAt
if updatedAt.IsZero() {
updatedAt = turn.StartedAt
}
dto := conversationTurnDTO{
ID: turn.ID,
TaskID: nonEmptyStringPointer(turn.TaskID),
SessionID: turn.TaskSessionID,
StartedAt: turn.StartedAt.UTC().Format(time.RFC3339Nano),
UpdatedAt: updatedAt.UTC().Format(time.RFC3339Nano),
}
if turn.CompletedAt != nil {
completedAt := turn.CompletedAt.UTC().Format(time.RFC3339Nano)
dto.CompletedAt = &completedAt
}
return dto
}
func validContinuationClaims(
cursor conversationTokenClaims,
snapshot conversationTokenClaims,
cursorErr error,
snapshotErr error,
) bool {
return cursorErr == nil &&
snapshotErr == nil &&
cursor.Kind == tokenKindCursor &&
snapshot.Kind == tokenKindSnapshot &&
matchingContinuationClaims(cursor, snapshot)
}
func matchingContinuationClaims(
cursor conversationTokenClaims,
snapshot conversationTokenClaims,
) bool {
return cursor.PluginID == snapshot.PluginID &&
cursor.UserID == snapshot.UserID &&
cursor.Generation == snapshot.Generation &&
cursor.SessionID == snapshot.SessionID &&
cursor.Cutoff == snapshot.Cutoff &&
cursor.Fingerprint != "" &&
cursor.Fingerprint == snapshot.Fingerprint
}
func nonEmptyStringPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
func equalOptionalString(left, right *string) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func writeConversationError(ctx *gin.Context, status int, code, message string, retryable bool) {
ctx.JSON(status, conversationErrorEnvelope{Error: conversationError{
Code: code, Message: message, Retryable: retryable,
}})
}