Changeset 0.22.4 (#146)

This commit is contained in:
2026-03-02 22:16:08 +00:00
parent 9940fb5831
commit 3953dcf364
25 changed files with 2254 additions and 145 deletions

View File

@@ -62,6 +62,11 @@ type Config struct {
// WORKSPACE_INDEX_CONCURRENCY: max concurrent indexing goroutines (default 2).
WorkspaceIndexingEnabled bool
WorkspaceIndexConcurrency int
// Provider auto-disable (v0.22.4)
// PROVIDER_AUTO_DISABLE_THRESHOLD: consecutive "down" hourly windows before a
// provider is automatically deactivated. Default 3. Set to 0 to disable.
ProviderAutoDisableThreshold int
}
// Load reads configuration from environment variables.
@@ -98,6 +103,8 @@ func Load() *Config {
WorkspaceIndexingEnabled: getEnvBool("WORKSPACE_INDEXING_ENABLED", true),
WorkspaceIndexConcurrency: getEnvInt("WORKSPACE_INDEX_CONCURRENCY", 2),
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
}
}

View File

@@ -0,0 +1,33 @@
-- v0.22.4: Rate limit tracking, tool health, project files.
-- ── Rate limit column on provider_health ────
ALTER TABLE provider_health ADD COLUMN IF NOT EXISTS rate_limit_count INT NOT NULL DEFAULT 0;
-- ── Tool Health ─────────────────────────────
-- Lightweight health tracking for built-in tools (web_search, url_fetch, etc.).
-- One row per tool per hourly window, similar to provider_health.
CREATE TABLE IF NOT EXISTS tool_health (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tool_name TEXT NOT NULL, -- web_search, url_fetch, etc.
window_start TIMESTAMPTZ NOT NULL, -- hourly bucket floor
request_count INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
total_latency_ms BIGINT NOT NULL DEFAULT 0,
max_latency_ms INT NOT NULL DEFAULT 0,
last_error TEXT,
last_error_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tool_name, window_start)
);
CREATE INDEX IF NOT EXISTS idx_tool_health_window
ON tool_health (tool_name, window_start DESC);
-- ── Project Files ───────────────────────────
-- Extends attachments to support project-scoped uploads.
-- When project_id is set, the file belongs to the project (not a message).
ALTER TABLE attachments ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_attachments_project
ON attachments (project_id) WHERE project_id IS NOT NULL;

View File

@@ -0,0 +1,22 @@
-- v0.22.4: Rate limit tracking, tool health, project files (SQLite).
-- Rate limit column (SQLite requires full rebuild or default — use default).
ALTER TABLE provider_health ADD COLUMN rate_limit_count INTEGER NOT NULL DEFAULT 0;
-- Tool Health
CREATE TABLE IF NOT EXISTS tool_health (
id TEXT PRIMARY KEY,
tool_name TEXT NOT NULL,
window_start TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
total_latency_ms INTEGER NOT NULL DEFAULT 0,
max_latency_ms INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_error_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tool_name, window_start)
);
-- Project Files
ALTER TABLE attachments ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE CASCADE;

View File

@@ -457,6 +457,121 @@ func sanitizeFilename(name string) string {
return name
}
// ── Project Files (v0.22.4) ────────────────
// UploadToProject handles project-scoped file uploads.
// POST /api/v1/projects/:id/files
func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
// Verify project access (user owns or is team member)
var exists bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(
SELECT 1 FROM projects
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
))
)
`), projectID, userID).Scan(&exists)
if err != nil || !exists {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
if header.Size > int64(defaultMaxFileSize) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds size limit"})
return
}
// Detect content type
buf := make([]byte, 512)
n, _ := file.Read(buf)
contentType := http.DetectContentType(buf[:n])
file.Seek(0, io.SeekStart)
ext := strings.ToLower(filepath.Ext(header.Filename))
if better, ok := extToMIME[ext]; ok && contentType == "application/octet-stream" {
contentType = better
}
// Store file
storageKey := fmt.Sprintf("projects/%s/%s/%s", projectID, store.NewID()[:8], sanitizeFilename(header.Filename))
if err := h.objStore.Put(c.Request.Context(), storageKey, file, header.Size, contentType); err != nil {
log.Printf("error: project file upload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
att := models.Attachment{
ChannelID: "", // no channel association
UserID: userID,
ProjectID: &projectID,
Filename: header.Filename,
ContentType: contentType,
SizeBytes: header.Size,
StorageKey: storageKey,
}
if err := h.stores.Attachments.Create(c.Request.Context(), &att); err != nil {
log.Printf("error: project file create: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"})
return
}
if h.extQueue != nil {
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
log.Printf("warning: project file extraction enqueue failed: %v", err)
}
}
c.JSON(http.StatusCreated, att)
}
// ListByProject returns all files uploaded to a project.
// GET /api/v1/projects/:id/files
func (h *AttachmentHandler) ListByProject(c *gin.Context) {
userID := getUserID(c)
projectID := c.Param("id")
// Verify project access
var exists bool
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(
SELECT 1 FROM projects
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
))
)
`), projectID, userID).Scan(&exists)
if err != nil || !exists {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return
}
files, err := h.stores.Attachments.GetByProject(c.Request.Context(), projectID)
if err != nil {
log.Printf("error: list project files: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
}
// extToMIME maps file extensions to MIME types for cases where
// http.DetectContentType returns application/octet-stream.
var extToMIME = map[string]string{

View File

@@ -64,6 +64,9 @@ type HealthRecorder interface {
RecordSuccess(providerConfigID string, latencyMs int)
RecordError(providerConfigID string, latencyMs int, errMsg string)
RecordTimeout(providerConfigID string, latencyMs int, errMsg string)
RecordRateLimit(providerConfigID string, latencyMs int, errMsg string) // v0.22.4
RecordToolSuccess(toolName string, latencyMs int) // v0.22.4
RecordToolError(toolName string, latencyMs int, errMsg string) // v0.22.4
}
// HealthStatusQuerier retrieves current provider health for routing decisions.
@@ -1365,6 +1368,8 @@ func (h *CompletionHandler) recordHealth(configID string, start time.Time, err e
errMsg := err.Error()
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
h.health.RecordTimeout(configID, latencyMs, errMsg)
} else if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
h.health.RecordRateLimit(configID, latencyMs, errMsg)
} else {
h.health.RecordError(configID, latencyMs, errMsg)
}

110
server/handlers/export.go Normal file
View File

@@ -0,0 +1,110 @@
package handlers
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// ExportHandler converts markdown content to PDF or DOCX via pandoc.
type ExportHandler struct{}
func NewExportHandler() *ExportHandler { return &ExportHandler{} }
type exportRequest struct {
Content string `json:"content" binding:"required"`
Format string `json:"format" binding:"required"` // "pdf" or "docx"
Filename string `json:"filename"` // optional base name
}
// Convert handles POST /api/v1/export.
// Converts markdown content to the requested format and returns the file.
func (h *ExportHandler) Convert(c *gin.Context) {
var req exportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate format
switch req.Format {
case "pdf", "docx":
// ok
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be pdf or docx"})
return
}
// Check pandoc availability
if _, err := exec.LookPath("pandoc"); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "pandoc is not installed on the server"})
return
}
// Create temp dir for conversion
tmpDir, err := os.MkdirTemp("", "export-*")
if err != nil {
log.Printf("export: failed to create temp dir: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
return
}
defer os.RemoveAll(tmpDir)
// Write markdown to temp file
inputPath := filepath.Join(tmpDir, "input.md")
if err := os.WriteFile(inputPath, []byte(req.Content), 0644); err != nil {
log.Printf("export: failed to write input: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
return
}
// Determine output filename
baseName := "document"
if req.Filename != "" {
baseName = strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
}
outputFile := baseName + "." + req.Format
outputPath := filepath.Join(tmpDir, outputFile)
// Build pandoc command
args := []string{inputPath, "-o", outputPath, "--standalone"}
if req.Format == "pdf" {
// Try to use a lightweight PDF engine
for _, engine := range []string{"weasyprint", "wkhtmltopdf", "pdflatex"} {
if _, err := exec.LookPath(engine); err == nil {
args = append(args, "--pdf-engine="+engine)
break
}
}
}
cmd := exec.CommandContext(c.Request.Context(), "pandoc", args...)
cmd.Dir = tmpDir
if output, err := cmd.CombinedOutput(); err != nil {
log.Printf("export: pandoc failed: %v\n%s", err, string(output))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pandoc conversion failed",
"details": string(output),
})
return
}
// Serve the file
contentType := "application/octet-stream"
switch req.Format {
case "pdf":
contentType = "application/pdf"
case "docx":
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", outputFile))
c.File(outputPath)
c.Header("Content-Type", contentType)
}

View File

@@ -79,6 +79,9 @@ func (h *HealthAdminHandler) GetAllProviderHealth(c *gin.Context) {
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
RequestCount: w.RequestCount,
ErrorRate: w.ErrorRate(),
ErrorCount: w.ErrorCount,
RateLimitCount: w.RateLimitCount,
TimeoutCount: w.TimeoutCount,
AvgLatencyMs: w.AvgLatencyMs(),
MaxLatencyMs: w.MaxLatencyMs,
LastError: w.LastError,

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -35,7 +36,11 @@ func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err err
latencyMs := int(time.Since(start).Milliseconds())
if err != nil {
errMsg := err.Error()
hr.RecordError(configID, latencyMs, errMsg)
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
hr.RecordRateLimit(configID, latencyMs, errMsg)
} else {
hr.RecordError(configID, latencyMs, errMsg)
}
} else {
hr.RecordSuccess(configID, latencyMs)
}
@@ -205,9 +210,19 @@ func streamWithToolLoop(
var toolResult tools.ToolResult
// Check if tool is registered locally (server-side)
toolStart := time.Now()
if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
// Record tool health (v0.22.4)
if health != nil {
toolLatency := int(time.Since(toolStart).Milliseconds())
if toolResult.IsError {
health.RecordToolError(call.Name, toolLatency, toolResult.Content)
} else {
health.RecordToolSuccess(call.Name, toolLatency)
}
}
} else if hub != nil && hub.IsConnected(userID) {
// Route to browser via WebSocket bridge
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
@@ -418,8 +433,18 @@ func streamModelResponse(
}
var toolResult tools.ToolResult
toolStart := time.Now()
if tools.Get(call.Name) != nil {
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
// Record tool health (v0.22.4)
if health != nil {
toolLatency := int(time.Since(toolStart).Milliseconds())
if toolResult.IsError {
health.RecordToolError(call.Name, toolLatency, toolResult.Content)
} else {
health.RecordToolSuccess(call.Name, toolLatency)
}
}
} else if hub != nil && hub.IsConnected(userID) {
toolResult = executeBrowserTool(hub, userID, call)
} else {

View File

@@ -44,6 +44,13 @@ type Store interface {
// Prune deletes rows older than the given time.
Prune(ctx context.Context, before time.Time) (int64, error)
// ── Tool Health (v0.22.4) ──────────────
// UpsertToolWindow merges tool health counters into the hourly bucket.
UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error
// ListAllToolCurrent returns the current-hour bucket for every tool.
ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error)
}
// ── In-Memory Bucket ────────────────────────
@@ -54,6 +61,7 @@ type bucket struct {
requestCount int
errorCount int
timeoutCount int
rateLimitCount int
totalLatencyMs int64
maxLatencyMs int
lastError string
@@ -67,23 +75,86 @@ type bucket struct {
type Accumulator struct {
mu sync.Mutex
buckets map[string]*bucket // keyed by provider_config_id
store Store
stopCh chan struct{}
wg sync.WaitGroup
// Tool health (v0.22.4)
toolMu sync.Mutex
toolBuckets map[string]*toolBucket // keyed by tool_name
store Store
stopCh chan struct{}
wg sync.WaitGroup
// Auto-disable (v0.22.4): if set, providers that are "down" for
// N consecutive windows get auto-deactivated.
autoDisabler AutoDisabler
autoDisableThreshold int // consecutive down windows to trigger (0 = disabled)
}
// AutoDisabler marks a provider config as inactive. Implemented by the
// provider store so the accumulator doesn't need to import the store package.
type AutoDisabler interface {
DeactivateProvider(ctx context.Context, configID string) error
}
// toolBucket holds counters for one tool within one flush interval.
type toolBucket struct {
toolName string
requestCount int
errorCount int
totalLatencyMs int64
maxLatencyMs int
lastError string
lastErrorAt time.Time
}
// NewAccumulator creates and starts the background flush loop.
func NewAccumulator(store Store) *Accumulator {
a := &Accumulator{
buckets: make(map[string]*bucket),
store: store,
stopCh: make(chan struct{}),
buckets: make(map[string]*bucket),
toolBuckets: make(map[string]*toolBucket),
store: store,
stopCh: make(chan struct{}),
}
a.wg.Add(1)
go a.flushLoop()
return a
}
// SetAutoDisable configures the auto-disable policy. When a provider
// has been "down" for N consecutive hourly windows, it is deactivated.
// N=0 disables the feature.
func (a *Accumulator) SetAutoDisable(disabler AutoDisabler, threshold int) {
a.autoDisabler = disabler
a.autoDisableThreshold = threshold
}
// RecordToolSuccess records a successful tool execution.
func (a *Accumulator) RecordToolSuccess(toolName string, latencyMs int) {
a.toolMu.Lock()
defer a.toolMu.Unlock()
b := a.getToolBucket(toolName)
b.requestCount++
b.totalLatencyMs += int64(latencyMs)
if latencyMs > b.maxLatencyMs {
b.maxLatencyMs = latencyMs
}
}
// RecordToolError records a failed tool execution.
func (a *Accumulator) RecordToolError(toolName string, latencyMs int, errMsg string) {
a.toolMu.Lock()
defer a.toolMu.Unlock()
b := a.getToolBucket(toolName)
b.requestCount++
b.errorCount++
b.totalLatencyMs += int64(latencyMs)
if latencyMs > b.maxLatencyMs {
b.maxLatencyMs = latencyMs
}
b.lastError = errMsg
b.lastErrorAt = time.Now().UTC()
}
// RecordSuccess records a successful provider call.
func (a *Accumulator) RecordSuccess(providerConfigID string, latencyMs int) {
a.mu.Lock()
@@ -127,6 +198,47 @@ func (a *Accumulator) RecordTimeout(providerConfigID string, latencyMs int, errM
b.lastErrorAt = time.Now().UTC()
}
// RecordRateLimit records a rate-limited provider call (HTTP 429).
// Counted as both an error and a rate limit event.
func (a *Accumulator) RecordRateLimit(providerConfigID string, latencyMs int, errMsg string) {
a.mu.Lock()
defer a.mu.Unlock()
b := a.getBucket(providerConfigID)
b.requestCount++
b.errorCount++
b.rateLimitCount++
b.totalLatencyMs += int64(latencyMs)
if latencyMs > b.maxLatencyMs {
b.maxLatencyMs = latencyMs
}
b.lastError = errMsg
b.lastErrorAt = time.Now().UTC()
}
// IsRateLimitError returns true if the error message indicates an HTTP 429.
func IsRateLimitError(errMsg string) bool {
return len(errMsg) > 0 && (contains(errMsg, "HTTP 429") || contains(errMsg, "rate limit") || contains(errMsg, "too many requests"))
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsLower(s, sub))
}
func containsLower(s, sub string) bool {
// Simple case-insensitive contains for short substrings.
for i := 0; i <= len(s)-len(sub); i++ {
match := true
for j := 0; j < len(sub); j++ {
sc, tc := s[i+j], sub[j]
if sc >= 'A' && sc <= 'Z' { sc += 32 }
if tc >= 'A' && tc <= 'Z' { tc += 32 }
if sc != tc { match = false; break }
}
if match { return true }
}
return false
}
// Stop halts the flush loop and performs a final flush.
func (a *Accumulator) Stop() {
close(a.stopCh)
@@ -158,6 +270,15 @@ func (a *Accumulator) getBucket(providerConfigID string) *bucket {
return b
}
func (a *Accumulator) getToolBucket(toolName string) *toolBucket {
b, ok := a.toolBuckets[toolName]
if !ok {
b = &toolBucket{toolName: toolName}
a.toolBuckets[toolName] = b
}
return b
}
func (a *Accumulator) flushLoop() {
defer a.wg.Done()
ticker := time.NewTicker(FlushInterval)
@@ -167,8 +288,11 @@ func (a *Accumulator) flushLoop() {
select {
case <-ticker.C:
a.flush()
a.flushTools()
a.checkAutoDisable()
case <-a.stopCh:
a.flush() // final flush
a.flushTools()
return
}
}
@@ -199,6 +323,7 @@ func (a *Accumulator) flush() {
RequestCount: b.requestCount,
ErrorCount: b.errorCount,
TimeoutCount: b.timeoutCount,
RateLimitCount: b.rateLimitCount,
TotalLatencyMs: b.totalLatencyMs,
MaxLatencyMs: b.maxLatencyMs,
}
@@ -213,6 +338,89 @@ func (a *Accumulator) flush() {
}
}
// flushTools writes tool health buckets to the database.
func (a *Accumulator) flushTools() {
a.toolMu.Lock()
if len(a.toolBuckets) == 0 {
a.toolMu.Unlock()
return
}
snap := a.toolBuckets
a.toolBuckets = make(map[string]*toolBucket, len(snap))
a.toolMu.Unlock()
windowStart := hourFloor(time.Now().UTC())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, b := range snap {
if b.requestCount == 0 {
continue
}
w := &models.ToolHealthWindow{
ToolName: b.toolName,
WindowStart: windowStart,
RequestCount: b.requestCount,
ErrorCount: b.errorCount,
TotalLatencyMs: b.totalLatencyMs,
MaxLatencyMs: b.maxLatencyMs,
}
if b.lastError != "" {
w.LastError = &b.lastError
ts := b.lastErrorAt.Format(time.RFC3339)
w.LastErrorAt = &ts
}
if err := a.store.UpsertToolWindow(ctx, w); err != nil {
log.Printf("⚠ health: tool flush failed for %s: %v", b.toolName, err)
}
}
}
// checkAutoDisable inspects recent health windows and deactivates providers
// that have been "down" for too many consecutive hours.
func (a *Accumulator) checkAutoDisable() {
if a.autoDisabler == nil || a.autoDisableThreshold <= 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Get current windows for all providers
windows, err := a.store.ListAllCurrent(ctx)
if err != nil {
return
}
for _, w := range windows {
status := DeriveStatus(w.ErrorRate(), w.RequestCount)
if status != models.StatusDown {
continue
}
// Check last N windows — are they all down?
recent, err := a.store.ListWindows(ctx, w.ProviderConfigID, a.autoDisableThreshold)
if err != nil || len(recent) < a.autoDisableThreshold {
continue // not enough data
}
allDown := true
for _, rw := range recent {
if DeriveStatus(rw.ErrorRate(), rw.RequestCount) != models.StatusDown {
allDown = false
break
}
}
if allDown {
log.Printf("⚠ health: auto-disabling provider %s — down for %d consecutive windows", w.ProviderConfigID, a.autoDisableThreshold)
if err := a.autoDisabler.DeactivateProvider(ctx, w.ProviderConfigID); err != nil {
log.Printf("⚠ health: auto-disable failed for %s: %v", w.ProviderConfigID, err)
}
}
}
}
// hourFloor truncates a time to the start of its hour.
func hourFloor(t time.Time) time.Time {
return t.Truncate(time.Hour)

View File

@@ -70,6 +70,14 @@ func (m *mockStore) ListAllCurrent(_ context.Context) ([]models.ProviderHealthWi
func (m *mockStore) Prune(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
func (m *mockStore) UpsertToolWindow(_ context.Context, _ *models.ToolHealthWindow) error {
return nil
}
func (m *mockStore) ListAllToolCurrent(_ context.Context) ([]models.ToolHealthWindow, error) {
return nil, nil
}
// ── Tests ───────────────────────────────────
func TestRecordSuccess(t *testing.T) {

View File

@@ -29,6 +29,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqliteStore "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
"git.gobha.me/xcaliber/chat-switchboard/workspace"
@@ -57,7 +58,7 @@ func main() {
var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore
var healthAccum *health.Accumulator
var healthStore *postgres.HealthStore
var healthStore health.Store
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
@@ -94,10 +95,28 @@ func main() {
stores = postgres.NewStores(database.DB)
// Provider health accumulator (v0.22.0)
healthStore = postgres.NewHealthStore(database.DB)
if database.IsSQLite() {
healthStore = sqliteStore.NewHealthStore()
} else {
healthStore = postgres.NewHealthStore(database.DB)
}
healthAccum = health.NewAccumulator(healthStore)
defer healthAccum.Stop()
// Auto-disable: deactivate providers that are "down" for N consecutive
// hourly windows. Configured via PROVIDER_AUTO_DISABLE_THRESHOLD env var.
// Default: 3 (3 consecutive "down" hours triggers deactivation). Set to 0 to disable.
autoDisableThreshold := 3
if v := cfg.ProviderAutoDisableThreshold; v >= 0 {
autoDisableThreshold = v
}
if autoDisableThreshold > 0 {
if ad, ok := healthStore.(health.AutoDisabler); ok {
healthAccum.SetAutoDisable(ad, autoDisableThreshold)
log.Printf(" 🛡️ Provider auto-disable: %d consecutive down windows", autoDisableThreshold)
}
}
// Background health cleanup: prune windows older than 7 days
go func() {
ticker := time.NewTicker(6 * time.Hour)
@@ -472,6 +491,15 @@ func main() {
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
protected.GET("/projects/:id/notes", projectH.ListNotes)
// Project files (v0.22.4) — wired via attachH which is declared later,
// so we use a closure that captures the variable.
protected.POST("/projects/:id/files", func(c *gin.Context) {
handlers.NewAttachmentHandler(stores, objStore, extQueue).UploadToProject(c)
})
protected.GET("/projects/:id/files", func(c *gin.Context) {
handlers.NewAttachmentHandler(stores, objStore, extQueue).ListByProject(c)
})
// Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub)
protected.GET("/notifications", notifH.List)
@@ -533,6 +561,10 @@ func main() {
protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
exportH := handlers.NewExportHandler()
protected.POST("/export", exportH.Convert)
// Hook: clean up storage files when channels are deleted
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)

View File

@@ -506,6 +506,7 @@ type Attachment struct {
ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"`
MessageID *string `json:"message_id,omitempty" db:"message_id"`
ProjectID *string `json:"project_id,omitempty" db:"project_id"` // v0.22.4: project-scoped uploads
Filename string `json:"filename" db:"filename"`
ContentType string `json:"content_type" db:"content_type"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
@@ -890,6 +891,7 @@ type ProviderHealthWindow struct {
RequestCount int `json:"request_count" db:"request_count"`
ErrorCount int `json:"error_count" db:"error_count"`
TimeoutCount int `json:"timeout_count" db:"timeout_count"`
RateLimitCount int `json:"rate_limit_count" db:"rate_limit_count"` // v0.22.4
TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"`
MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"`
LastError *string `json:"last_error,omitempty" db:"last_error"`
@@ -919,12 +921,38 @@ type ProviderHealthSummary struct {
Status ProviderStatus `json:"status"`
RequestCount int `json:"request_count"` // last hour
ErrorRate float64 `json:"error_rate"` // last hour
ErrorCount int `json:"error_count"` // last hour
RateLimitCount int `json:"rate_limit_count"` // last hour (v0.22.4)
TimeoutCount int `json:"timeout_count"` // last hour
AvgLatencyMs int `json:"avg_latency_ms"` // last hour
MaxLatencyMs int `json:"max_latency_ms"` // last hour
LastError *string `json:"last_error,omitempty"`
LastErrorAt *string `json:"last_error_at,omitempty"`
}
// ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.)
// in hourly buckets, analogous to ProviderHealthWindow.
type ToolHealthWindow struct {
ID string `json:"id" db:"id"`
ToolName string `json:"tool_name" db:"tool_name"`
WindowStart time.Time `json:"window_start" db:"window_start"`
RequestCount int `json:"request_count" db:"request_count"`
ErrorCount int `json:"error_count" db:"error_count"`
TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"`
MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"`
LastError *string `json:"last_error,omitempty" db:"last_error"`
LastErrorAt *string `json:"last_error_at,omitempty" db:"last_error_at"`
}
// ToolHealthSummary is the API response for a tool's health.
type ToolHealthSummary struct {
ToolName string `json:"tool_name"`
Status string `json:"status"` // healthy, degraded, down, unknown
RequestCount int `json:"request_count"`
ErrorRate float64 `json:"error_rate"`
AvgLatencyMs int `json:"avg_latency_ms"`
}
// =========================================
// CAPABILITY OVERRIDES (v0.22.0)
// =========================================

View File

@@ -393,6 +393,7 @@ type AttachmentStore interface {
GetByID(ctx context.Context, id string) (*models.Attachment, error)
GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error)
GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) // v0.22.4
SetMessageID(ctx context.Context, attachmentID, messageID string) error
UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error
SetExtractedText(ctx context.Context, id string, text string) error

View File

@@ -14,18 +14,19 @@ type AttachmentStore struct{}
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
// ── columns shared across queries ──────────
const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type,
const attachmentCols = `id, channel_id, user_id, message_id, project_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata, created_at`
// scanAttachment scans a row into an Attachment struct.
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
var a models.Attachment
var messageID sql.NullString
var projectID sql.NullString
var extractedText sql.NullString
var metadataJSON []byte
err := row.Scan(
&a.ID, &a.ChannelID, &a.UserID, &messageID,
&a.ID, &a.ChannelID, &a.UserID, &messageID, &projectID,
&a.Filename, &a.ContentType, &a.SizeBytes,
&a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt,
)
@@ -34,6 +35,7 @@ func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.A
}
a.MessageID = NullableStringPtr(messageID)
a.ProjectID = NullableStringPtr(projectID)
if extractedText.Valid {
a.ExtractedText = &extractedText.String
}
@@ -45,11 +47,12 @@ func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.A
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
return DB.QueryRowContext(ctx, `
INSERT INTO attachments (channel_id, user_id, message_id, filename, content_type,
INSERT INTO attachments (channel_id, user_id, message_id, project_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING id, created_at`,
a.ChannelID, a.UserID, models.NullString(a.MessageID),
models.NullString(a.ProjectID),
a.Filename, a.ContentType, a.SizeBytes,
a.StorageKey, models.NullString(a.ExtractedText),
ToJSON(a.Metadata),
@@ -99,6 +102,26 @@ func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([
return out, rows.Err()
}
// GetByProject returns all attachments for a project (v0.22.4).
func (s *AttachmentStore) GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE project_id = $1 ORDER BY created_at`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE attachments SET message_id = $1 WHERE id = $2`,

View File

@@ -20,20 +20,21 @@ func NewHealthStore(db *sql.DB) *HealthStore {
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO provider_health (provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
request_count = provider_health.request_count + EXCLUDED.request_count,
error_count = provider_health.error_count + EXCLUDED.error_count,
timeout_count = provider_health.timeout_count + EXCLUDED.timeout_count,
request_count = provider_health.request_count + EXCLUDED.request_count,
error_count = provider_health.error_count + EXCLUDED.error_count,
timeout_count = provider_health.timeout_count + EXCLUDED.timeout_count,
rate_limit_count = provider_health.rate_limit_count + EXCLUDED.rate_limit_count,
total_latency_ms = provider_health.total_latency_ms + EXCLUDED.total_latency_ms,
max_latency_ms = GREATEST(provider_health.max_latency_ms, EXCLUDED.max_latency_ms),
last_error = COALESCE(EXCLUDED.last_error, provider_health.last_error),
last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at),
updated_at = now()
max_latency_ms = GREATEST(provider_health.max_latency_ms, EXCLUDED.max_latency_ms),
last_error = COALESCE(EXCLUDED.last_error, provider_health.last_error),
last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at),
updated_at = now()
`, w.ProviderConfigID, w.WindowStart,
w.RequestCount, w.ErrorCount, w.TimeoutCount,
w.RequestCount, w.ErrorCount, w.TimeoutCount, w.RateLimitCount,
w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
)
return err
@@ -44,13 +45,13 @@ func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID str
var w models.ProviderHealthWindow
err := s.db.QueryRowContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = $1 AND window_start = $2
`, providerConfigID, windowStart).Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
)
if err == sql.ErrNoRows {
@@ -62,7 +63,7 @@ func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID str
func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = $1
@@ -79,7 +80,7 @@ func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string,
var w models.ProviderHealthWindow
if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
@@ -93,7 +94,7 @@ func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHeal
windowStart := time.Now().UTC().Truncate(time.Hour)
rows, err := s.db.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE window_start = $1
@@ -109,7 +110,7 @@ func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHeal
var w models.ProviderHealthWindow
if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
@@ -126,5 +127,66 @@ func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error
if err != nil {
return 0, err
}
// Also prune tool health
s.db.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < $1`, before)
return result.RowsAffected()
}
// ── Tool Health (v0.22.4) ────────────────────
func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO tool_health (tool_name, window_start,
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (tool_name, window_start) DO UPDATE SET
request_count = tool_health.request_count + EXCLUDED.request_count,
error_count = tool_health.error_count + EXCLUDED.error_count,
total_latency_ms = tool_health.total_latency_ms + EXCLUDED.total_latency_ms,
max_latency_ms = GREATEST(tool_health.max_latency_ms, EXCLUDED.max_latency_ms),
last_error = COALESCE(EXCLUDED.last_error, tool_health.last_error),
last_error_at = COALESCE(EXCLUDED.last_error_at, tool_health.last_error_at),
updated_at = now()
`, w.ToolName, w.WindowStart,
w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs,
w.LastError, w.LastErrorAt,
)
return err
}
func (s *HealthStore) ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) {
windowStart := time.Now().UTC().Truncate(time.Hour)
rows, err := s.db.QueryContext(ctx, `
SELECT id, tool_name, window_start,
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at
FROM tool_health
WHERE window_start = $1
ORDER BY tool_name
`, windowStart)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ToolHealthWindow
for rows.Next() {
var w models.ToolHealthWindow
if err := rows.Scan(
&w.ID, &w.ToolName, &w.WindowStart,
&w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs,
&w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
}
result = append(result, w)
}
return result, rows.Err()
}
// DeactivateProvider marks a provider config as inactive (for auto-disable).
func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error {
_, err := s.db.ExecContext(ctx, `UPDATE provider_configs SET is_active = false WHERE id = $1`, configID)
return err
}

View File

@@ -15,18 +15,19 @@ type AttachmentStore struct{}
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
// ── columns shared across queries ──────────
const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type,
const attachmentCols = `id, channel_id, user_id, message_id, project_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata, created_at`
// scanAttachment scans a row into an Attachment struct.
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
var a models.Attachment
var messageID sql.NullString
var projectID sql.NullString
var extractedText sql.NullString
var metadataJSON []byte
err := row.Scan(
&a.ID, &a.ChannelID, &a.UserID, &messageID,
&a.ID, &a.ChannelID, &a.UserID, &messageID, &projectID,
&a.Filename, &a.ContentType, &a.SizeBytes,
&a.StorageKey, &extractedText, &metadataJSON, st(&a.CreatedAt),
)
@@ -35,6 +36,7 @@ func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.A
}
a.MessageID = NullableStringPtr(messageID)
a.ProjectID = NullableStringPtr(projectID)
if extractedText.Valid {
a.ExtractedText = &extractedText.String
}
@@ -48,10 +50,11 @@ func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) erro
a.ID = store.NewID()
a.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO attachments (id, channel_id, user_id, message_id, filename, content_type,
INSERT INTO attachments (id, channel_id, user_id, message_id, project_id, filename, content_type,
size_bytes, storage_key, extracted_text, metadata, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
a.ID, a.ChannelID, a.UserID, models.NullString(a.MessageID),
models.NullString(a.ProjectID),
a.Filename, a.ContentType, a.SizeBytes,
a.StorageKey, models.NullString(a.ExtractedText),
ToJSON(a.Metadata), a.CreatedAt.Format(timeFmt),
@@ -102,6 +105,26 @@ func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([
return out, rows.Err()
}
// GetByProject returns all attachments for a project (v0.22.4).
func (s *AttachmentStore) GetByProject(ctx context.Context, projectID string) ([]models.Attachment, error) {
rows, err := DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE project_id = ? ORDER BY created_at`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
_, err := DB.ExecContext(ctx,
`UPDATE attachments SET message_id = ? WHERE id = ?`,

View File

@@ -18,20 +18,21 @@ func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealth
windowStr := w.WindowStart.Format(timeFmt)
_, err := DB.ExecContext(ctx, `
INSERT INTO provider_health (id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
request_count = provider_health.request_count + excluded.request_count,
error_count = provider_health.error_count + excluded.error_count,
timeout_count = provider_health.timeout_count + excluded.timeout_count,
request_count = provider_health.request_count + excluded.request_count,
error_count = provider_health.error_count + excluded.error_count,
timeout_count = provider_health.timeout_count + excluded.timeout_count,
rate_limit_count = provider_health.rate_limit_count + excluded.rate_limit_count,
total_latency_ms = provider_health.total_latency_ms + excluded.total_latency_ms,
max_latency_ms = MAX(provider_health.max_latency_ms, excluded.max_latency_ms),
last_error = COALESCE(excluded.last_error, provider_health.last_error),
last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at),
updated_at = datetime('now')
max_latency_ms = MAX(provider_health.max_latency_ms, excluded.max_latency_ms),
last_error = COALESCE(excluded.last_error, provider_health.last_error),
last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at),
updated_at = datetime('now')
`, id, w.ProviderConfigID, windowStr,
w.RequestCount, w.ErrorCount, w.TimeoutCount,
w.RequestCount, w.ErrorCount, w.TimeoutCount, w.RateLimitCount,
w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
)
return err
@@ -43,13 +44,13 @@ func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID str
var windowStartStr string
err := DB.QueryRowContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = ? AND window_start = ?
`, providerConfigID, windowStr).Scan(
&w.ID, &w.ProviderConfigID, &windowStartStr,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
)
if err == sql.ErrNoRows {
@@ -65,7 +66,7 @@ func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID str
func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE provider_config_id = ?
@@ -83,7 +84,7 @@ func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHeal
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
rows, err := DB.QueryContext(ctx, `
SELECT id, provider_config_id, window_start,
request_count, error_count, timeout_count,
request_count, error_count, timeout_count, rate_limit_count,
total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health
WHERE window_start = ?
@@ -103,9 +104,72 @@ func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error
if err != nil {
return 0, err
}
DB.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < ?`, before.Format(timeFmt))
return result.RowsAffected()
}
// ── Tool Health (v0.22.4) ────────────────────
func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error {
id := store.NewID()
windowStr := w.WindowStart.Format(timeFmt)
_, err := DB.ExecContext(ctx, `
INSERT INTO tool_health (id, tool_name, window_start,
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (tool_name, window_start) DO UPDATE SET
request_count = tool_health.request_count + excluded.request_count,
error_count = tool_health.error_count + excluded.error_count,
total_latency_ms = tool_health.total_latency_ms + excluded.total_latency_ms,
max_latency_ms = MAX(tool_health.max_latency_ms, excluded.max_latency_ms),
last_error = COALESCE(excluded.last_error, tool_health.last_error),
last_error_at = COALESCE(excluded.last_error_at, tool_health.last_error_at),
updated_at = datetime('now')
`, id, w.ToolName, windowStr,
w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs,
w.LastError, w.LastErrorAt,
)
return err
}
func (s *HealthStore) ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) {
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
rows, err := DB.QueryContext(ctx, `
SELECT id, tool_name, window_start,
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at
FROM tool_health
WHERE window_start = ?
ORDER BY tool_name
`, windowStr)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ToolHealthWindow
for rows.Next() {
var w models.ToolHealthWindow
var windowStartStr string
if err := rows.Scan(
&w.ID, &w.ToolName, &windowStartStr,
&w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs,
&w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
}
w.WindowStart, _ = time.Parse(timeFmt, windowStartStr)
result = append(result, w)
}
return result, rows.Err()
}
func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error {
_, err := DB.ExecContext(ctx, `UPDATE provider_configs SET is_active = 0 WHERE id = ?`, configID)
return err
}
func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
var result []models.ProviderHealthWindow
for rows.Next() {
@@ -113,7 +177,7 @@ func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
var windowStartStr string
if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &windowStartStr,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount,
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err