909 lines
30 KiB
Go
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,
|
|
}})
|
|
}
|