Changeset 0.15.0 (#71)
This commit is contained in:
@@ -163,27 +163,13 @@ func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Null out vault columns — user gets a fresh vault on next login
|
||||
_, err := database.DB.Exec(`
|
||||
UPDATE users
|
||||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
|
||||
}
|
||||
deleted := DestroyVaultDB(c.Request.Context(), userID)
|
||||
|
||||
// Evict from session cache (if user is currently logged in)
|
||||
h.uekCache.Evict(userID)
|
||||
|
||||
// Delete personal provider configs — their keys are undecryptable now
|
||||
result, err := database.DB.Exec(`
|
||||
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
|
||||
} else if rows, _ := result.RowsAffected(); rows > 0 {
|
||||
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
|
||||
if deleted > 0 {
|
||||
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", deleted, userID)
|
||||
}
|
||||
|
||||
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
|
||||
@@ -191,6 +177,40 @@ func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
|
||||
})
|
||||
}
|
||||
|
||||
// ResetVault explicitly resets a user's vault without changing their password.
|
||||
// Clears vault columns, deletes personal provider configs, evicts UEK cache.
|
||||
// The user gets a fresh vault on their next login.
|
||||
// POST /api/v1/admin/users/:id/vault/reset
|
||||
func (h *AdminHandler) ResetVault(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
|
||||
// Verify user exists
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.uekCache == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "vault not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
deleted := DestroyVaultDB(c.Request.Context(), userID)
|
||||
h.uekCache.Evict(userID)
|
||||
|
||||
h.auditLog(c, "user.vault_reset", "user", userID, models.JSONMap{
|
||||
"reason": "admin_manual_reset",
|
||||
"providers_deleted": deleted,
|
||||
})
|
||||
|
||||
log.Printf(" 🔐 Vault reset for user %s (%d personal provider(s) cleared)", userID, deleted)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "vault reset — user will get a fresh vault on next login",
|
||||
"providers_deleted": deleted,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
|
||||
|
||||
@@ -258,6 +258,68 @@ func hashToken(token string) string {
|
||||
|
||||
// ── Vault Lifecycle ─────────────────────────
|
||||
|
||||
// DestroyVaultDB nullifies a user's vault columns and deletes personal
|
||||
// provider configs. This is the shared DB-level operation used by:
|
||||
// - unlockVault (stale seal recovery)
|
||||
// - BootstrapAdmin / SeedUsers (password rotation at startup)
|
||||
// - AdminHandler.destroyVault (admin-initiated reset)
|
||||
//
|
||||
// Does NOT evict from UEK cache or write audit logs — callers handle that.
|
||||
func DestroyVaultDB(ctx context.Context, userID string) (providersDeleted int64) {
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||||
WHERE id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
result, err := database.DB.ExecContext(ctx, `
|
||||
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err)
|
||||
return 0
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
return rows
|
||||
}
|
||||
|
||||
// ProbeAndRepairVault checks whether a user's vault can be unlocked with
|
||||
// the given password. If the vault doesn't exist (vault_set=false), this is
|
||||
// a no-op. If the vault exists but the seal is stale (encrypted_uek was
|
||||
// wrapped with a different password), the vault and personal providers are
|
||||
// destroyed so initVault fires cleanly on next login.
|
||||
//
|
||||
// Used by BootstrapAdmin and SeedUsers where the password is known at
|
||||
// startup but the UEK cache is not available.
|
||||
func ProbeAndRepairVault(ctx context.Context, userID, password string) {
|
||||
var vaultSet bool
|
||||
var encryptedUEK, salt, nonce []byte
|
||||
|
||||
err := database.DB.QueryRowContext(ctx, `
|
||||
SELECT vault_set, encrypted_uek, uek_salt, uek_nonce
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&vaultSet, &encryptedUEK, &salt, &nonce)
|
||||
if err != nil || !vaultSet {
|
||||
return // no vault to probe
|
||||
}
|
||||
|
||||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||||
if _, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk); err == nil {
|
||||
return // vault seal matches current password — all good
|
||||
}
|
||||
|
||||
// Stale seal: password has actually changed since the vault was sealed
|
||||
deleted := DestroyVaultDB(ctx, userID)
|
||||
if deleted > 0 {
|
||||
log.Printf(" 🔐 Vault stale-seal repair for user %s: cleared %d personal provider(s)", userID, deleted)
|
||||
} else {
|
||||
log.Printf(" 🔐 Vault stale-seal repair for user %s: vault columns cleared", userID)
|
||||
}
|
||||
}
|
||||
|
||||
// initVault generates a UEK, wraps it with the user's password, and stores
|
||||
// the encrypted UEK + salt + nonce on the user record. Called on registration
|
||||
// and on first login for pre-migration users.
|
||||
@@ -328,7 +390,20 @@ func (h *AuthHandler) unlockVault(ctx context.Context, user *models.User, passwo
|
||||
pdk := crypto.DeriveKeyFromPassword(password, salt)
|
||||
uek, err := crypto.UnwrapUEK(encryptedUEK, nonce, pdk)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Vault unlock failed for user %s: %v", user.ID, err)
|
||||
// Stale seal: encrypted_uek was wrapped with a different password
|
||||
// (e.g. admin reset, BootstrapAdmin rotation, or pre-UEK migration).
|
||||
// The old UEK is irrecoverable. Destroy the vault and re-initialize
|
||||
// with the current password so the user isn't permanently locked out.
|
||||
deleted := DestroyVaultDB(ctx, user.ID)
|
||||
if deleted > 0 {
|
||||
log.Printf("⚠ Vault stale-seal recovery for user %s: cleared %d personal provider(s)", user.ID, deleted)
|
||||
} else {
|
||||
log.Printf("⚠ Vault stale-seal recovery for user %s: no personal providers to clear", user.ID)
|
||||
}
|
||||
|
||||
if err := h.initVault(ctx, user.ID, password); err != nil {
|
||||
log.Printf("⚠ Vault re-init failed for user %s: %v", user.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -356,6 +431,9 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||||
"role": models.UserRoleAdmin,
|
||||
"is_active": true,
|
||||
})
|
||||
// If the actual password changed, the vault seal is stale. Probe it
|
||||
// with the current password and destroy only if unwrap fails.
|
||||
ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword)
|
||||
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
|
||||
return
|
||||
}
|
||||
@@ -437,6 +515,8 @@ func SeedUsers(cfg *config.Config, s store.Stores) {
|
||||
"role": role,
|
||||
"is_active": true,
|
||||
})
|
||||
// Probe vault with current password — only destroys if seal is stale
|
||||
ProbeAndRepairVault(ctx, existing.ID, password)
|
||||
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
|
||||
continue
|
||||
}
|
||||
|
||||
257
server/handlers/live_compaction_test.go
Normal file
257
server/handlers/live_compaction_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/compaction"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Live Compaction Tests
|
||||
// ═══════════════════════════════════════════
|
||||
// Requires: TEST_DATABASE_URL + VENICE_API_KEY
|
||||
//
|
||||
// Tests the full Compact() pipeline end-to-end:
|
||||
// seed messages → call utility model → verify summary tree node
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func TestLive_CompactionFullPipeline(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
userID, adminToken := h.createAdminUser("compactadmin", "compact@test.com")
|
||||
|
||||
// Set up Venice provider + enable qwen3-4b
|
||||
configID, _ := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
|
||||
// Configure utility role → qwen3-4b
|
||||
w := h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
|
||||
"primary": map[string]interface{}{
|
||||
"provider_config_id": configID,
|
||||
"model_id": veniceTestModel,
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("configure utility role: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
t.Log(" ✓ Utility role configured with", veniceTestModel)
|
||||
|
||||
// Create a channel with enough messages to summarize
|
||||
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Compaction Test", "type": "direct",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Seed messages manually (not via completion — faster and cheaper)
|
||||
msgs := []struct{ role, content string }{
|
||||
{"user", "I'm planning a trip to Japan next month. What cities should I visit?"},
|
||||
{"assistant", "For a Japan trip, I'd recommend Tokyo for modern culture, Kyoto for temples and traditional culture, Osaka for food, and Hiroshima for history. How long is your trip?"},
|
||||
{"user", "About 10 days. I'm most interested in food and temples."},
|
||||
{"assistant", "With 10 days focusing on food and temples, I'd suggest: 3 days in Kyoto for Fushimi Inari, Kinkaku-ji, and Arashiyama. 2 days in Osaka for Dotonbori street food, Kuromon Market, and Namba. 2 days in Tokyo for Tsukiji Outer Market, Senso-ji, and Meiji Shrine. Then day trips to Nara (deer park and Todai-ji) and Kamakura (Great Buddha)."},
|
||||
{"user", "That sounds great! What about budget? I'm trying to keep it under $3000 for the whole trip."},
|
||||
{"assistant", "A $3000 budget for 10 days in Japan is doable. Rough breakdown: Flights ~$800-1000, Accommodation ~$50-80/night in hostels or budget hotels ($500-800), JR Pass 14-day ~$380, Food ~$30-50/day ($300-500), Activities and temples ~$200. Total: $2180-2880. Tips: eat at konbini (convenience stores) for cheap meals, visit free shrines, and book accommodation early."},
|
||||
{"user", "Should I get a JR Pass or just buy individual tickets?"},
|
||||
{"assistant", "For your itinerary covering Tokyo, Kyoto, Osaka, Nara, and Kamakura, a 7-day JR Pass ($200) would cover most of your intercity travel. The Tokyo-Kyoto shinkansen alone costs $120 one-way. I'd recommend the 7-day pass starting when you leave Tokyo for Kyoto, then use IC cards (Suica/Pasmo) for local transit."},
|
||||
{"user", "Perfect. One more question — what's the best time to visit temples to avoid crowds?"},
|
||||
{"assistant", "Early morning is best for temples. Fushimi Inari at sunrise (5-6am) is magical and nearly empty. Kinkaku-ji opens at 9am — arrive right at opening. Arashiyama bamboo grove is best before 8am. For Senso-ji in Tokyo, go at dawn for beautiful photos. Weekdays are always less crowded than weekends."},
|
||||
}
|
||||
|
||||
var lastMsgID string
|
||||
for _, m := range msgs {
|
||||
var parentPtr *string
|
||||
if lastMsgID != "" {
|
||||
parentPtr = &lastMsgID
|
||||
}
|
||||
err := database.TestDB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, 0)
|
||||
RETURNING id
|
||||
`, channelID, parentPtr, m.role, m.content).Scan(&lastMsgID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set cursor to last message
|
||||
database.TestDB.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
|
||||
`, channelID, userID, lastMsgID)
|
||||
|
||||
t.Logf(" ✓ Seeded %d messages in channel %s", len(msgs), channelID)
|
||||
|
||||
// ── Run compaction ──
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
resolver := roles.NewResolver(stores, nil)
|
||||
svc := compaction.NewService(stores, resolver)
|
||||
|
||||
result, err := svc.Compact(context.Background(), compaction.CompactRequest{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
TeamID: nil,
|
||||
Trigger: "manual",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact() failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf(" ✓ Compact result: %d messages summarized, model=%s, %d chars",
|
||||
result.SummarizedCount, result.Model, len(result.Content))
|
||||
|
||||
// Verify summary was created
|
||||
if result.SummarizedCount != len(msgs) {
|
||||
t.Errorf("summarized_count = %d, want %d", result.SummarizedCount, len(msgs))
|
||||
}
|
||||
if result.Content == "" {
|
||||
t.Fatal("summary content should not be empty")
|
||||
}
|
||||
if result.Model != veniceTestModel {
|
||||
t.Errorf("model = %q, want %q", result.Model, veniceTestModel)
|
||||
}
|
||||
|
||||
// Verify summary message exists in the tree
|
||||
var summaryContent, summaryRole string
|
||||
var metadataRaw []byte
|
||||
err = database.TestDB.QueryRow(`
|
||||
SELECT role, content, metadata FROM messages WHERE id = $1
|
||||
`, result.SummaryID).Scan(&summaryRole, &summaryContent, &metadataRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("query summary message: %v", err)
|
||||
}
|
||||
if summaryRole != "assistant" {
|
||||
t.Errorf("summary role = %q, want assistant", summaryRole)
|
||||
}
|
||||
|
||||
var metadata models.JSONMap
|
||||
json.Unmarshal(metadataRaw, &metadata)
|
||||
if metadata["type"] != "summary" {
|
||||
t.Errorf("metadata.type = %q, want summary", metadata["type"])
|
||||
}
|
||||
if metadata["trigger"] != "manual" {
|
||||
t.Errorf("metadata.trigger = %q, want manual", metadata["trigger"])
|
||||
}
|
||||
t.Logf(" ✓ Summary message verified: id=%s, metadata=%v", result.SummaryID, metadata)
|
||||
|
||||
// Verify cursor was updated to point to summary
|
||||
path, err := treepath.GetActivePath(channelID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActivePath after compaction: %v", err)
|
||||
}
|
||||
|
||||
// The last message in the path should be the summary
|
||||
if len(path) == 0 {
|
||||
t.Fatal("path should not be empty after compaction")
|
||||
}
|
||||
lastPath := path[len(path)-1]
|
||||
if lastPath.ID != result.SummaryID {
|
||||
t.Errorf("active path leaf = %s, want summary %s", lastPath.ID, result.SummaryID)
|
||||
}
|
||||
t.Logf(" ✓ Cursor updated: path has %d messages, leaf is summary", len(path))
|
||||
|
||||
// Verify usage was logged
|
||||
var usageCount int
|
||||
database.TestDB.QueryRow(`
|
||||
SELECT COUNT(*) FROM usage_log WHERE channel_id = $1 AND role = 'utility'
|
||||
`, channelID).Scan(&usageCount)
|
||||
if usageCount == 0 {
|
||||
t.Error("expected usage_log entry for utility role")
|
||||
}
|
||||
t.Logf(" ✓ Usage logged: %d entries", usageCount)
|
||||
}
|
||||
|
||||
// TestLive_CompactionContextBudgetGuardRail verifies that Compact()
|
||||
// rejects input that exceeds the utility model's context window.
|
||||
func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("guardrail_admin", "guardrail@test.com")
|
||||
userID := database.SeedTestUser(t, "guardrail_user", "gruser@test.com")
|
||||
|
||||
// Set up Venice + qwen3-4b (32K context)
|
||||
configID, catalogEntryID := setupVeniceWithModel(t, h, adminToken, veniceKey, veniceTestModel)
|
||||
|
||||
// Set max_context to 32000 in catalog (in case sync didn't populate it)
|
||||
database.TestDB.Exec(`
|
||||
UPDATE model_catalog SET capabilities = capabilities || '{"max_context": 32000}'::jsonb
|
||||
WHERE id = $1
|
||||
`, catalogEntryID)
|
||||
|
||||
// Configure utility role
|
||||
h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
|
||||
"primary": map[string]interface{}{
|
||||
"provider_config_id": configID,
|
||||
"model_id": veniceTestModel,
|
||||
},
|
||||
})
|
||||
|
||||
// Create channel with huge messages (>32K context worth)
|
||||
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Guard Rail Test", "type": "direct",
|
||||
})
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Seed 20 messages × 8000 chars each = 160K chars ≈ 40K tokens
|
||||
// This exceeds 32K × 0.80 = 25.6K token ceiling
|
||||
bigContent := make([]byte, 8000)
|
||||
for i := range bigContent {
|
||||
bigContent[i] = 'a'
|
||||
}
|
||||
var lastMsgID string
|
||||
for i := 0; i < 20; i++ {
|
||||
var parentPtr *string
|
||||
if lastMsgID != "" {
|
||||
parentPtr = &lastMsgID
|
||||
}
|
||||
role := "user"
|
||||
if i%2 == 1 {
|
||||
role = "assistant"
|
||||
}
|
||||
database.TestDB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, 0) RETURNING id
|
||||
`, channelID, parentPtr, role, string(bigContent)).Scan(&lastMsgID)
|
||||
}
|
||||
database.TestDB.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
|
||||
`, channelID, userID, lastMsgID)
|
||||
|
||||
// Run compaction — should fail with context budget error
|
||||
stores := postgres.NewStores(database.TestDB)
|
||||
resolver := roles.NewResolver(stores, nil)
|
||||
svc := compaction.NewService(stores, resolver)
|
||||
|
||||
_, err := svc.Compact(context.Background(), compaction.CompactRequest{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
TeamID: nil,
|
||||
Trigger: "auto",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Compact() should fail when content exceeds utility model context window")
|
||||
}
|
||||
|
||||
t.Logf(" ✓ Guard rail triggered: %v", err)
|
||||
|
||||
// Verify it's specifically a context budget error
|
||||
if !strings.Contains(err.Error(), "context window") {
|
||||
t.Errorf("expected context window error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/compaction"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// SummarizeHandler handles conversation summarization using the utility model role.
|
||||
// SummarizeHandler handles manual conversation summarization via HTTP.
|
||||
type SummarizeHandler struct {
|
||||
stores store.Stores
|
||||
resolver *roles.Resolver
|
||||
compaction *compaction.Service
|
||||
}
|
||||
|
||||
// NewSummarizeHandler creates a new handler.
|
||||
func NewSummarizeHandler(s store.Stores, resolver *roles.Resolver) *SummarizeHandler {
|
||||
return &SummarizeHandler{stores: s, resolver: resolver}
|
||||
// NewSummarizeHandler creates a new handler backed by the compaction service.
|
||||
func NewSummarizeHandler(svc *compaction.Service) *SummarizeHandler {
|
||||
return &SummarizeHandler{compaction: svc}
|
||||
}
|
||||
|
||||
// ── Summarize & Continue ──────────────────
|
||||
// POST /channels/:id/summarize
|
||||
//
|
||||
// Calls the utility role to summarize the conversation history, inserts
|
||||
// the summary as a special message node in the tree, and returns it.
|
||||
// Subsequent completions will use the summary as context boundary.
|
||||
// User-triggered compaction: calls the utility role to summarize the
|
||||
// conversation history, inserts the summary as a tree node, and returns it.
|
||||
|
||||
func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
@@ -55,9 +45,9 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── Check utility role is configured ──
|
||||
if !h.resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) {
|
||||
// If user doesn't have a personal override either, it's not available
|
||||
if !h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) {
|
||||
resolver := h.compaction.Resolver()
|
||||
if !resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) {
|
||||
if !resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.",
|
||||
})
|
||||
@@ -66,205 +56,40 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── Rate limiting (org-funded calls only) ──
|
||||
isPersonal := h.resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility)
|
||||
isPersonal := resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility)
|
||||
if !isPersonal {
|
||||
if err := h.checkRateLimit(c.Request.Context(), userID); err != nil {
|
||||
if err := h.compaction.CheckRateLimit(c.Request.Context(), userID); err != nil {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load active path ──
|
||||
path, err := getActivePath(channelID, userID)
|
||||
// ── Compact ──
|
||||
teamID := h.compaction.GetUserTeamID(c.Request.Context(), userID)
|
||||
result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Trigger: "manual",
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
}
|
||||
if len(path) < 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation too short to summarize"})
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing summary boundary (if any) and only summarize after it
|
||||
startIdx := 0
|
||||
for i, m := range path {
|
||||
if isSummaryMessage(&m) {
|
||||
startIdx = i + 1
|
||||
// Map specific errors to HTTP status codes
|
||||
switch err.Error() {
|
||||
case "conversation too short to summarize":
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case "not enough new messages since last summary":
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages to summarize (skip system messages, skip previous summaries)
|
||||
var toSummarize []string
|
||||
messagesInScope := 0
|
||||
var lastMessageID string
|
||||
for _, m := range path[startIdx:] {
|
||||
if m.Role == "system" || isSummaryMessage(&m) {
|
||||
continue
|
||||
}
|
||||
toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content))
|
||||
messagesInScope++
|
||||
lastMessageID = m.ID
|
||||
}
|
||||
|
||||
if messagesInScope < 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not enough new messages since last summary"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Build the summarization prompt ──
|
||||
conversationText := strings.Join(toSummarize, "\n\n")
|
||||
summaryPrompt := []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves:
|
||||
- Key decisions and conclusions reached
|
||||
- Important facts, names, numbers, and technical details mentioned
|
||||
- Action items or commitments made
|
||||
- The overall context and topic flow
|
||||
|
||||
Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.`,
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Summarize this conversation:\n\n" + conversationText,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Resolve team context for the user ──
|
||||
teamID := h.getUserTeamID(c.Request.Context(), userID)
|
||||
|
||||
// ── Call utility role ──
|
||||
log.Printf("📝 Summarizing channel %s for user %s (%d messages)", channelID, userID, messagesInScope)
|
||||
result, err := h.resolver.Complete(c.Request.Context(), roles.RoleUtility, userID, teamID, summaryPrompt)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Summarize failed for channel %s: %v", channelID, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "summarization failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Log usage ──
|
||||
h.logSummaryUsage(c.Request.Context(), channelID, userID, result)
|
||||
|
||||
// ── Insert summary message as a tree node ──
|
||||
// The summary message is inserted as an "assistant" message with special metadata.
|
||||
// Its parent is the last message that was summarized, making it part of the tree.
|
||||
metadata := models.JSONMap{
|
||||
"type": "summary",
|
||||
"summarized_until_id": lastMessageID,
|
||||
"summarized_count": messagesInScope,
|
||||
"utility_model": result.Model,
|
||||
"used_fallback": result.UsedFallback,
|
||||
}
|
||||
|
||||
metaJSON, _ := json.Marshal(metadata)
|
||||
siblingIdx := nextSiblingIndex(channelID, &lastMessageID)
|
||||
|
||||
var summaryMsgID string
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, parent_id, role, content, model, metadata, sibling_index, participant_type)
|
||||
VALUES ($1, $2, 'assistant', $3, $4, $5, $6, 'system')
|
||||
RETURNING id
|
||||
`, channelID, lastMessageID, result.Content, result.Model, string(metaJSON), siblingIdx).Scan(&summaryMsgID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to persist summary for channel %s: %v", channelID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save summary"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update cursor to point to the summary node
|
||||
if err := updateCursor(channelID, userID, summaryMsgID); err != nil {
|
||||
log.Printf("⚠ Failed to update cursor after summarize: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Summary created for channel %s: %s (%d messages → %d chars)",
|
||||
channelID, summaryMsgID, messagesInScope, len(result.Content))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary_id": summaryMsgID,
|
||||
"summarized_count": messagesInScope,
|
||||
"summary_id": result.SummaryID,
|
||||
"summarized_count": result.SummarizedCount,
|
||||
"model": result.Model,
|
||||
"used_fallback": result.UsedFallback,
|
||||
"content": result.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Rate Limiting ─────────────────────────
|
||||
|
||||
func (h *SummarizeHandler) checkRateLimit(ctx context.Context, userID string) error {
|
||||
// Load rate limit from global settings (default: 20/hour, 0 = unlimited)
|
||||
limit := 20
|
||||
settings, err := h.stores.GlobalConfig.Get(ctx, "utility_rate_limit")
|
||||
if err == nil {
|
||||
if v, ok := settings["value"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
limit = int(n)
|
||||
case int:
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
return nil // unlimited
|
||||
}
|
||||
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
count, err := h.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Rate limit check failed: %v", err)
|
||||
return nil // fail open — don't block on DB errors
|
||||
}
|
||||
|
||||
if count >= limit {
|
||||
return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────
|
||||
|
||||
func (h *SummarizeHandler) getUserTeamID(ctx context.Context, userID string) *string {
|
||||
// Get the user's first team (for role override resolution)
|
||||
var teamID string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT team_id FROM team_members WHERE user_id = $1 LIMIT 1
|
||||
`, userID).Scan(&teamID)
|
||||
if err != nil || teamID == "" {
|
||||
return nil
|
||||
}
|
||||
return &teamID
|
||||
}
|
||||
|
||||
func (h *SummarizeHandler) logSummaryUsage(ctx context.Context, channelID, userID string, result *roles.CompletionResult) {
|
||||
role := roles.RoleUtility
|
||||
entry := &models.UsageEntry{
|
||||
ChannelID: &channelID,
|
||||
UserID: userID,
|
||||
ProviderConfigID: &result.ConfigID,
|
||||
ProviderScope: result.ProviderScope,
|
||||
ModelID: result.Model,
|
||||
Role: &role,
|
||||
PromptTokens: result.InputTokens,
|
||||
CompletionTokens: result.OutputTokens,
|
||||
CacheCreationTokens: result.CacheCreationTokens,
|
||||
CacheReadTokens: result.CacheReadTokens,
|
||||
}
|
||||
|
||||
// Calculate cost from pricing
|
||||
pricing, err := h.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model)
|
||||
if err == nil && pricing != nil {
|
||||
if pricing.InputPerM != nil {
|
||||
costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM
|
||||
entry.CostInput = &costIn
|
||||
}
|
||||
if pricing.OutputPerM != nil {
|
||||
costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM
|
||||
entry.CostOutput = &costOut
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Usage.Log(ctx, entry); err != nil {
|
||||
log.Printf("⚠ Failed to log summary usage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,309 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
// This file previously contained all tree traversal logic (path building,
|
||||
// sibling queries, cursor management). As of v0.15.0, the core logic lives
|
||||
// in the treepath package; these are package-local aliases so existing
|
||||
// handler code (messages.go, completion.go) compiles with minimal churn.
|
||||
//
|
||||
// New code should import treepath directly.
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ── Tree Types ──────────────────────────────
|
||||
// ── Type Aliases ────────────────────────────
|
||||
// Existing handler code references these types by unqualified name.
|
||||
|
||||
// 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"`
|
||||
}
|
||||
type PathMessage = treepath.PathMessage
|
||||
type SiblingInfo = treepath.SiblingInfo
|
||||
|
||||
// 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"`
|
||||
}
|
||||
// ── Function Wrappers ───────────────────────
|
||||
|
||||
// ── 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
|
||||
return treepath.GetActiveLeaf(channelID, userID)
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
return treepath.GetActivePath(channelID, userID)
|
||||
}
|
||||
|
||||
// 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
|
||||
return treepath.GetPathToLeaf(channelID, leafID)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
return treepath.GetSiblingCount(channelID, parentID)
|
||||
}
|
||||
|
||||
// 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
|
||||
return treepath.GetSiblings(messageID)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
return treepath.FindLeafFromMessage(messageID)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
return treepath.NextSiblingIndex(channelID, parentID)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
return treepath.UpdateCursor(channelID, userID, messageID)
|
||||
}
|
||||
|
||||
// isSummaryMessage checks if a PathMessage has summary metadata.
|
||||
// This was previously defined in completion.go; moved here alongside the
|
||||
// other tree helpers so all callers use the canonical treepath version.
|
||||
func isSummaryMessage(m *PathMessage) bool {
|
||||
return treepath.IsSummaryMessage(m)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user