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

@@ -2,6 +2,40 @@
All notable changes to Chat Switchboard. All notable changes to Chat Switchboard.
## [0.22.4] — 2026-03-02
### Added
- **Rate limit tracking.** Provider health windows now track HTTP 429 / rate limit events separately from general errors. New `rate_limit_count` column on `provider_health` table. `RecordRateLimit()` method on health accumulator. Completion handler and stream loop classify `HTTP 429` / `rate limit` / `Too Many Requests` errors automatically.
- **Auto-disable policy.** Providers that report "down" status for N consecutive hourly windows are automatically deactivated. Configurable via `PROVIDER_AUTO_DISABLE_THRESHOLD` env var (default: 3, set to 0 to disable). `AutoDisabler` interface on health store, `checkAutoDisable()` runs on each flush cycle.
- **Tool health tracking.** Built-in tools (web_search, url_fetch, etc.) now record success/error/latency to `tool_health` table via the health accumulator. `RecordToolSuccess()` / `RecordToolError()` methods. Both tool execution sites in stream loop instrumented. `ToolHealthWindow` and `ToolHealthSummary` models.
- **Search provider health tracking.** web_search and url_fetch tool calls are now included in the tool health pipeline with per-invocation latency and error recording.
- **Project-specific file uploads.** `POST /api/v1/projects/:id/files` (multipart upload) and `GET /api/v1/projects/:id/files` (list). `project_id` column on `attachments` table. Files are stored under `projects/{id}/` prefix. Project access verified via ownership or team membership. Files queued for text extraction if enabled.
- **Workspace settings UI.** Git configuration section in project settings panel: remote URL, branch, credential selector. Appears when a workspace is bound. Saves via `PATCH /api/v1/workspaces/:id`. `updateWorkspace()` and `listGitCredentials()` API client methods.
- **PDF/DOCX export via pandoc.** New `POST /api/v1/export` endpoint converts markdown content to PDF or DOCX using pandoc. Editor export dropdown now includes "Export as PDF" and "Export as DOCX" options. Graceful error handling when pandoc is unavailable.
- **Project files UI.** File list and upload button in project settings panel. Shows file names and sizes. Multi-file upload support.
- **API client methods.** `projectUploadFile`, `projectListFiles`, `exportDocument`, `updateWorkspace`, `listGitCredentials`.
### Changed
- `health/accumulator.go`: Added `rateLimitCount` to bucket, `RecordRateLimit()`, `RecordToolSuccess()`, `RecordToolError()`, `flushTools()`, `checkAutoDisable()`, `SetAutoDisable()`, `AutoDisabler` interface, `toolBucket` struct. `Store` interface extended with `UpsertToolWindow()`, `ListAllToolCurrent()`.
- `handlers/completion.go`: `HealthRecorder` interface extended with `RecordRateLimit()`, `RecordToolSuccess()`, `RecordToolError()`. `recordHealth()` classifies HTTP 429 errors.
- `handlers/stream_loop.go`: Both tool execution sites now record tool health. `recordHealthFn()` classifies rate limit errors.
- `models/models.go`: `RateLimitCount` field on `ProviderHealthWindow`. `ToolHealthWindow`, `ToolHealthSummary` types. `ErrorCount`, `RateLimitCount`, `TimeoutCount` fields on `ProviderHealthSummary`. `ProjectID` field on `Attachment`.
- `store/postgres/health.go`: All queries updated for `rate_limit_count`. `UpsertToolWindow()`, `ListAllToolCurrent()`, `DeactivateProvider()` methods.
- `store/sqlite/health.go`: Full rewrite with rate_limit_count, tool health, and auto-disable support.
- `store/postgres/attachment.go`: `project_id` in cols, scan, create, and new `GetByProject()`.
- `store/sqlite/attachment.go`: Same project_id additions.
- `store/interfaces.go`: `GetByProject()` on `AttachmentStore`.
- `config/config.go`: `ProviderAutoDisableThreshold` field + env var loading.
- `main.go`: SQLite health store branching, auto-disable wiring, export handler route, project file routes.
- `handlers/health_admin.go`: Health summary includes error_count, rate_limit_count, timeout_count.
- `src/js/ui-admin.js`: Health dashboard cards show "Rate Limits" count with warning color.
- `src/js/projects-ui.js`: Git settings section, project files section, file upload handler.
- `src/js/editor-mode.js`: PDF and DOCX export menu items and handler.
- `src/js/api.js`: 5 new API client methods.
### Database
- Migration 015 (Postgres) / 014 (SQLite): `rate_limit_count` column on `provider_health`, `tool_health` table, `project_id` column on `attachments`.
## [0.22.3] — 2026-03-02 ## [0.22.3] — 2026-03-02
### Added ### Added

View File

@@ -1 +1 @@
0.22.3 0.22.4

View File

@@ -151,14 +151,14 @@ server/
├── crypto/ # AES-256-GCM API key encryption ├── crypto/ # AES-256-GCM API key encryption
├── events/ # EventBus + WebSocket hub ├── events/ # EventBus + WebSocket hub
├── extraction/ # Document text extraction pipeline ├── extraction/ # Document text extraction pipeline
├── health/ # Provider health tracking (v0.22.0) ├── health/ # Provider health tracking (v0.22.0), tool health + auto-disable (v0.22.4)
├── routing/ # Policy-based request routing (v0.22.2) ├── routing/ # Policy-based request routing (v0.22.2)
│ ├── types.go # Policy, Candidate, Context, Decision types │ ├── types.go # Policy, Candidate, Context, Decision types
│ ├── evaluator.go # Policy evaluation engine (4 policy types) │ ├── evaluator.go # Policy evaluation engine (4 policy types)
│ ├── evaluator_test.go # 12 tests │ ├── evaluator_test.go # 12 tests
│ ├── fallback.go # RunWithFallback candidate dispatcher │ ├── fallback.go # RunWithFallback candidate dispatcher
│ └── convert.go # DB model → routing type conversion │ └── convert.go # DB model → routing type conversion
│ ├── accumulator.go # In-memory counters, 60s flush to DB, status derivation │ ├── accumulator.go # In-memory counters, 60s flush, rate limits, tool health, auto-disable
│ └── accumulator_test.go # Concurrent recording, flush isolation, threshold tests │ └── accumulator_test.go # Concurrent recording, flush isolation, threshold tests
├── memory/ # Long-term memory extraction + scanning ├── memory/ # Long-term memory extraction + scanning
│ ├── extractor.go # Conversation analysis → fact extraction │ ├── extractor.go # Conversation analysis → fact extraction
@@ -178,6 +178,7 @@ server/
│ ├── stream_loop.go # Shared streaming + tool execution loop │ ├── stream_loop.go # Shared streaming + tool execution loop
│ ├── capabilities.go # ModelHandler: model list + health enrichment + ResolveModelCaps (v0.22.3) │ ├── capabilities.go # ModelHandler: model list + health enrichment + ResolveModelCaps (v0.22.3)
│ ├── health_admin.go # Provider health + capability override admin endpoints │ ├── health_admin.go # Provider health + capability override admin endpoints
│ ├── export.go # Markdown → PDF/DOCX conversion via pandoc (v0.22.4)
│ ├── routing_admin.go # Routing policy CRUD + dry-run test (v0.22.2) │ ├── routing_admin.go # Routing policy CRUD + dry-run test (v0.22.2)
│ ├── presets.go # Persona CRUD (all scopes) │ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK) │ ├── apiconfigs.go # User provider config CRUD (BYOK)

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,11 @@ type Config struct {
// WORKSPACE_INDEX_CONCURRENCY: max concurrent indexing goroutines (default 2). // WORKSPACE_INDEX_CONCURRENCY: max concurrent indexing goroutines (default 2).
WorkspaceIndexingEnabled bool WorkspaceIndexingEnabled bool
WorkspaceIndexConcurrency int 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. // Load reads configuration from environment variables.
@@ -98,6 +103,8 @@ func Load() *Config {
WorkspaceIndexingEnabled: getEnvBool("WORKSPACE_INDEXING_ENABLED", true), WorkspaceIndexingEnabled: getEnvBool("WORKSPACE_INDEXING_ENABLED", true),
WorkspaceIndexConcurrency: getEnvInt("WORKSPACE_INDEX_CONCURRENCY", 2), 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 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 // extToMIME maps file extensions to MIME types for cases where
// http.DetectContentType returns application/octet-stream. // http.DetectContentType returns application/octet-stream.
var extToMIME = map[string]string{ var extToMIME = map[string]string{

View File

@@ -64,6 +64,9 @@ type HealthRecorder interface {
RecordSuccess(providerConfigID string, latencyMs int) RecordSuccess(providerConfigID string, latencyMs int)
RecordError(providerConfigID string, latencyMs int, errMsg string) RecordError(providerConfigID string, latencyMs int, errMsg string)
RecordTimeout(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. // 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() errMsg := err.Error()
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") { if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
h.health.RecordTimeout(configID, latencyMs, errMsg) 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 { } else {
h.health.RecordError(configID, latencyMs, errMsg) 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), Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
RequestCount: w.RequestCount, RequestCount: w.RequestCount,
ErrorRate: w.ErrorRate(), ErrorRate: w.ErrorRate(),
ErrorCount: w.ErrorCount,
RateLimitCount: w.RateLimitCount,
TimeoutCount: w.TimeoutCount,
AvgLatencyMs: w.AvgLatencyMs(), AvgLatencyMs: w.AvgLatencyMs(),
MaxLatencyMs: w.MaxLatencyMs, MaxLatencyMs: w.MaxLatencyMs,
LastError: w.LastError, LastError: w.LastError,

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "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()) latencyMs := int(time.Since(start).Milliseconds())
if err != nil { if err != nil {
errMsg := err.Error() 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 { } else {
hr.RecordSuccess(configID, latencyMs) hr.RecordSuccess(configID, latencyMs)
} }
@@ -205,9 +210,19 @@ func streamWithToolLoop(
var toolResult tools.ToolResult var toolResult tools.ToolResult
// Check if tool is registered locally (server-side) // Check if tool is registered locally (server-side)
toolStart := time.Now()
if tools.Get(call.Name) != nil { if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID) log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call) 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) { } else if hub != nil && hub.IsConnected(userID) {
// Route to browser via WebSocket bridge // Route to browser via WebSocket bridge
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID) log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
@@ -418,8 +433,18 @@ func streamModelResponse(
} }
var toolResult tools.ToolResult var toolResult tools.ToolResult
toolStart := time.Now()
if tools.Get(call.Name) != nil { if tools.Get(call.Name) != nil {
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call) 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) { } else if hub != nil && hub.IsConnected(userID) {
toolResult = executeBrowserTool(hub, userID, call) toolResult = executeBrowserTool(hub, userID, call)
} else { } else {

View File

@@ -44,6 +44,13 @@ type Store interface {
// Prune deletes rows older than the given time. // Prune deletes rows older than the given time.
Prune(ctx context.Context, before time.Time) (int64, error) 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 ──────────────────────── // ── In-Memory Bucket ────────────────────────
@@ -54,6 +61,7 @@ type bucket struct {
requestCount int requestCount int
errorCount int errorCount int
timeoutCount int timeoutCount int
rateLimitCount int
totalLatencyMs int64 totalLatencyMs int64
maxLatencyMs int maxLatencyMs int
lastError string lastError string
@@ -67,23 +75,86 @@ type bucket struct {
type Accumulator struct { type Accumulator struct {
mu sync.Mutex mu sync.Mutex
buckets map[string]*bucket // keyed by provider_config_id buckets map[string]*bucket // keyed by provider_config_id
store Store
stopCh chan struct{} // Tool health (v0.22.4)
wg sync.WaitGroup 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. // NewAccumulator creates and starts the background flush loop.
func NewAccumulator(store Store) *Accumulator { func NewAccumulator(store Store) *Accumulator {
a := &Accumulator{ a := &Accumulator{
buckets: make(map[string]*bucket), buckets: make(map[string]*bucket),
store: store, toolBuckets: make(map[string]*toolBucket),
stopCh: make(chan struct{}), store: store,
stopCh: make(chan struct{}),
} }
a.wg.Add(1) a.wg.Add(1)
go a.flushLoop() go a.flushLoop()
return a 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. // RecordSuccess records a successful provider call.
func (a *Accumulator) RecordSuccess(providerConfigID string, latencyMs int) { func (a *Accumulator) RecordSuccess(providerConfigID string, latencyMs int) {
a.mu.Lock() a.mu.Lock()
@@ -127,6 +198,47 @@ func (a *Accumulator) RecordTimeout(providerConfigID string, latencyMs int, errM
b.lastErrorAt = time.Now().UTC() 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. // Stop halts the flush loop and performs a final flush.
func (a *Accumulator) Stop() { func (a *Accumulator) Stop() {
close(a.stopCh) close(a.stopCh)
@@ -158,6 +270,15 @@ func (a *Accumulator) getBucket(providerConfigID string) *bucket {
return b 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() { func (a *Accumulator) flushLoop() {
defer a.wg.Done() defer a.wg.Done()
ticker := time.NewTicker(FlushInterval) ticker := time.NewTicker(FlushInterval)
@@ -167,8 +288,11 @@ func (a *Accumulator) flushLoop() {
select { select {
case <-ticker.C: case <-ticker.C:
a.flush() a.flush()
a.flushTools()
a.checkAutoDisable()
case <-a.stopCh: case <-a.stopCh:
a.flush() // final flush a.flush() // final flush
a.flushTools()
return return
} }
} }
@@ -199,6 +323,7 @@ func (a *Accumulator) flush() {
RequestCount: b.requestCount, RequestCount: b.requestCount,
ErrorCount: b.errorCount, ErrorCount: b.errorCount,
TimeoutCount: b.timeoutCount, TimeoutCount: b.timeoutCount,
RateLimitCount: b.rateLimitCount,
TotalLatencyMs: b.totalLatencyMs, TotalLatencyMs: b.totalLatencyMs,
MaxLatencyMs: b.maxLatencyMs, 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. // hourFloor truncates a time to the start of its hour.
func hourFloor(t time.Time) time.Time { func hourFloor(t time.Time) time.Time {
return t.Truncate(time.Hour) 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) 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 ─────────────────────────────────── // ── Tests ───────────────────────────────────
func TestRecordSuccess(t *testing.T) { 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/storage"
"git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" 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"
"git.gobha.me/xcaliber/chat-switchboard/tools/search" "git.gobha.me/xcaliber/chat-switchboard/tools/search"
"git.gobha.me/xcaliber/chat-switchboard/workspace" "git.gobha.me/xcaliber/chat-switchboard/workspace"
@@ -57,7 +58,7 @@ func main() {
var keyResolver *crypto.KeyResolver var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore var objStore storage.ObjectStore
var healthAccum *health.Accumulator var healthAccum *health.Accumulator
var healthStore *postgres.HealthStore var healthStore health.Store
if err := database.Connect(cfg); err != nil { if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err) log.Printf("⚠ Database unavailable: %v", err)
@@ -94,10 +95,28 @@ func main() {
stores = postgres.NewStores(database.DB) stores = postgres.NewStores(database.DB)
// Provider health accumulator (v0.22.0) // 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) healthAccum = health.NewAccumulator(healthStore)
defer healthAccum.Stop() 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 // Background health cleanup: prune windows older than 7 days
go func() { go func() {
ticker := time.NewTicker(6 * time.Hour) ticker := time.NewTicker(6 * time.Hour)
@@ -472,6 +491,15 @@ func main() {
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote) protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
protected.GET("/projects/:id/notes", projectH.ListNotes) 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) // Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub) notifH := handlers.NewNotificationHandler(stores, hub)
protected.GET("/notifications", notifH.List) protected.GET("/notifications", notifH.List)
@@ -533,6 +561,10 @@ func main() {
protected.GET("/attachments/:id/download", attachH.Download) protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment) 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 // Hook: clean up storage files when channels are deleted
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage) handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)

View File

@@ -506,6 +506,7 @@ type Attachment struct {
ChannelID string `json:"channel_id" db:"channel_id"` ChannelID string `json:"channel_id" db:"channel_id"`
UserID string `json:"user_id" db:"user_id"` UserID string `json:"user_id" db:"user_id"`
MessageID *string `json:"message_id,omitempty" db:"message_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"` Filename string `json:"filename" db:"filename"`
ContentType string `json:"content_type" db:"content_type"` ContentType string `json:"content_type" db:"content_type"`
SizeBytes int64 `json:"size_bytes" db:"size_bytes"` SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
@@ -890,6 +891,7 @@ type ProviderHealthWindow struct {
RequestCount int `json:"request_count" db:"request_count"` RequestCount int `json:"request_count" db:"request_count"`
ErrorCount int `json:"error_count" db:"error_count"` ErrorCount int `json:"error_count" db:"error_count"`
TimeoutCount int `json:"timeout_count" db:"timeout_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"` TotalLatencyMs int64 `json:"total_latency_ms" db:"total_latency_ms"`
MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"` MaxLatencyMs int `json:"max_latency_ms" db:"max_latency_ms"`
LastError *string `json:"last_error,omitempty" db:"last_error"` LastError *string `json:"last_error,omitempty" db:"last_error"`
@@ -919,12 +921,38 @@ type ProviderHealthSummary struct {
Status ProviderStatus `json:"status"` Status ProviderStatus `json:"status"`
RequestCount int `json:"request_count"` // last hour RequestCount int `json:"request_count"` // last hour
ErrorRate float64 `json:"error_rate"` // 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 AvgLatencyMs int `json:"avg_latency_ms"` // last hour
MaxLatencyMs int `json:"max_latency_ms"` // last hour MaxLatencyMs int `json:"max_latency_ms"` // last hour
LastError *string `json:"last_error,omitempty"` LastError *string `json:"last_error,omitempty"`
LastErrorAt *string `json:"last_error_at,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) // CAPABILITY OVERRIDES (v0.22.0)
// ========================================= // =========================================

View File

@@ -393,6 +393,7 @@ type AttachmentStore interface {
GetByID(ctx context.Context, id string) (*models.Attachment, error) GetByID(ctx context.Context, id string) (*models.Attachment, error)
GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
GetByMessage(ctx context.Context, messageID 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 SetMessageID(ctx context.Context, attachmentID, messageID string) error
UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error
SetExtractedText(ctx context.Context, id string, text string) error SetExtractedText(ctx context.Context, id string, text string) error

View File

@@ -14,18 +14,19 @@ type AttachmentStore struct{}
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} } func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
// ── columns shared across queries ────────── // ── 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` size_bytes, storage_key, extracted_text, metadata, created_at`
// scanAttachment scans a row into an Attachment struct. // scanAttachment scans a row into an Attachment struct.
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) { func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
var a models.Attachment var a models.Attachment
var messageID sql.NullString var messageID sql.NullString
var projectID sql.NullString
var extractedText sql.NullString var extractedText sql.NullString
var metadataJSON []byte var metadataJSON []byte
err := row.Scan( 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.Filename, &a.ContentType, &a.SizeBytes,
&a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt, &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.MessageID = NullableStringPtr(messageID)
a.ProjectID = NullableStringPtr(projectID)
if extractedText.Valid { if extractedText.Valid {
a.ExtractedText = &extractedText.String 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 { func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
return DB.QueryRowContext(ctx, ` 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) 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`, RETURNING id, created_at`,
a.ChannelID, a.UserID, models.NullString(a.MessageID), a.ChannelID, a.UserID, models.NullString(a.MessageID),
models.NullString(a.ProjectID),
a.Filename, a.ContentType, a.SizeBytes, a.Filename, a.ContentType, a.SizeBytes,
a.StorageKey, models.NullString(a.ExtractedText), a.StorageKey, models.NullString(a.ExtractedText),
ToJSON(a.Metadata), ToJSON(a.Metadata),
@@ -99,6 +102,26 @@ func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([
return out, rows.Err() 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 { func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
_, err := DB.ExecContext(ctx, _, err := DB.ExecContext(ctx,
`UPDATE attachments SET message_id = $1 WHERE id = $2`, `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 { func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error {
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO provider_health (provider_config_id, window_start, 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) 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 ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
request_count = provider_health.request_count + EXCLUDED.request_count, request_count = provider_health.request_count + EXCLUDED.request_count,
error_count = provider_health.error_count + EXCLUDED.error_count, error_count = provider_health.error_count + EXCLUDED.error_count,
timeout_count = provider_health.timeout_count + EXCLUDED.timeout_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, 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), 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 = COALESCE(EXCLUDED.last_error, provider_health.last_error),
last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at), last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at),
updated_at = now() updated_at = now()
`, w.ProviderConfigID, w.WindowStart, `, 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, w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
) )
return err return err
@@ -44,13 +45,13 @@ func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID str
var w models.ProviderHealthWindow var w models.ProviderHealthWindow
err := s.db.QueryRowContext(ctx, ` err := s.db.QueryRowContext(ctx, `
SELECT id, provider_config_id, window_start, 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 total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health FROM provider_health
WHERE provider_config_id = $1 AND window_start = $2 WHERE provider_config_id = $1 AND window_start = $2
`, providerConfigID, windowStart).Scan( `, providerConfigID, windowStart).Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart, &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, &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
) )
if err == sql.ErrNoRows { 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) { func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT id, provider_config_id, window_start, 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 total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health FROM provider_health
WHERE provider_config_id = $1 WHERE provider_config_id = $1
@@ -79,7 +80,7 @@ func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string,
var w models.ProviderHealthWindow var w models.ProviderHealthWindow
if err := rows.Scan( if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart, &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, &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -93,7 +94,7 @@ func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHeal
windowStart := time.Now().UTC().Truncate(time.Hour) windowStart := time.Now().UTC().Truncate(time.Hour)
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT id, provider_config_id, window_start, 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 total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health FROM provider_health
WHERE window_start = $1 WHERE window_start = $1
@@ -109,7 +110,7 @@ func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHeal
var w models.ProviderHealthWindow var w models.ProviderHealthWindow
if err := rows.Scan( if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &w.WindowStart, &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, &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil { ); err != nil {
return nil, err return nil, err
@@ -126,5 +127,66 @@ func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error
if err != nil { if err != nil {
return 0, err return 0, err
} }
// Also prune tool health
s.db.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < $1`, before)
return result.RowsAffected() 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{} } func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
// ── columns shared across queries ────────── // ── 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` size_bytes, storage_key, extracted_text, metadata, created_at`
// scanAttachment scans a row into an Attachment struct. // scanAttachment scans a row into an Attachment struct.
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) { func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
var a models.Attachment var a models.Attachment
var messageID sql.NullString var messageID sql.NullString
var projectID sql.NullString
var extractedText sql.NullString var extractedText sql.NullString
var metadataJSON []byte var metadataJSON []byte
err := row.Scan( 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.Filename, &a.ContentType, &a.SizeBytes,
&a.StorageKey, &extractedText, &metadataJSON, st(&a.CreatedAt), &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.MessageID = NullableStringPtr(messageID)
a.ProjectID = NullableStringPtr(projectID)
if extractedText.Valid { if extractedText.Valid {
a.ExtractedText = &extractedText.String a.ExtractedText = &extractedText.String
} }
@@ -48,10 +50,11 @@ func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) erro
a.ID = store.NewID() a.ID = store.NewID()
a.CreatedAt = time.Now().UTC() a.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, ` _, 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) size_bytes, storage_key, extracted_text, metadata, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
a.ID, a.ChannelID, a.UserID, models.NullString(a.MessageID), a.ID, a.ChannelID, a.UserID, models.NullString(a.MessageID),
models.NullString(a.ProjectID),
a.Filename, a.ContentType, a.SizeBytes, a.Filename, a.ContentType, a.SizeBytes,
a.StorageKey, models.NullString(a.ExtractedText), a.StorageKey, models.NullString(a.ExtractedText),
ToJSON(a.Metadata), a.CreatedAt.Format(timeFmt), ToJSON(a.Metadata), a.CreatedAt.Format(timeFmt),
@@ -102,6 +105,26 @@ func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([
return out, rows.Err() 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 { func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
_, err := DB.ExecContext(ctx, _, err := DB.ExecContext(ctx,
`UPDATE attachments SET message_id = ? WHERE id = ?`, `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) windowStr := w.WindowStart.Format(timeFmt)
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO provider_health (id, provider_config_id, window_start, 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) 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 ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
request_count = provider_health.request_count + excluded.request_count, request_count = provider_health.request_count + excluded.request_count,
error_count = provider_health.error_count + excluded.error_count, error_count = provider_health.error_count + excluded.error_count,
timeout_count = provider_health.timeout_count + excluded.timeout_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, 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), 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 = COALESCE(excluded.last_error, provider_health.last_error),
last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at), last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at),
updated_at = datetime('now') updated_at = datetime('now')
`, id, w.ProviderConfigID, windowStr, `, 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, w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
) )
return err return err
@@ -43,13 +44,13 @@ func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID str
var windowStartStr string var windowStartStr string
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT id, provider_config_id, window_start, 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 total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health FROM provider_health
WHERE provider_config_id = ? AND window_start = ? WHERE provider_config_id = ? AND window_start = ?
`, providerConfigID, windowStr).Scan( `, providerConfigID, windowStr).Scan(
&w.ID, &w.ProviderConfigID, &windowStartStr, &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, &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
) )
if err == sql.ErrNoRows { 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) { func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, provider_config_id, window_start, 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 total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health FROM provider_health
WHERE provider_config_id = ? 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) windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
rows, err := DB.QueryContext(ctx, ` rows, err := DB.QueryContext(ctx, `
SELECT id, provider_config_id, window_start, 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 total_latency_ms, max_latency_ms, last_error, last_error_at
FROM provider_health FROM provider_health
WHERE window_start = ? WHERE window_start = ?
@@ -103,9 +104,72 @@ func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error
if err != nil { if err != nil {
return 0, err return 0, err
} }
DB.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < ?`, before.Format(timeFmt))
return result.RowsAffected() 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) { func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
var result []models.ProviderHealthWindow var result []models.ProviderHealthWindow
for rows.Next() { for rows.Next() {
@@ -113,7 +177,7 @@ func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
var windowStartStr string var windowStartStr string
if err := rows.Scan( if err := rows.Scan(
&w.ID, &w.ProviderConfigID, &windowStartStr, &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, &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
); err != nil { ); err != nil {
return nil, err return nil, err

View File

@@ -259,6 +259,8 @@ const API = {
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); }, getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
listWorkspaces() { return this._get('/api/v1/workspaces'); }, listWorkspaces() { return this._get('/api/v1/workspaces'); },
createWorkspace(data) { return this._post('/api/v1/workspaces', data); }, createWorkspace(data) { return this._post('/api/v1/workspaces', data); },
updateWorkspace(id, patch) { return this._patch(`/api/v1/workspaces/${id}`, patch); },
listGitCredentials() { return this._get('/api/v1/git-credentials'); },
// ── Messages ───────────────────────────── // ── Messages ─────────────────────────────
@@ -727,6 +729,33 @@ const API = {
adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); }, adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); },
adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); }, adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); },
// ── Project Files (v0.22.4) ─────────────
async projectUploadFile(projectId, file) {
const form = new FormData();
form.append('file', file, file.name);
const doFetch = () => fetch(BASE + `/api/v1/projects/${projectId}/files`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${this.accessToken}` },
body: form,
});
let resp = await doFetch();
if (resp.status === 401 && this.refreshToken) {
await this._refreshAccessToken();
resp = await doFetch();
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
return resp.json();
},
projectListFiles(projectId) { return this._get(`/api/v1/projects/${projectId}/files`); },
// ── Export (v0.22.4) ────────────────────
exportDocument(content, format, filename) {
return this._post('/api/v1/export', { content, format, filename });
},
// ── User Usage ────────────────────────── // ── User Usage ──────────────────────────
getMyUsage(params) { getMyUsage(params) {
const q = new URLSearchParams(); const q = new URLSearchParams();

View File

@@ -333,6 +333,8 @@ const EditorMode = {
<div class="editor-export-menu" id="editorExportMenu"> <div class="editor-export-menu" id="editorExportMenu">
<button class="editor-export-item" data-format="md">Download file</button> <button class="editor-export-item" data-format="md">Download file</button>
<button class="editor-export-item" data-format="html">Export as HTML</button> <button class="editor-export-item" data-format="html">Export as HTML</button>
<button class="editor-export-item" data-format="pdf">Export as PDF</button>
<button class="editor-export-item" data-format="docx">Export as DOCX</button>
<button class="editor-export-item" data-format="clipboard">Copy to clipboard</button> <button class="editor-export-item" data-format="clipboard">Copy to clipboard</button>
</div> </div>
</div> </div>
@@ -1020,6 +1022,37 @@ pre{background:#f5f5f5;padding:16px;border-radius:6px;overflow-x:auto}code{font-
a.click(); URL.revokeObjectURL(url); a.click(); URL.revokeObjectURL(url);
break; break;
} }
case 'pdf':
case 'docx': {
try {
if (typeof UI !== 'undefined') UI.toast(`Exporting as ${format.toUpperCase()}`, 'info');
const resp = await fetch(
(window.BASE_PATH || '') + '/api/v1/export',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API.accessToken}`,
},
body: JSON.stringify({ content, format, filename: fileName }),
}
);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${resp.status}`);
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.replace(/\.[^.]+$/, '.' + format);
a.click();
URL.revokeObjectURL(url);
} catch (e) {
if (typeof UI !== 'undefined') UI.toast(`Export failed: ${e.message}`, 'error');
}
break;
}
case 'clipboard': { case 'clipboard': {
try { try {
await navigator.clipboard.writeText(content); await navigator.clipboard.writeText(content);

View File

@@ -409,6 +409,26 @@ function _registerProjectPanel() {
</div> </div>
<div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div> <div class="project-panel-hint">Workspace files are available to AI in all project chats. Enables the Editor surface.</div>
</div> </div>
<div class="project-panel-section" id="projectGitSection" style="display:none">
<label class="project-panel-label">Git Settings</label>
<div style="display:flex;flex-direction:column;gap:6px">
<input type="text" id="projectGitRemote" class="project-panel-input" placeholder="Git remote URL (https://...)" spellcheck="false">
<input type="text" id="projectGitBranch" class="project-panel-input" placeholder="Branch (default: main)" spellcheck="false">
<select id="projectGitCredential" class="project-panel-select">
<option value="">No credential</option>
</select>
<button class="btn-small" id="projectGitSave">Save Git Config</button>
</div>
<div class="project-panel-hint">Configure git remote to sync workspace files with a repository.</div>
</div>
<div class="project-panel-section" id="projectFilesSection">
<label class="project-panel-label">Project Files</label>
<div id="projectFileList" class="project-panel-list"></div>
<div style="margin-top:6px">
<input type="file" id="projectFileInput" style="display:none" multiple>
<button class="btn-small" id="projectFileUploadBtn">Upload File</button>
</div>
</div>
<div class="project-panel-section project-panel-footer"> <div class="project-panel-section project-panel-footer">
<label class="project-panel-checkbox"> <label class="project-panel-checkbox">
<input type="checkbox" id="projectArchiveToggle"> <input type="checkbox" id="projectArchiveToggle">
@@ -434,6 +454,15 @@ function _registerProjectPanel() {
el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace); el.querySelector('#projectWsSelect').addEventListener('change', _saveProjectWorkspace);
el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace); el.querySelector('#projectWsCreate').addEventListener('click', _createProjectWorkspace);
// Wire git settings (v0.22.4)
el.querySelector('#projectGitSave').addEventListener('click', _saveProjectGitConfig);
// Wire project files (v0.22.4)
el.querySelector('#projectFileUploadBtn').addEventListener('click', () => {
document.getElementById('projectFileInput').click();
});
el.querySelector('#projectFileInput').addEventListener('change', _handleProjectFileUpload);
PanelRegistry.register('project', { PanelRegistry.register('project', {
element: el, element: el,
label: 'Project', label: 'Project',
@@ -481,6 +510,12 @@ async function _loadProjectPanel(projectId) {
// Load workspace picker (v0.21.5) // Load workspace picker (v0.21.5)
await _renderProjectWorkspace(projectId); await _renderProjectWorkspace(projectId);
// Load git settings (v0.22.4) — depends on workspace being loaded
await _renderProjectGitSettings(projectId);
// Load project files (v0.22.4)
await _renderProjectFiles(projectId);
} }
async function _saveProjectPrompt() { async function _saveProjectPrompt() {
@@ -700,6 +735,8 @@ async function _saveProjectWorkspace() {
UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success'); UI.toast(wsId ? 'Workspace linked' : 'Workspace unlinked', 'success');
// Trigger editor surface check // Trigger editor surface check
if (typeof EditorMode !== 'undefined') EditorMode.check(); if (typeof EditorMode !== 'undefined') EditorMode.check();
// Refresh git settings (v0.22.4)
await _renderProjectGitSettings(_projectPanelId);
} catch (e) { } catch (e) {
UI.toast('Failed to update workspace', 'error'); UI.toast('Failed to update workspace', 'error');
} }
@@ -731,6 +768,106 @@ async function _createProjectWorkspace() {
// ── Channel workspace picker (v0.21.5) ─────── // ── Channel workspace picker (v0.21.5) ───────
// ── Workspace Git Config (v0.22.4) ───────────
async function _renderProjectGitSettings(projectId) {
const gitSection = document.getElementById('projectGitSection');
const wsId = document.getElementById('projectWsSelect')?.value;
if (!wsId) {
gitSection.style.display = 'none';
return;
}
gitSection.style.display = '';
try {
const ws = await API.getWorkspace(wsId);
document.getElementById('projectGitRemote').value = ws.git_remote_url || '';
document.getElementById('projectGitBranch').value = ws.git_branch || '';
// Load git credentials
const credSel = document.getElementById('projectGitCredential');
credSel.innerHTML = '<option value="">No credential</option>';
try {
const creds = await API.listGitCredentials();
const list = Array.isArray(creds) ? creds : (creds?.data || []);
for (const c of list) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name || c.id.slice(0, 8);
if (c.id === ws.git_credential_id) opt.selected = true;
credSel.appendChild(opt);
}
} catch (_) {}
} catch (_) {
gitSection.style.display = 'none';
}
}
async function _saveProjectGitConfig() {
const wsId = document.getElementById('projectWsSelect')?.value;
if (!wsId) return;
const remote = document.getElementById('projectGitRemote').value.trim();
const branch = document.getElementById('projectGitBranch').value.trim() || null;
const credId = document.getElementById('projectGitCredential').value || null;
try {
await API.updateWorkspace(wsId, {
git_remote_url: remote || null,
git_branch: branch,
git_credential_id: credId,
});
UI.toast('Git config saved', 'success');
} catch (e) {
UI.toast('Failed to save git config: ' + (e.message || e), 'error');
}
}
// ── Project Files (v0.22.4) ──────────────────
async function _renderProjectFiles(projectId) {
const list = document.getElementById('projectFileList');
if (!list) return;
try {
const resp = await API.projectListFiles(projectId);
const files = resp?.files || [];
if (!files.length) {
list.innerHTML = '<div class="project-panel-empty">No files uploaded</div>';
return;
}
list.innerHTML = files.map(f => {
const size = f.size_bytes > 1024 * 1024
? (f.size_bytes / 1024 / 1024).toFixed(1) + ' MB'
: (f.size_bytes / 1024).toFixed(1) + ' KB';
return `<div class="project-panel-item" style="display:flex;justify-content:space-between;align-items:center">
<span title="${esc(f.filename)}">${esc(f.filename)}</span>
<span class="text-muted" style="font-size:11px">${size}</span>
</div>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="project-panel-empty">Failed to load files</div>';
}
}
async function _handleProjectFileUpload() {
const input = document.getElementById('projectFileInput');
if (!input.files?.length || !_projectPanelId) return;
for (const file of input.files) {
try {
await API.projectUploadFile(_projectPanelId, file);
UI.toast(`Uploaded ${file.name}`, 'success');
} catch (e) {
UI.toast(`Upload failed: ${e.message || e}`, 'error');
}
}
input.value = '';
await _renderProjectFiles(_projectPanelId);
}
async function showWorkspacePicker(chatId) { async function showWorkspacePicker(chatId) {
let workspaces = []; let workspaces = [];
try { try {

View File

@@ -929,6 +929,10 @@ Object.assign(UI, {
<span class="admin-storage-label">Timeouts</span> <span class="admin-storage-label">Timeouts</span>
<span class="admin-storage-value">${p.timeout_count ?? 0}</span> <span class="admin-storage-value">${p.timeout_count ?? 0}</span>
</div> </div>
<div class="admin-storage-item">
<span class="admin-storage-label">Rate Limits</span>
<span class="admin-storage-value" style="color:${(p.rate_limit_count || 0) > 0 ? 'var(--warning)' : 'inherit'}">${p.rate_limit_count ?? 0}</span>
</div>
<div class="admin-storage-item"> <div class="admin-storage-item">
<span class="admin-storage-label">Max Latency</span> <span class="admin-storage-label">Max Latency</span>
<span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span> <span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span>