Changeset 0.22.4 (#146)
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
1290
docs/ROADMAP.md
1290
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
server/database/migrations/015_v0224_health_enhance.sql
Normal file
33
server/database/migrations/015_v0224_health_enhance.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
110
server/handlers/export.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = ?`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user