Changeset 0.15.0 patches (#72)
This commit is contained in:
@@ -161,7 +161,7 @@ jobs:
|
|||||||
PGPORT: ${{ env.POSTGRES_PORT }}
|
PGPORT: ${{ env.POSTGRES_PORT }}
|
||||||
PGUSER: ${{ secrets.POSTGRES_USER }}
|
PGUSER: ${{ secrets.POSTGRES_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
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
|
- name: Drop CI test database
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -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).
|
Depends on: utility model role (v0.10.0).
|
||||||
|
|
||||||
- [ ] Auto-compaction service: background job that calls utility model to summarize
|
- [x] Compaction service: extracts messages, calls utility model to summarize, replaces with summary
|
||||||
- [ ] Channel-scoped: triggers when context exceeds threshold
|
- [x] `Summarize & Continue` button: user-triggered compaction via existing summarize endpoint
|
||||||
- [ ] Summaries stored as system messages
|
- [x] Channel-scoped context threshold triggers (configurable min messages, min chars, activity gap)
|
||||||
- [ ] Configurable: per-channel opt-in/out, summary model override
|
- [x] Summaries stored as system messages with `is_summary = true` metadata
|
||||||
- [ ] Admin controls for resource limits
|
- [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**
|
**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-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**
|
**Knowledge Bases — Future**
|
||||||
- Hybrid search: combine vector similarity with full-text `tsvector`, re-rank
|
- Hybrid search: combine vector similarity with full-text `tsvector`, re-rank
|
||||||
|
|||||||
@@ -43,14 +43,25 @@ func setGlobalSetting(t *testing.T, key string, value interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// touchChannelTime backdates a channel's updated_at.
|
// seedChannelBackdated creates a channel with updated_at set to (now - age).
|
||||||
func touchChannelTime(t *testing.T, channelID string, age time.Duration) {
|
// 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()
|
t.Helper()
|
||||||
target := time.Now().Add(-age)
|
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 {
|
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.
|
// 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
|
// Create a channel with enough messages to qualify
|
||||||
// 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K)
|
// 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K)
|
||||||
channelID, _ := seedChannel(t, userID, "Big Chat", 20, 2000)
|
// Backdated 5min so it passes activity gap (>2min) and recency (<7 days)
|
||||||
|
channelID, _ := seedChannelBackdated(t, userID, "Big Chat", 5*time.Minute, 20, 2000)
|
||||||
// Backdate so it passes the activity gap (>2min old) and recency (<7 days)
|
|
||||||
touchChannelTime(t, channelID, 5*time.Minute)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
candidates := sc.findCandidates(ctx)
|
candidates := sc.findCandidates(ctx)
|
||||||
@@ -122,8 +131,7 @@ func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) {
|
|||||||
userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com")
|
userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com")
|
||||||
|
|
||||||
// Only 4 messages — below candidateMinMessages=10
|
// Only 4 messages — below candidateMinMessages=10
|
||||||
channelID, _ := seedChannel(t, userID, "Small Chat", 4, 2000)
|
channelID, _ := seedChannelBackdated(t, userID, "Small Chat", 5*time.Minute, 4, 2000)
|
||||||
touchChannelTime(t, channelID, 5*time.Minute)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
candidates := sc.findCandidates(ctx)
|
candidates := sc.findCandidates(ctx)
|
||||||
@@ -164,8 +172,7 @@ func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) {
|
|||||||
sc := NewScanner(svc, stores, ScannerConfig{})
|
sc := NewScanner(svc, stores, ScannerConfig{})
|
||||||
|
|
||||||
userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com")
|
userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com")
|
||||||
channelID, _ := seedChannel(t, userID, "Archived Chat", 20, 2000)
|
channelID, _ := seedChannelBackdated(t, userID, "Archived Chat", 5*time.Minute, 20, 2000)
|
||||||
touchChannelTime(t, channelID, 5*time.Minute)
|
|
||||||
|
|
||||||
// Archive the channel
|
// Archive the channel
|
||||||
database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID)
|
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) {
|
func TestEstimateTokens_GuardRailMath(t *testing.T) {
|
||||||
// Simulate a large conversation that exceeds a 32K utility model
|
// Simulate a large conversation that exceeds a 32K utility model
|
||||||
// 100K chars ≈ 25K tokens of conversation + system prompt overhead
|
// 120K chars ≈ 30K tokens of conversation + system prompt overhead
|
||||||
largeContent := make([]byte, 100000)
|
// inputCeiling = 32K * 0.80 = 25.6K → 30K exceeds it
|
||||||
|
largeContent := make([]byte, 120000)
|
||||||
contentTokens := EstimateTokens(string(largeContent))
|
contentTokens := EstimateTokens(string(largeContent))
|
||||||
systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead
|
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)
|
inputCeiling := int(float64(utilityBudget) * 0.80)
|
||||||
|
|
||||||
if totalPrompt <= inputCeiling {
|
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)
|
totalPrompt, inputCeiling)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,13 +237,12 @@ func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel {
|
|||||||
maxAge := time.Now().Add(-candidateMaxAge)
|
maxAge := time.Now().Add(-candidateMaxAge)
|
||||||
|
|
||||||
rows, err := database.DB.QueryContext(ctx, `
|
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,
|
COUNT(m.id) AS msg_count,
|
||||||
COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars
|
COALESCE(SUM(LENGTH(m.content)), 0) AS total_chars
|
||||||
FROM channels c
|
FROM channels c
|
||||||
JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL
|
JOIN messages m ON m.channel_id = c.id AND m.deleted_at IS NULL
|
||||||
WHERE c.deleted_at IS NULL
|
WHERE c.type = 'direct'
|
||||||
AND c.type = 'direct'
|
|
||||||
AND c.is_archived = false
|
AND c.is_archived = false
|
||||||
AND c.updated_at < $1
|
AND c.updated_at < $1
|
||||||
AND c.updated_at > $2
|
AND c.updated_at > $2
|
||||||
|
|||||||
@@ -829,18 +829,6 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
|||||||
return messages, nil
|
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 ─────────────────────
|
// ── Message Persistence ─────────────────────
|
||||||
|
|
||||||
// persistMessage inserts a message into the tree, updates the cursor, and
|
// persistMessage inserts a message into the tree, updates the cursor, and
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
|||||||
// ── Verify channel ownership ──
|
// ── Verify channel ownership ──
|
||||||
var ownerID string
|
var ownerID string
|
||||||
err := database.DB.QueryRow(
|
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)
|
).Scan(&ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/config"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
@@ -248,8 +249,9 @@ func main() {
|
|||||||
protected.POST("/chat/completions", comp.Complete)
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
protected.GET("/tools", comp.ListTools)
|
protected.GET("/tools", comp.ListTools)
|
||||||
|
|
||||||
// Summarize & Continue
|
// Summarize & Continue (backed by compaction service)
|
||||||
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
|
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||||
|
summarize := handlers.NewSummarizeHandler(compactionSvc)
|
||||||
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
||||||
|
|
||||||
// Provider Configs (user-facing — replaces /api-configs)
|
// Provider Configs (user-facing — replaces /api-configs)
|
||||||
@@ -400,6 +402,7 @@ func main() {
|
|||||||
admin.PUT("/users/:id/role", adm.UpdateUserRole)
|
admin.PUT("/users/:id/role", adm.UpdateUserRole)
|
||||||
admin.PUT("/users/:id/active", adm.ToggleUserActive)
|
admin.PUT("/users/:id/active", adm.ToggleUserActive)
|
||||||
admin.POST("/users/:id/reset-password", adm.ResetPassword)
|
admin.POST("/users/:id/reset-password", adm.ResetPassword)
|
||||||
|
admin.POST("/users/:id/vault/reset", adm.ResetVault)
|
||||||
admin.DELETE("/users/:id", adm.DeleteUser)
|
admin.DELETE("/users/:id", adm.DeleteUser)
|
||||||
|
|
||||||
// Global settings
|
// Global settings
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (pr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, providers.ProviderConfig{}, "", fmt.Errorf("decrypt API key: %w", err)
|
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
|
// Parse headers
|
||||||
|
|||||||
@@ -131,7 +131,15 @@ const API = {
|
|||||||
this._refreshTimer = setTimeout(async () => {
|
this._refreshTimer = setTimeout(async () => {
|
||||||
if (!this.refreshToken) return;
|
if (!this.refreshToken) return;
|
||||||
console.debug('🔄 Proactive token refresh');
|
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);
|
}, ms);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ async function fetchModels() {
|
|||||||
App.hiddenModels = new Set(
|
App.hiddenModels = new Set(
|
||||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
(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 => {
|
App.models = (data.models || []).map(m => {
|
||||||
const isPreset = !!m.is_preset;
|
const isPreset = !!m.is_preset;
|
||||||
|
|||||||
@@ -509,7 +509,9 @@ Object.assign(UI, {
|
|||||||
App.hiddenModels = new Set(
|
App.hiddenModels = new Set(
|
||||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
(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();
|
const data = await API.listEnabledModels();
|
||||||
|
|||||||
Reference in New Issue
Block a user