Files
viettranx cd4a8bd10f fix(store): persist last_prompt_tokens via sessions.metadata for accurate UI display
- Store last_prompt_tokens in sessions.metadata JSONB (PostgreSQL + SQLite)
- Update SessionsList queries to retrieve metadata and provide token display values
- Add fallback heuristic for sessions without metadata (estimated from history)
- Add tests: sessions_list_heuristic_test.go, sessions_list_metadata_tokens_test.go
- Add integration test: sessions_display_tokens_integration_test.go
2026-04-23 08:31:53 +07:00

349 lines
9.4 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"maps"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteSessionStore implements store.SessionStore backed by SQLite.
type SQLiteSessionStore struct {
db *sql.DB
mu sync.RWMutex
cache map[string]*store.SessionData
// OnDelete is called with the session key when a session is deleted.
OnDelete func(sessionKey string)
}
func NewSQLiteSessionStore(db *sql.DB) *SQLiteSessionStore {
// No migrateLegacyWSKeys — SQLite has no regexp_replace.
return &SQLiteSessionStore{db: db, cache: make(map[string]*store.SessionData)}
}
// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions.
func sessionCacheKey(ctx context.Context, key string) string {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
return tid.String() + ":" + key
}
func (s *SQLiteSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData {
s.mu.Lock()
defer s.mu.Unlock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
return data
}
now := time.Now()
data = &store.SessionData{
Key: key,
Messages: []providers.Message{},
Created: now,
Updated: now,
}
// Extract team_id from team session keys (agent:{agentId}:team:{teamId}:{chatId}).
var teamID *uuid.UUID
if parts := strings.SplitN(key, ":", 5); len(parts) >= 4 && parts[2] == "team" {
if tid, err := uuid.Parse(parts[3]); err == nil {
teamID = &tid
data.TeamID = teamID
}
}
s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.ExecContext(ctx,
`INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id)
VALUES (?,?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx),
)
return data
}
// Get returns the session if it exists (cache or DB), nil otherwise. Never creates.
func (s *SQLiteSessionStore) Get(ctx context.Context, key string) *store.SessionData {
s.mu.RLock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
s.mu.RUnlock()
return cached
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return cached
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
}
return data
}
func (s *SQLiteSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) {
s.mu.Lock()
defer s.mu.Unlock()
if msg.CreatedAt == nil {
now := time.Now().UTC()
msg.CreatedAt = &now
}
data := s.getOrInit(ctx, key)
data.Messages = append(data.Messages, msg)
data.Updated = time.Now()
}
func (s *SQLiteSessionStore) GetHistory(ctx context.Context, key string) []providers.Message {
s.mu.RLock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
s.mu.RUnlock()
return msgs
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
data := s.loadFromDB(ctx, key)
if data == nil {
return nil
}
s.cache[sessionCacheKey(ctx, key)] = data
msgs := make([]providers.Message, len(data.Messages))
copy(msgs, data.Messages)
return msgs
}
func (s *SQLiteSessionStore) GetSummary(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Summary
}
return ""
}
func (s *SQLiteSessionStore) SetSummary(ctx context.Context, key, summary string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Summary = summary
data.Updated = time.Now()
}
}
func (s *SQLiteSessionStore) GetLabel(ctx context.Context, key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data.Label
}
return ""
}
func (s *SQLiteSessionStore) SetLabel(ctx context.Context, key, label string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
data.Label = label
data.Updated = time.Now()
}
}
func (s *SQLiteSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil {
out := make(map[string]string, len(data.Metadata))
maps.Copy(out, data.Metadata)
return out
}
return nil
}
func (s *SQLiteSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.getOrInit(ctx, key)
if data.Metadata == nil {
data.Metadata = make(map[string]string)
}
maps.Copy(data.Metadata, metadata)
data.Updated = time.Now()
}
func (s *SQLiteSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) {
s.mu.Lock()
defer s.mu.Unlock()
data := s.getOrInit(ctx, key)
if agentUUID != uuid.Nil {
data.AgentUUID = agentUUID
}
if userID != "" {
data.UserID = userID
}
}
func (s *SQLiteSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
if model != "" {
data.Model = model
}
if provider != "" {
data.Provider = provider
}
if channel != "" {
data.Channel = channel
}
}
}
// --- helpers ---
func (s *SQLiteSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData {
if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok {
return data
}
data := s.loadFromDB(ctx, key)
if data != nil {
s.cache[sessionCacheKey(ctx, key)] = data
return data
}
now := time.Now()
data = &store.SessionData{
Key: key,
Messages: []providers.Message{},
Created: now,
Updated: now,
}
s.cache[sessionCacheKey(ctx, key)] = data
msgsJSON, _ := json.Marshal([]providers.Message{})
s.db.ExecContext(ctx,
`INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id)
VALUES (?,?,?,?,?,?) ON CONFLICT (tenant_id, session_key) DO NOTHING`,
uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx),
)
return data
}
func (s *SQLiteSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData {
var sessionKey string
var msgsJSON []byte
var summary, model, provider, channel, label, spawnedBy, userID *string
var agentID, teamID *uuid.UUID
var inputTokens, outputTokens int64
var compactionCount, memoryFlushCompactionCount, spawnDepth int
var memoryFlushAt int64
createdAt, updatedAt := scanTimePair()
var metaJSON *[]byte
tid := tenantIDForInsert(ctx)
err := s.db.QueryRowContext(ctx,
`SELECT session_key, messages, summary, model, provider, channel,
input_tokens, output_tokens, compaction_count,
memory_flush_compaction_count, memory_flush_at,
label, spawned_by, spawn_depth, agent_id, user_id,
COALESCE(metadata, '{}'), created_at, updated_at, team_id
FROM sessions WHERE session_key = ? AND tenant_id = ?`, key, tid,
).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel,
&inputTokens, &outputTokens, &compactionCount,
&memoryFlushCompactionCount, &memoryFlushAt,
&label, &spawnedBy, &spawnDepth, &agentID, &userID,
&metaJSON, createdAt, updatedAt, &teamID)
if err != nil {
return nil
}
var msgs []providers.Message
json.Unmarshal(msgsJSON, &msgs)
var meta map[string]string
if metaJSON != nil {
json.Unmarshal(*metaJSON, &meta)
}
// Restore adaptive-throttle fields from metadata so GetLastPromptTokens()
// returns the persisted value after a server restart (clean cache).
var lastPromptTokens, lastMessageCount int
if meta != nil {
if v := meta["last_prompt_tokens"]; v != "" {
lastPromptTokens, _ = strconv.Atoi(v)
}
if v := meta["last_message_count"]; v != "" {
lastMessageCount, _ = strconv.Atoi(v)
}
}
return &store.SessionData{
Key: sessionKey,
Messages: msgs,
Summary: derefStr(summary),
Created: createdAt.Time,
Updated: updatedAt.Time,
AgentUUID: derefUUID(agentID),
UserID: derefStr(userID),
TeamID: teamID,
Model: derefStr(model),
Provider: derefStr(provider),
Channel: derefStr(channel),
InputTokens: inputTokens,
OutputTokens: outputTokens,
CompactionCount: compactionCount,
MemoryFlushCompactionCount: memoryFlushCompactionCount,
MemoryFlushAt: memoryFlushAt,
Label: derefStr(label),
SpawnedBy: derefStr(spawnedBy),
SpawnDepth: spawnDepth,
Metadata: meta,
LastPromptTokens: lastPromptTokens,
LastMessageCount: lastMessageCount,
}
}
func nilSessionUUID(u uuid.UUID) *uuid.UUID {
if u == uuid.Nil {
return nil
}
return &u
}