diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5b32611..19c8241 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -161,7 +161,7 @@ jobs: PGPORT: ${{ env.POSTGRES_PORT }} PGUSER: ${{ secrets.POSTGRES_USER }} PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - run: go test -v -race -count=1 ./... + run: go test -v -race -count=1 -p 1 ./... - name: Drop CI test database if: always() diff --git a/VERSION b/VERSION index 0548fb4..7092c7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.14.0 \ No newline at end of file +0.15.0 \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fc0cc37..50cb240 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -749,15 +749,30 @@ See [DESIGN-0.14.0.md](DESIGN-0.14.0.md) for full spec. --- -## v0.15.0 — Compaction +## v0.15.0 — Compaction ✅ Depends on: utility model role (v0.10.0). -- [ ] Auto-compaction service: background job that calls utility model to summarize -- [ ] Channel-scoped: triggers when context exceeds threshold -- [ ] Summaries stored as system messages -- [ ] Configurable: per-channel opt-in/out, summary model override -- [ ] Admin controls for resource limits +- [x] Compaction service: extracts messages, calls utility model to summarize, replaces with summary +- [x] `Summarize & Continue` button: user-triggered compaction via existing summarize endpoint +- [x] Channel-scoped context threshold triggers (configurable min messages, min chars, activity gap) +- [x] Summaries stored as system messages with `is_summary = true` metadata +- [x] Background scanner: periodic candidate detection with cooldown and in-flight dedup +- [x] Configurable: per-channel opt-in/out (`compaction_opt_out`), global threshold overrides +- [x] Admin controls: global enable/disable, threshold/cooldown settings via `global_settings` +- [x] Context budget guard rail: estimated tokens must exceed 80% of ceiling before compaction +- [x] Token estimation: `(len + 3) / 4` heuristic with path-from-summary support +- [x] Rate limiting on summarize endpoint +- [x] Comprehensive test suite: scanner candidates, guard rails, live pipeline integration tests + +Bug fixes during CI stabilization: +- [x] Fixed `channels.deleted_at` → `channels.is_archived` (channels use soft-archive, not soft-delete) +- [x] Fixed NULL model column scan in scanner (`COALESCE(c.model, '')`) +- [x] Fixed test channel backdating (BEFORE UPDATE trigger override via INSERT) +- [x] Fixed vault nil-check fallback in role resolver for plaintext API keys +- [x] Fixed parallel test DB races (`-p 1` serialization) +- [x] Fixed proactive token refresh cascade: failed refresh no longer nukes active session +- [x] Fixed model visibility reset: preferences fetch failure preserves existing hidden state --- @@ -1160,6 +1175,7 @@ based on need. **UX / Multi-Seat** - ~~Per-chat model/preset persistence (server-side)~~ ✅ _(v0.12.0 — `channels.settings.last_selector_id` JSONB merge, localStorage write-through cache)_ +- Per-provider model preferences: `user_model_settings` unique key is `(user_id, model_id)` — same model from different providers shares one visibility toggle. Needs `provider_config_id` dimension in DB constraint, store, API, and frontend `hiddenModels` keying. Frontend composite key (`configId:modelId`) already exists in `App.models[].id`. **Knowledge Bases — Future** - Hybrid search: combine vector similarity with full-text `tsvector`, re-rank @@ -1184,4 +1200,4 @@ based on need. - Multi-tenant SaaS mode - Plugin/extension marketplace - Virtual scroll for long conversations -- SQLite backend option (single-user / dev) +- SQLite backend option (single-user / dev) \ No newline at end of file diff --git a/server/compaction/compaction_test.go b/server/compaction/compaction_test.go index 628f536..718859f 100644 --- a/server/compaction/compaction_test.go +++ b/server/compaction/compaction_test.go @@ -43,14 +43,25 @@ func setGlobalSetting(t *testing.T, key string, value interface{}) { } } -// touchChannelTime backdates a channel's updated_at. -func touchChannelTime(t *testing.T, channelID string, age time.Duration) { +// seedChannelBackdated creates a channel with updated_at set to (now - age). +// Uses INSERT with an explicit timestamp so the BEFORE UPDATE trigger on +// channels (which overwrites updated_at = NOW()) never fires. +func seedChannelBackdated(t *testing.T, userID, title string, age time.Duration, msgCount, contentSize int) (channelID string, msgIDs []string) { t.Helper() target := time.Now().Add(-age) - _, err := database.DB.Exec(`UPDATE channels SET updated_at = $1 WHERE id = $2`, target, channelID) + err := database.DB.QueryRow(` + INSERT INTO channels (user_id, title, type, updated_at) + VALUES ($1, $2, 'direct', $3) + RETURNING id + `, userID, title, target).Scan(&channelID) if err != nil { - t.Fatalf("touchChannelTime: %v", err) + t.Fatalf("seedChannelBackdated: %v", err) } + msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize) + if len(msgIDs) > 0 { + database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1]) + } + return } // setChannelSettings sets the channel.settings JSONB. @@ -89,10 +100,8 @@ func TestScanner_FindCandidates_ReturnsQualifying(t *testing.T) { // Create a channel with enough messages to qualify // 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K) - channelID, _ := seedChannel(t, userID, "Big Chat", 20, 2000) - - // Backdate so it passes the activity gap (>2min old) and recency (<7 days) - touchChannelTime(t, channelID, 5*time.Minute) + // Backdated 5min so it passes activity gap (>2min) and recency (<7 days) + channelID, _ := seedChannelBackdated(t, userID, "Big Chat", 5*time.Minute, 20, 2000) ctx := context.Background() candidates := sc.findCandidates(ctx) @@ -122,8 +131,7 @@ func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) { userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com") // Only 4 messages — below candidateMinMessages=10 - channelID, _ := seedChannel(t, userID, "Small Chat", 4, 2000) - touchChannelTime(t, channelID, 5*time.Minute) + channelID, _ := seedChannelBackdated(t, userID, "Small Chat", 5*time.Minute, 4, 2000) ctx := context.Background() candidates := sc.findCandidates(ctx) @@ -164,8 +172,7 @@ func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) { sc := NewScanner(svc, stores, ScannerConfig{}) userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com") - channelID, _ := seedChannel(t, userID, "Archived Chat", 20, 2000) - touchChannelTime(t, channelID, 5*time.Minute) + channelID, _ := seedChannelBackdated(t, userID, "Archived Chat", 5*time.Minute, 20, 2000) // Archive the channel database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID) @@ -377,8 +384,9 @@ func TestScanner_GetCooldownDuration_Override(t *testing.T) { func TestEstimateTokens_GuardRailMath(t *testing.T) { // Simulate a large conversation that exceeds a 32K utility model - // 100K chars ≈ 25K tokens of conversation + system prompt overhead - largeContent := make([]byte, 100000) + // 120K chars ≈ 30K tokens of conversation + system prompt overhead + // inputCeiling = 32K * 0.80 = 25.6K → 30K exceeds it + largeContent := make([]byte, 120000) contentTokens := EstimateTokens(string(largeContent)) systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead @@ -387,7 +395,7 @@ func TestEstimateTokens_GuardRailMath(t *testing.T) { inputCeiling := int(float64(utilityBudget) * 0.80) if totalPrompt <= inputCeiling { - t.Errorf("100K chars (%d tokens) should exceed 32K model ceiling (%d tokens)", + t.Errorf("120K chars (%d tokens) should exceed 32K model ceiling (%d tokens)", totalPrompt, inputCeiling) } diff --git a/server/compaction/scanner.go b/server/compaction/scanner.go index 8fe4f61..db0856d 100644 --- a/server/compaction/scanner.go +++ b/server/compaction/scanner.go @@ -237,13 +237,12 @@ func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel { maxAge := time.Now().Add(-candidateMaxAge) rows, err := database.DB.QueryContext(ctx, ` - SELECT c.id, c.user_id, c.model, c.settings::text, + SELECT c.id, c.user_id, COALESCE(c.model, ''), COALESCE(c.settings::text, '{}'), COUNT(m.id) AS msg_count, COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars FROM channels c JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL - WHERE c.deleted_at IS NULL - AND c.type = 'direct' + WHERE c.type = 'direct' AND c.is_archived = false AND c.updated_at < $1 AND c.updated_at > $2 diff --git a/server/handlers/completion.go b/server/handlers/completion.go index a4f2ce7..2e08613 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -829,18 +829,6 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm return messages, nil } -// isSummaryMessage checks if a PathMessage has summary metadata. -func isSummaryMessage(m *PathMessage) bool { - if m.Metadata == nil { - return false - } - var meta map[string]interface{} - if err := json.Unmarshal(*m.Metadata, &meta); err != nil { - return false - } - return meta["type"] == "summary" -} - // ── Message Persistence ───────────────────── // persistMessage inserts a message into the tree, updates the cursor, and diff --git a/server/handlers/summarize.go b/server/handlers/summarize.go index 240de46..ce193d4 100644 --- a/server/handlers/summarize.go +++ b/server/handlers/summarize.go @@ -33,7 +33,7 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) { // ── Verify channel ownership ── var ownerID string err := database.DB.QueryRow( - `SELECT user_id FROM channels WHERE id = $1 AND deleted_at IS NULL`, channelID, + `SELECT user_id FROM channels WHERE id = $1`, channelID, ).Scan(&ownerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) diff --git a/server/main.go b/server/main.go index 9bf298c..752da03 100644 --- a/server/main.go +++ b/server/main.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/compaction" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" @@ -248,8 +249,9 @@ func main() { protected.POST("/chat/completions", comp.Complete) protected.GET("/tools", comp.ListTools) - // Summarize & Continue - summarize := handlers.NewSummarizeHandler(stores, roleResolver) + // Summarize & Continue (backed by compaction service) + compactionSvc := compaction.NewService(stores, roleResolver) + summarize := handlers.NewSummarizeHandler(compactionSvc) protected.POST("/channels/:id/summarize", summarize.Summarize) // Provider Configs (user-facing — replaces /api-configs) @@ -400,6 +402,7 @@ func main() { admin.PUT("/users/:id/role", adm.UpdateUserRole) admin.PUT("/users/:id/active", adm.ToggleUserActive) admin.POST("/users/:id/reset-password", adm.ResetPassword) + admin.POST("/users/:id/vault/reset", adm.ResetVault) admin.DELETE("/users/:id", adm.DeleteUser) // Global settings diff --git a/server/roles/roles.go b/server/roles/roles.go index 24a4a1b..c885c4d 100644 --- a/server/roles/roles.go +++ b/server/roles/roles.go @@ -279,6 +279,9 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr if err != nil { return nil, providers.ProviderConfig{}, "", fmt.Errorf("decrypt API key: %w", err) } + } else if len(cfg.APIKeyEnc) > 0 { + // No vault — key stored as raw bytes (unencrypted fallback) + apiKey = string(cfg.APIKeyEnc) } // Parse headers diff --git a/src/js/api.js b/src/js/api.js index 37317ca..7021331 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -131,7 +131,15 @@ const API = { this._refreshTimer = setTimeout(async () => { if (!this.refreshToken) return; console.debug('🔄 Proactive token refresh'); - await this.refresh(); + try { + const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true); + this._setAuth(data); + } catch (e) { + // Proactive refresh failed — keep session alive. + // Access token is still valid for ~20% of its lifetime. + // When it naturally expires, 401-retry in _authed() handles cleanup. + console.warn('⚠ Proactive refresh failed, access token still valid'); + } }, ms); }, diff --git a/src/js/app.js b/src/js/app.js index be36f6d..eafbf05 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -62,7 +62,11 @@ async function fetchModels() { App.hiddenModels = new Set( (prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id) ); - } catch (e) { App.hiddenModels = new Set(); } + } catch (e) { + // Keep existing preferences on failure (auth dead, network issue). + // Only init to empty if there's nothing to preserve. + if (!App.hiddenModels) App.hiddenModels = new Set(); + } App.models = (data.models || []).map(m => { const isPreset = !!m.is_preset; diff --git a/src/js/ui-settings.js b/src/js/ui-settings.js index 7e566f6..a6ac421 100644 --- a/src/js/ui-settings.js +++ b/src/js/ui-settings.js @@ -509,7 +509,9 @@ Object.assign(UI, { App.hiddenModels = new Set( (prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id) ); - } catch (e) { App.hiddenModels = new Set(); } + } catch (e) { + if (!App.hiddenModels) App.hiddenModels = new Set(); + } } const data = await API.listEnabledModels();