package handlers import ( "database/sql" "encoding/json" "fmt" "git.gobha.me/xcaliber/chat-switchboard/database" ) // ── Tree Types ────────────────────────────── // PathMessage represents a single message in an active path, enriched // with sibling metadata for the frontend branch indicator. type PathMessage struct { ID string `json:"id"` ParentID *string `json:"parent_id"` Role string `json:"role"` Content string `json:"content"` Model *string `json:"model"` TokensUsed *int `json:"tokens_used,omitempty"` ToolCalls *json.RawMessage `json:"tool_calls,omitempty"` Metadata *json.RawMessage `json:"metadata,omitempty"` SiblingCount int `json:"sibling_count"` SiblingIndex int `json:"sibling_index"` ParticipantType string `json:"participant_type,omitempty"` ParticipantID string `json:"participant_id,omitempty"` CreatedAt string `json:"created_at"` } // SiblingInfo is a lightweight entry for the siblings listing endpoint. type SiblingInfo struct { ID string `json:"id"` Role string `json:"role"` Model *string `json:"model,omitempty"` SiblingIndex int `json:"sibling_index"` Preview string `json:"preview"` // first ~80 chars of content CreatedAt string `json:"created_at"` } // ── Get Active Leaf ───────────────────────── // getActiveLeaf returns the cursor's active_leaf_id for a user in a channel. // Falls back to the chronologically latest live message if no cursor exists. func getActiveLeaf(channelID, userID string) (*string, error) { var leafID *string // Try cursor first err := database.DB.QueryRow(` SELECT active_leaf_id FROM channel_cursors WHERE channel_id = $1 AND user_id = $2 `, channelID, userID).Scan(&leafID) if err == nil && leafID != nil { // Verify the leaf still exists and isn't deleted var exists bool database.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL) `, *leafID).Scan(&exists) if exists { return leafID, nil } } // Fallback: latest live message in channel var fallbackID string err = database.DB.QueryRow(` SELECT id FROM messages WHERE channel_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1 `, channelID).Scan(&fallbackID) if err == sql.ErrNoRows { return nil, nil // empty channel } if err != nil { return nil, err } return &fallbackID, nil } // ── Get Active Path ───────────────────────── // getActivePath walks from the active leaf up to the root via parent_id, // returning messages in root-first order. This is what gets sent to the LLM. func getActivePath(channelID, userID string) ([]PathMessage, error) { leafID, err := getActiveLeaf(channelID, userID) if err != nil { return nil, err } if leafID == nil { return []PathMessage{}, nil // empty channel } return getPathToLeaf(channelID, *leafID) } // getPathToLeaf walks from a specific leaf up to root, returning root-first. func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { rows, err := database.DB.Query(` WITH RECURSIVE path AS ( -- Anchor: start at the leaf SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at, 0 AS depth FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL UNION ALL -- Walk up via parent_id SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata, m.participant_type, m.participant_id, m.sibling_index, m.created_at, p.depth + 1 FROM messages m JOIN path p ON m.id = p.parent_id WHERE m.deleted_at IS NULL ) SELECT id, parent_id, role, content, model, tokens_used, tool_calls, metadata, participant_type, participant_id, sibling_index, created_at FROM path ORDER BY depth DESC `, leafID, channelID) if err != nil { return nil, fmt.Errorf("getPathToLeaf: %w", err) } defer rows.Close() var path []PathMessage for rows.Next() { var m PathMessage var participantType, participantID sql.NullString var toolCallsJSON, metadataJSON []byte if err := rows.Scan( &m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed, &toolCallsJSON, &metadataJSON, &participantType, &participantID, &m.SiblingIndex, &m.CreatedAt, ); err != nil { return nil, fmt.Errorf("getPathToLeaf scan: %w", err) } if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" { raw := json.RawMessage(toolCallsJSON) m.ToolCalls = &raw } if len(metadataJSON) > 0 && string(metadataJSON) != "null" && string(metadataJSON) != "{}" { raw := json.RawMessage(metadataJSON) m.Metadata = &raw } if participantType.Valid { m.ParticipantType = participantType.String } if participantID.Valid { m.ParticipantID = participantID.String } path = append(path, m) } // Enrich with sibling counts for i := range path { path[i].SiblingCount = getSiblingCount(channelID, path[i].ParentID) } return path, nil } // ── Sibling Queries ───────────────────────── // getSiblingCount returns how many live siblings share the same parent_id. // Root messages (parent_id IS NULL) count all roots in the same channel. func getSiblingCount(channelID string, parentID *string) int { var count int if parentID == nil { database.DB.QueryRow(` SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL `, channelID).Scan(&count) } else { database.DB.QueryRow(` SELECT COUNT(*) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL `, *parentID).Scan(&count) } if count == 0 { count = 1 } return count } // getSiblings returns all live siblings of a given message (including itself), // ordered by sibling_index. func getSiblings(messageID string) ([]SiblingInfo, int, error) { // First get the parent_id of the target message var parentID *string var channelID string err := database.DB.QueryRow(` SELECT parent_id, channel_id FROM messages WHERE id = $1 AND deleted_at IS NULL `, messageID).Scan(&parentID, &channelID) if err != nil { return nil, 0, fmt.Errorf("message not found: %w", err) } var rows *sql.Rows if parentID == nil { // Root messages: siblings are other roots in the same channel rows, err = database.DB.Query(` SELECT id, role, model, sibling_index, LEFT(content, 80), created_at FROM messages WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL ORDER BY sibling_index, created_at `, channelID) } else { rows, err = database.DB.Query(` SELECT id, role, model, sibling_index, LEFT(content, 80), created_at FROM messages WHERE parent_id = $1 AND deleted_at IS NULL ORDER BY sibling_index, created_at `, *parentID) } if err != nil { return nil, 0, err } defer rows.Close() var siblings []SiblingInfo currentIdx := 0 for i := 0; rows.Next(); i++ { var s SiblingInfo if err := rows.Scan(&s.ID, &s.Role, &s.Model, &s.SiblingIndex, &s.Preview, &s.CreatedAt); err != nil { return nil, 0, err } if s.ID == messageID { currentIdx = i } siblings = append(siblings, s) } return siblings, currentIdx, nil } // ── Find Leaf ─────────────────────────────── // findLeafFromMessage walks down from a message to find the deepest descendant. // Follows the first child (lowest sibling_index) at each level. // Used when switching branches: clicking a mid-tree sibling navigates to its leaf. func findLeafFromMessage(messageID string) (string, error) { var leafID string err := database.DB.QueryRow(` WITH RECURSIVE descendants AS ( SELECT id, 0 AS depth FROM messages WHERE id = $1 AND deleted_at IS NULL UNION ALL SELECT child.id, d.depth + 1 FROM messages child JOIN descendants d ON child.parent_id = d.id WHERE child.deleted_at IS NULL AND child.sibling_index = ( SELECT MIN(sibling_index) FROM messages WHERE parent_id = d.id AND deleted_at IS NULL ) ) SELECT id FROM descendants ORDER BY depth DESC LIMIT 1 `, messageID).Scan(&leafID) if err != nil { return messageID, nil // if anything fails, just use the message itself } return leafID, nil } // ── Next Sibling Index ────────────────────── // nextSiblingIndex returns the next sibling_index for a new child of parentID. // For root messages (parentID is nil), counts existing roots in the channel. func nextSiblingIndex(channelID string, parentID *string) int { var maxIdx sql.NullInt64 if parentID == nil { database.DB.QueryRow(` SELECT MAX(sibling_index) FROM messages WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL `, channelID).Scan(&maxIdx) } else { database.DB.QueryRow(` SELECT MAX(sibling_index) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL `, *parentID).Scan(&maxIdx) } if !maxIdx.Valid { return 0 } return int(maxIdx.Int64) + 1 } // ── Update Cursor ─────────────────────────── // updateCursor upserts the channel_cursors row for a user. func updateCursor(channelID, userID, messageID string) error { _, err := database.DB.Exec(` INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ($1, $2, $3) ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW() `, channelID, userID, messageID) return err }