diff --git a/server/capabilities/capabilities_test.go b/server/capabilities/capabilities_test.go deleted file mode 100644 index efa3b4d..0000000 --- a/server/capabilities/capabilities_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package capabilities - -import ( - "testing" - - "switchboard-core/models" -) - -func TestLookupKnownModel_AlwaysFalse(t *testing.T) { - // Known table removed in 0.9.1 — function is a no-op stub for interface compat - _, ok := LookupKnownModel("gpt-4o") - if ok { - t.Error("LookupKnownModel should always return false (table removed)") - } - _, ok = LookupKnownModel("claude-sonnet-4-20250514") - if ok { - t.Error("LookupKnownModel should always return false (table removed)") - } -} - -func TestInferCapabilities_ToolCalling(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"llama-3.1-70b-instruct", true}, - {"mistral-7b", true}, - {"qwen-2.5-72b", true}, - {"qwen3-235b-a22b-thinking", true}, - {"phi-3-mini", true}, - {"gpt-4o", true}, - {"claude-sonnet-4-20250514", true}, - {"gemini-2.5-pro", true}, - {"grok-41-fast", true}, - {"kimi-k2-thinking", true}, - {"minimax-m2.5", true}, - {"random-smallmodel", false}, - } - for _, tt := range tests { - caps := InferCapabilities(tt.model) - if caps.ToolCalling != tt.want { - t.Errorf("InferCapabilities(%q).ToolCalling = %v, want %v", tt.model, caps.ToolCalling, tt.want) - } - } -} - -func TestInferCapabilities_Vision(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"llava-v1.6-34b", true}, - {"gemini-2.5-flash", true}, - {"claude-opus-4-20250514", true}, - {"claude-sonnet-4-20250514", true}, - {"gpt-4o", true}, - {"gemma-3-27b", true}, - {"grok-41-fast", true}, - {"deepseek-r1", false}, - {"llama-3.1-70b", false}, - } - for _, tt := range tests { - caps := InferCapabilities(tt.model) - if caps.Vision != tt.want { - t.Errorf("InferCapabilities(%q).Vision = %v, want %v", tt.model, caps.Vision, tt.want) - } - } -} - -func TestInferCapabilities_Reasoning(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"deepseek-r1-distill-llama-70b", true}, - {"qwq-32b", true}, - {"o3-mini", true}, - {"qwen3-235b-a22b-thinking-2507", true}, - {"grok-41-fast", true}, - {"glm-5-32b", true}, - {"gpt-4o", false}, - {"llama-3.1-70b", false}, - } - for _, tt := range tests { - caps := InferCapabilities(tt.model) - if caps.Reasoning != tt.want { - t.Errorf("InferCapabilities(%q).Reasoning = %v, want %v", tt.model, caps.Reasoning, tt.want) - } - } -} - -func TestInferCapabilities_CodeOptimized(t *testing.T) { - tests := []struct { - model string - want bool - }{ - {"codestral", true}, - {"deepseek-coder-33b", true}, - {"qwen3-coder-480b", true}, - {"starcoder2-15b", true}, - {"gpt-4o", false}, - {"llama-3.1-70b", false}, - } - for _, tt := range tests { - caps := InferCapabilities(tt.model) - if caps.CodeOptimized != tt.want { - t.Errorf("InferCapabilities(%q).CodeOptimized = %v, want %v", tt.model, caps.CodeOptimized, tt.want) - } - } -} - -func TestResolveMaxOutput_FromCaps(t *testing.T) { - caps := models.ModelCapabilities{MaxOutputTokens: 32000} - got := ResolveMaxOutput("whatever-model", caps) - if got != 32000 { - t.Errorf("got %d, want 32000", got) - } -} - -func TestResolveMaxOutput_FromContext(t *testing.T) { - caps := models.ModelCapabilities{MaxContext: 32768} - got := ResolveMaxOutput("unknown-model-abc", caps) - if got != 4096 { - t.Errorf("got %d, want 4096 (derived from context/8)", got) - } -} - -func TestResolveMaxOutput_ContextClamp(t *testing.T) { - caps := models.ModelCapabilities{MaxContext: 1048576} - got := ResolveMaxOutput("unknown-large-model", caps) - if got != 16384 { - t.Errorf("got %d, want 16384 (clamped)", got) - } - - caps = models.ModelCapabilities{MaxContext: 4096} - got = ResolveMaxOutput("unknown-tiny-model", caps) - if got != 2048 { - t.Errorf("got %d, want 2048 (clamped)", got) - } -} - -func TestResolveMaxOutput_LastResort(t *testing.T) { - caps := models.ModelCapabilities{} - got := ResolveMaxOutput("mystery-model", caps) - if got != 4096 { - t.Errorf("got %d, want 4096 (last resort)", got) - } -} - -func TestResolveIntrinsic_CatalogWins(t *testing.T) { - // Provider reports tool_calling and max_output — authoritative. - // Heuristic should fill vision for claude (regex match). - providerCaps := models.ModelCapabilities{ - ToolCalling: true, - MaxOutputTokens: 16384, - } - merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps, nil) - - if !merged.ToolCalling { - t.Error("tool_calling should be preserved from provider") - } - if merged.MaxOutputTokens != 16384 { - t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens) - } - if !merged.Vision { - t.Error("vision should be filled from heuristic (claude-sonnet matches)") - } -} - -func TestResolveIntrinsic_ProviderDataPreserved(t *testing.T) { - // Provider says no vision — heuristic shouldn't override. - // mergeGaps only fills false→true, never overrides true→false. - // But: provider set vision=false explicitly. Since mergeGaps uses - // "fill zero", and false IS zero, heuristic CAN fill it. - // This is the expected behavior: heuristic is additive best-effort. - providerCaps := models.ModelCapabilities{ - ToolCalling: true, - Vision: false, - MaxOutputTokens: 8192, - MaxContext: 65536, - } - merged := ResolveIntrinsic("some-unknown-model", &providerCaps, nil) - - if !merged.ToolCalling { - t.Error("tool_calling should be preserved from provider") - } - if merged.MaxContext != 65536 { - t.Errorf("max_context should be preserved from provider, got %d", merged.MaxContext) - } -} - -func TestResolveIntrinsic_NilProvider(t *testing.T) { - // Nil provider caps — falls through entirely to heuristic - merged := ResolveIntrinsic("gpt-4o", nil, nil) - - if !merged.ToolCalling { - t.Error("should get tool_calling from heuristic (gpt-4 pattern)") - } - if !merged.Vision { - t.Error("should get vision from heuristic (gpt-4o pattern)") - } -} - -func TestResolveIntrinsic_HeuristicOnly(t *testing.T) { - // No catalog, no known table — pure heuristic - merged := ResolveIntrinsic("deepseek-r1-distill-llama-70b", nil, nil) - - if !merged.Reasoning { - t.Error("should infer reasoning from deepseek-r1 pattern") - } - if !merged.Streaming { - t.Error("should infer streaming (everything streams)") - } -} - -func TestHasProviderData(t *testing.T) { - empty := models.ModelCapabilities{} - if empty.HasProviderData() { - t.Error("empty caps should not have provider data") - } - - withTool := models.ModelCapabilities{ToolCalling: true} - if !withTool.HasProviderData() { - t.Error("caps with tool_calling should have provider data") - } - - withContext := models.ModelCapabilities{MaxContext: 128000} - if !withContext.HasProviderData() { - t.Error("caps with max_context should have provider data") - } -} - -func TestResolveIntrinsic_AdminOverrides(t *testing.T) { - // Catalog says no vision, heuristic says no vision, - // but admin override forces vision=true. - catalogCaps := &models.ModelCapabilities{ - ToolCalling: true, - Vision: false, - } - overrides := []models.CapabilityOverride{ - {Field: "vision", Value: "true"}, - } - - merged := ResolveIntrinsic("some-custom-model", catalogCaps, overrides) - - if !merged.Vision { - t.Error("admin override should force vision=true") - } - if !merged.ToolCalling { - t.Error("catalog tool_calling should be preserved") - } -} - -func TestResolveIntrinsic_AdminOverrides_DisableCapability(t *testing.T) { - // Heuristic infers tool_calling for gpt-4o, but admin says no. - overrides := []models.CapabilityOverride{ - {Field: "tool_calling", Value: "false"}, - } - - merged := ResolveIntrinsic("gpt-4o", nil, overrides) - - if merged.ToolCalling { - t.Error("admin override should disable tool_calling") - } -} - -func TestResolveIntrinsic_AdminOverrides_IntValues(t *testing.T) { - overrides := []models.CapabilityOverride{ - {Field: "max_context", Value: "200000"}, - {Field: "max_output_tokens", Value: "16384"}, - } - - merged := ResolveIntrinsic("some-model", nil, overrides) - - if merged.MaxContext != 200000 { - t.Errorf("expected max_context=200000, got %d", merged.MaxContext) - } - if merged.MaxOutputTokens != 16384 { - t.Errorf("expected max_output_tokens=16384, got %d", merged.MaxOutputTokens) - } -} - -func TestResolveIntrinsic_OverridePriority(t *testing.T) { - // Catalog says max_context=128000, admin overrides to 256000. - // Admin should win. - catalogCaps := &models.ModelCapabilities{ - MaxContext: 128000, - } - overrides := []models.CapabilityOverride{ - {Field: "max_context", Value: "256000"}, - } - - merged := ResolveIntrinsic("some-model", catalogCaps, overrides) - - if merged.MaxContext != 256000 { - t.Errorf("admin override should win over catalog: expected 256000, got %d", merged.MaxContext) - } -} - diff --git a/server/capabilities/intrinsic.go b/server/capabilities/intrinsic.go deleted file mode 100644 index dc97e4b..0000000 --- a/server/capabilities/intrinsic.go +++ /dev/null @@ -1,240 +0,0 @@ -package capabilities - -import ( - "regexp" - "strings" - - "switchboard-core/models" -) - -// ── Known Model Defaults ──────────────────── -// -// REMOVED in 0.9.1: The static known model table was removed because the same -// model ID can have different capabilities depending on the provider hosting it -// (e.g. DeepSeek on Venice has no tool_calling, same model on OpenRouter does). -// -// Capability resolution chain: -// 1. Catalog (provider API sync) — authoritative per-provider -// 2. Heuristic inference (regex on model ID) — best effort -// 3. Admin override — manual correction (shipped v0.22.0) -// -// The catalog is populated when providers are added (auto-fetch) or refreshed. -// Heuristics cover broad model families (llama→tools, gemini→vision, etc.) -// until catalog data is available. - -// LookupKnownModel always returns false — kept for interface compatibility. -// Callers fall through to heuristic inference. -func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) { - return models.ModelCapabilities{}, false -} - -// ── Heuristic Capability Detection ────────── - -var ( - toolPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)llama[_-]?[34]`), - regexp.MustCompile(`(?i)granite[_-]?[34]`), - regexp.MustCompile(`(?i)hermes[_-]?[23]?`), - regexp.MustCompile(`(?i)qwen[_-]?[23]`), - regexp.MustCompile(`(?i)mistral|mixtral`), - regexp.MustCompile(`(?i)command[_-]?r`), - regexp.MustCompile(`(?i)phi[_-]?[34]`), - regexp.MustCompile(`(?i)deepseek[_-]?v[23]`), - regexp.MustCompile(`(?i)functionary`), - regexp.MustCompile(`(?i)^gpt[_-]?[345]`), - regexp.MustCompile(`(?i)^openai[_-]gpt`), - regexp.MustCompile(`(?i)^claude`), - regexp.MustCompile(`(?i)gemma[_-]?[23]`), - regexp.MustCompile(`(?i)glm[_-]?[45]`), - regexp.MustCompile(`(?i)gemini`), - regexp.MustCompile(`(?i)grok`), - regexp.MustCompile(`(?i)kimi`), - regexp.MustCompile(`(?i)minimax`), - } - - visionPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)llava|bakllava`), - regexp.MustCompile(`(?i)moondream`), - regexp.MustCompile(`(?i)llama.*vision|vision.*llama`), - regexp.MustCompile(`(?i)minicpm[_-]?v`), - regexp.MustCompile(`(?i)^gpt[_-]?4[_-]?o|^gpt[_-]?4[_-]?turbo`), - regexp.MustCompile(`(?i)^claude[_-]?(3|opus|sonnet)`), - regexp.MustCompile(`(?i)gemini`), - regexp.MustCompile(`(?i)qwen.*vl|vl.*qwen`), - regexp.MustCompile(`(?i)phi[_-]?[34].*vision`), - regexp.MustCompile(`(?i)internvl`), - regexp.MustCompile(`(?i)glm[_-]?4v`), - regexp.MustCompile(`(?i)gemma[_-]?3`), - regexp.MustCompile(`(?i)grok[_-]?4`), - regexp.MustCompile(`(?i)mistral.*24b`), - } - - reasoningPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)deepseek[_-]?r1`), - regexp.MustCompile(`(?i)qwq`), - regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`), - regexp.MustCompile(`(?i)thinking`), - regexp.MustCompile(`(?i)grok`), - regexp.MustCompile(`(?i)glm[_-]?[45]`), - } - - codePatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)codellama|code[_-]?llama`), - regexp.MustCompile(`(?i)deepseek[_-]?coder`), - regexp.MustCompile(`(?i)starcoder`), - regexp.MustCompile(`(?i)coder|codestral`), - regexp.MustCompile(`(?i)wizardcoder`), - regexp.MustCompile(`(?i)codegemma`), - } -) - -func matchesAny(id string, patterns []*regexp.Regexp) bool { - for _, p := range patterns { - if p.MatchString(id) { - return true - } - } - return false -} - -// InferCapabilities guesses model capabilities from the model ID string. -// Best-effort fallback when the provider API hasn't reported capabilities. -func InferCapabilities(modelID string) models.ModelCapabilities { - id := normalizeModelID(modelID) - return models.ModelCapabilities{ - Streaming: true, // virtually everything streams - ToolCalling: matchesAny(id, toolPatterns), - Vision: matchesAny(id, visionPatterns), - Reasoning: matchesAny(id, reasoningPatterns), - CodeOptimized: matchesAny(id, codePatterns), - } -} - -// ResolveIntrinsic determines the intrinsic capabilities of a model. -// Priority (highest wins): -// 1. Admin overrides (manual corrections from capability_overrides table) -// 2. Catalog (from model_catalog DB — synced from provider API) -// 3. Heuristic inference (regex patterns on model ID) -// -// The catalog is the source of truth per provider. Heuristics fill gaps -// for models that haven't been synced yet. Admin overrides correct -// provider-reported or heuristic errors. No hardcoded model table — -// the same model can have different capabilities on different providers. -func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities, overrides []models.CapabilityOverride) models.ModelCapabilities { - // Start with catalog data if available - var base models.ModelCapabilities - if catalogCaps != nil && catalogCaps.HasProviderData() { - base = *catalogCaps - } - - // Fill gaps from heuristic inference - inferred := InferCapabilities(modelID) - mergeGaps(&base, &inferred) - - // Apply admin overrides (highest priority — can flip any field) - applyOverrides(&base, overrides) - - return base -} - -// applyOverrides applies admin capability corrections to the resolved capabilities. -func applyOverrides(caps *models.ModelCapabilities, overrides []models.CapabilityOverride) { - for _, o := range overrides { - boolVal := o.Value == "true" || o.Value == "1" - switch o.Field { - case "streaming": - caps.Streaming = boolVal - case "tool_calling": - caps.ToolCalling = boolVal - case "vision": - caps.Vision = boolVal - case "thinking": - caps.Thinking = boolVal - case "reasoning": - caps.Reasoning = boolVal - case "code_optimized": - caps.CodeOptimized = boolVal - case "web_search": - caps.WebSearch = boolVal - case "max_context": - if n := parseInt(o.Value); n > 0 { - caps.MaxContext = n - } - case "max_output_tokens": - if n := parseInt(o.Value); n > 0 { - caps.MaxOutputTokens = n - } - } - } -} - -// parseInt parses a string to int, returning 0 on failure. -func parseInt(s string) int { - n := 0 - for _, c := range s { - if c < '0' || c > '9' { - return 0 - } - n = n*10 + int(c-'0') - } - return n -} - -// ResolveMaxOutput returns the max output tokens for a model. -// Priority: explicit caps → derive from context → 4096 fallback. -func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int { - if caps.MaxOutputTokens > 0 { - return caps.MaxOutputTokens - } - if caps.MaxContext > 0 { - derived := caps.MaxContext / 8 - if derived < 2048 { - derived = 2048 - } - if derived > 16384 { - derived = 16384 - } - return derived - } - return 4096 -} - -// mergeGaps fills zero/false fields in dst from src. Never overrides existing data. -func mergeGaps(dst, src *models.ModelCapabilities) { - if !dst.Streaming && src.Streaming { - dst.Streaming = true - } - if !dst.ToolCalling && src.ToolCalling { - dst.ToolCalling = true - } - if !dst.Vision && src.Vision { - dst.Vision = true - } - if !dst.Thinking && src.Thinking { - dst.Thinking = true - } - if !dst.Reasoning && src.Reasoning { - dst.Reasoning = true - } - if !dst.CodeOptimized && src.CodeOptimized { - dst.CodeOptimized = true - } - if !dst.WebSearch && src.WebSearch { - dst.WebSearch = true - } - if dst.MaxContext == 0 && src.MaxContext > 0 { - dst.MaxContext = src.MaxContext - } - if dst.MaxOutputTokens == 0 && src.MaxOutputTokens > 0 { - dst.MaxOutputTokens = src.MaxOutputTokens - } -} - -// normalizeModelID strips provider prefix and lowercases. -func normalizeModelID(modelID string) string { - id := strings.ToLower(modelID) - if idx := strings.Index(id, "/"); idx >= 0 { - id = id[idx+1:] - } - return id -} diff --git a/server/capabilities/resolver.go b/server/capabilities/resolver.go deleted file mode 100644 index 5d6b8e3..0000000 --- a/server/capabilities/resolver.go +++ /dev/null @@ -1,288 +0,0 @@ -package capabilities - -import ( - "context" - "log" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ModelsForUser returns all models and Personas visible to a user. -// -// Visibility is controlled by the three-state visibility field on each -// catalog entry (enabled / team / disabled), applied per provider scope: -// -// Tier | enabled | team | disabled -// ----------+----------------+----------------------+--------- -// Global | All users | Team admin → personas | Hidden -// Team | Team members | Team admin → personas | Hidden -// Personal | Owner direct | N/A | Hidden -// -// Sources aggregated: -// 1. Global catalog: enabled models → all authenticated users -// 2. Team catalog: enabled models → team members -// 3. Personal BYOK: enabled models → owner (if allow_user_byok) -// 4. Personas: global + team-scoped + personal + shared -// 5. User hidden preferences applied last -func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) { - result := make([]models.UserModel, 0) // never nil — serializes as [] not null - - // Load policies once - policies, err := stores.Policies.GetAll(ctx) - if err != nil { - return nil, err - } - allowBYOK := policies["allow_user_byok"] == "true" - - // Load user's hidden preferences - hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID) - if err != nil { - log.Printf("warn: failed to load user model settings: %v", err) - hiddenMap = make(map[string]bool) - } - - // Get user's team IDs - teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID) - if err != nil { - log.Printf("warn: failed to load user teams: %v", err) - } - - // ── 1. Global enabled catalog models → all users ──── - globalModels, err := stores.Catalog.ListVisible(ctx) - if err != nil { - return nil, err - } - - // Build provider name lookup for global providers - globalProviders, _ := stores.Providers.ListGlobal(ctx) - providerMap := make(map[string]models.ProviderConfig) - for _, p := range globalProviders { - providerMap[p.ID] = p - } - - countGlobal := len(globalModels) - for _, entry := range globalModels { - caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil) - prov := providerMap[entry.ProviderConfigID] - - result = append(result, models.UserModel{ - ID: entry.ModelID, - DisplayName: displayName(entry.DisplayName, entry.ModelID), - ModelID: entry.ModelID, - ModelType: entry.ModelType, - Source: "catalog", - ProviderConfigID: entry.ProviderConfigID, - ConfigID: entry.ProviderConfigID, - ProviderName: prov.Name, - ProviderType: prov.Provider, - Capabilities: caps, - Pricing: entry.Pricing, - Scope: models.ScopeGlobal, - Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)], - }) - } - - // ── 2. Team enabled catalog models → team members ──── - countTeam := 0 - for _, teamID := range teamIDs { - teamProviders, err := stores.Providers.ListForTeam(ctx, teamID) - if err != nil { - log.Printf("warn: failed to load team %s providers: %v", teamID, err) - continue - } - for _, prov := range teamProviders { - if !prov.IsActive { - continue - } - entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID) - if err != nil { - continue - } - for _, entry := range entries { - caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil) - result = append(result, models.UserModel{ - ID: entry.ModelID, - DisplayName: displayName(entry.DisplayName, entry.ModelID), - ModelID: entry.ModelID, - ModelType: entry.ModelType, - Source: "catalog", - ProviderConfigID: prov.ID, - ConfigID: prov.ID, - ProviderName: prov.Name, - ProviderType: prov.Provider, - Capabilities: caps, - Pricing: entry.Pricing, - Scope: models.ScopeTeam, - OwnerID: prov.OwnerID, - Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)], - }) - countTeam++ - } - } - } - - // ── 3. Personal BYOK enabled models → owner ──── - countPersonal := 0 - if allowBYOK { - personalProviders, err := stores.Providers.ListForUser(ctx, userID) - if err != nil { - log.Printf("warn: failed to load personal providers: %v", err) - } else { - for _, prov := range personalProviders { - if !prov.IsActive { - continue - } - entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID) - if err != nil { - continue - } - for _, entry := range entries { - caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil) - result = append(result, models.UserModel{ - ID: entry.ModelID, - DisplayName: displayName(entry.DisplayName, entry.ModelID), - ModelID: entry.ModelID, - ModelType: entry.ModelType, - Source: "catalog", - ProviderConfigID: prov.ID, - ConfigID: prov.ID, - ProviderName: prov.Name, - ProviderType: prov.Provider, - Capabilities: caps, - Pricing: entry.Pricing, - Scope: models.ScopePersonal, - OwnerID: &userID, - Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)], - }) - countPersonal++ - } - } - } - } - - // ── 4. Personas (always resolved) ──────────────── - personas, err := stores.Personas.ListForUser(ctx, userID) - if err != nil { - log.Printf("warn: failed to load personas: %v", err) - } else { - for _, p := range personas { - // Resolve base model capabilities - var catalogCaps *models.ModelCapabilities - if p.ProviderConfigID != nil { - if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - // Fallback: look up any provider's catalog entry for this model - // (covers auto-resolve personas where provider_config_id is NULL) - if catalogCaps == nil { - if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - caps := ResolveIntrinsic(p.BaseModelID, catalogCaps, nil) - - // Load tool grants - toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID) - - if len(toolGrants) > 0 { - caps.ToolCalling = true - } - - // Look up provider info - var provName, provType string - if p.ProviderConfigID != nil { - if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil { - provName = prov.Name - provType = prov.Provider - } - } - - result = append(result, models.UserModel{ - ID: p.ID, - DisplayName: p.Name, - ModelID: p.BaseModelID, - Source: "persona", - ProviderConfigID: deref(p.ProviderConfigID), - ConfigID: deref(p.ProviderConfigID), - ProviderName: provName, - ProviderType: provType, - Capabilities: caps, - IsPersona: true, - PersonaID: p.ID, - PersonaHandle: p.Handle, - PersonaScope: p.Scope, - PersonaAvatar: p.Avatar, - PersonaTeamName: teamName(p, stores, ctx), - Description: p.Description, - Icon: p.Icon, - Avatar: p.Avatar, - SystemPrompt: p.SystemPrompt, - Temperature: p.Temperature, - MaxTokens: p.MaxTokens, - ToolGrants: toolGrants, - Scope: p.Scope, - OwnerID: p.OwnerID, - Hidden: false, // ICD §11.2: persona visibility via grants, not model prefs - }) - } - } - - countPersonas := len(result) - countGlobal - countTeam - countPersonal - log.Printf("📋 ModelsForUser(%s): %d global, %d team, %d personal, %d personas → %d total", - userID, countGlobal, countTeam, countPersonal, countPersonas, len(result)) - - return result, nil -} - -// ResolveForPersona returns effective capabilities for a specific Persona. -// Used at completion time to determine what tools/features are available. -func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) { - var catalogCaps *models.ModelCapabilities - if persona.ProviderConfigID != nil { - if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - // Fallback: any provider's catalog entry (auto-resolve personas) - if catalogCaps == nil { - if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil) - - toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID) - if err != nil { - return caps, nil, err - } - - return caps, toolGrants, nil -} - -func displayName(name, modelID string) string { - if name != "" { - return name - } - return modelID -} - -func deref(s *string) string { - if s == nil { - return "" - } - return *s -} - -// teamName resolves the team_name for a persona's owner (if team-scoped). -func teamName(p models.Persona, stores store.Stores, ctx context.Context) string { - if p.Scope != models.ScopeTeam || p.OwnerID == nil { - return "" - } - team, err := stores.Teams.GetByID(ctx, *p.OwnerID) - if err != nil { - return "" - } - return team.Name -} diff --git a/server/compaction/compaction.go b/server/compaction/compaction.go deleted file mode 100644 index a33db2d..0000000 --- a/server/compaction/compaction.go +++ /dev/null @@ -1,314 +0,0 @@ -package compaction - -import ( - "context" - "encoding/json" - "fmt" - "log" - "strings" - "time" - - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/roles" - "switchboard-core/store" - "switchboard-core/treepath" -) - -// ── Request / Result ──────────────────────── - -// CompactRequest specifies what to compact. -type CompactRequest struct { - ChannelID string - UserID string // channel owner - TeamID *string // for role resolution - Trigger string // "manual" | "auto" -} - -// CompactResult describes what compaction produced. -type CompactResult struct { - SummaryID string - SummarizedCount int - Model string - UsedFallback bool - Content string - InputTokens int - OutputTokens int -} - -// ── Errors ────────────────────────────────── - -var ( - // ErrContextBudget is returned when the conversation to summarize - // exceeds the utility model's context window. - ErrContextBudget = fmt.Errorf("conversation exceeds utility model context window") -) - -// ── Service ───────────────────────────────── - -// Service provides conversation compaction (summarization). -// Used by both the HTTP handler (manual) and the background scanner (auto). -type Service struct { - stores store.Stores - resolver *roles.Resolver -} - -// NewService creates a compaction service. -func NewService(stores store.Stores, resolver *roles.Resolver) *Service { - return &Service{stores: stores, resolver: resolver} -} - -// Resolver exposes the underlying role resolver (for external checks like -// IsConfigured / IsPersonalOverride). -func (s *Service) Resolver() *roles.Resolver { - return s.resolver -} - -// ── Compact ───────────────────────────────── - -// Compact summarizes the conversation in a channel, inserting a summary -// tree node. Returns the result or an error. -// -// The method: -// 1. Loads the active path via the message tree -// 2. Finds the most recent summary boundary -// 3. Collects post-boundary messages -// 4. Checks the content fits the utility model's context window -// 5. Calls the utility model role to generate a summary -// 6. Inserts the summary as a tree node with metadata -// 7. Updates the user's cursor -// 8. Logs usage -func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error) { - // ── Load active path ── - path, err := treepath.GetActivePath(req.ChannelID, req.UserID) - if err != nil { - return nil, fmt.Errorf("load conversation: %w", err) - } - if len(path) < 4 { - return nil, fmt.Errorf("conversation too short to summarize") - } - - // Find existing summary boundary (if any) and only summarize after it - startIdx := 0 - for i := range path { - if treepath.IsSummaryMessage(&path[i]) { - startIdx = i + 1 - } - } - - // Build messages to summarize (skip system messages, skip previous summaries) - var toSummarize []string - messagesInScope := 0 - var lastMessageID string - for _, m := range path[startIdx:] { - if m.Role == "system" || treepath.IsSummaryMessage(&m) { - continue - } - toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content)) - messagesInScope++ - lastMessageID = m.ID - } - - if messagesInScope < 4 { - return nil, fmt.Errorf("not enough new messages since last summary") - } - - // ── Build the summarization prompt ── - conversationText := strings.Join(toSummarize, "\n\n") - systemPrompt := `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves: -- Key decisions and conclusions reached -- Important facts, names, numbers, and technical details mentioned -- Action items or commitments made -- The overall context and topic flow - -Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.` - - userContent := "Summarize this conversation:\n\n" + conversationText - - // ── Context budget guard rail ── - // Estimate whether the prompt fits the utility model's context window. - // Prevents sending truncated input to small models (e.g. 32K utility - // summarizing a 128K conversation). - promptTokens := EstimateTokens(systemPrompt) + 4 + EstimateTokens(userContent) + 4 - utilityBudget := s.getUtilityContextBudget(ctx, req.UserID, req.TeamID) - // Reserve 20% for output (the summary itself) - inputCeiling := int(float64(utilityBudget) * 0.80) - - if promptTokens > inputCeiling { - log.Printf("⚠ Compaction skipped for channel %s: prompt ~%dK tokens exceeds utility model budget %dK (ceiling %dK)", - req.ChannelID, promptTokens/1000, utilityBudget/1000, inputCeiling/1000) - return nil, fmt.Errorf("%w: ~%d tokens needed, utility model allows ~%d", - ErrContextBudget, promptTokens, inputCeiling) - } - - summaryPrompt := []providers.Message{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userContent}, - } - - // ── Call utility role ── - log.Printf("📝 Compacting channel %s for user %s (%d messages, ~%dK tokens, trigger=%s)", - req.ChannelID, req.UserID, messagesInScope, promptTokens/1000, req.Trigger) - - result, err := s.resolver.Complete(ctx, roles.RoleUtility, req.UserID, req.TeamID, summaryPrompt) - if err != nil { - log.Printf("⚠ Compaction failed for channel %s: %v", req.ChannelID, err) - return nil, fmt.Errorf("summarization failed: %w", err) - } - - // ── Log usage ── - s.logUsage(ctx, req.ChannelID, req.UserID, req.Trigger, result) - - // ── Insert summary message as a tree node ── - metadata := models.JSONMap{ - "type": "summary", - "summarized_until_id": lastMessageID, - "summarized_count": messagesInScope, - "utility_model": result.Model, - "used_fallback": result.UsedFallback, - "trigger": req.Trigger, - } - - metaJSON, _ := json.Marshal(metadata) - siblingIdx := treepath.NextSiblingIndex(req.ChannelID, &lastMessageID) - - summaryMsg := &models.Message{ - ChannelID: req.ChannelID, - ParentID: &lastMessageID, - Role: "assistant", - Content: result.Content, - Model: result.Model, - Metadata: models.JSONMap{}, - SiblingIndex: siblingIdx, - ParticipantType: "system", - } - _ = json.Unmarshal(metaJSON, &summaryMsg.Metadata) - if err := s.stores.Messages.Create(ctx, summaryMsg); err != nil { - log.Printf("⚠ Failed to persist summary for channel %s: %v", req.ChannelID, err) - return nil, fmt.Errorf("failed to save summary: %w", err) - } - summaryMsgID := summaryMsg.ID - - // Update cursor to point to the summary node - if err := treepath.UpdateCursor(req.ChannelID, req.UserID, summaryMsgID); err != nil { - log.Printf("⚠ Failed to update cursor after compaction: %v", err) - } - - log.Printf("✅ Compaction done for channel %s: %s (%d messages → %d chars, trigger=%s)", - req.ChannelID, summaryMsgID, messagesInScope, len(result.Content), req.Trigger) - - return &CompactResult{ - SummaryID: summaryMsgID, - SummarizedCount: messagesInScope, - Model: result.Model, - UsedFallback: result.UsedFallback, - Content: result.Content, - InputTokens: result.InputTokens, - OutputTokens: result.OutputTokens, - }, nil -} - -// ── Context Budget ────────────────────────── - -// getUtilityContextBudget resolves the utility model's context window. -// Falls back to DefaultBudget if the model isn't in the catalog. -func (s *Service) getUtilityContextBudget(ctx context.Context, userID string, teamID *string) int { - cfg, err := s.resolver.GetConfig(ctx, roles.RoleUtility, userID, teamID) - if err != nil || cfg == nil || cfg.Primary == nil { - return DefaultBudget - } - - // Look up the primary model in catalog - entry, err := s.stores.Catalog.GetByModelID(ctx, cfg.Primary.ProviderConfigID, cfg.Primary.ModelID) - if err == nil && entry.Capabilities.MaxContext > 0 { - return entry.Capabilities.MaxContext - } - - // Try any-provider lookup (model may be synced under a different config) - entry, err = s.stores.Catalog.GetByModelIDAny(ctx, cfg.Primary.ModelID) - if err == nil && entry.Capabilities.MaxContext > 0 { - return entry.Capabilities.MaxContext - } - - return DefaultBudget -} - -// ── Rate Limit Check ──────────────────────── - -// CheckRateLimit checks the utility rate limit for org-funded calls. -// Returns nil if within limits, an error with a descriptive message otherwise. -func (s *Service) CheckRateLimit(ctx context.Context, userID string) error { - // Load rate limit from global settings (default: 20/hour, 0 = unlimited) - limit := 20 - settings, err := s.stores.GlobalConfig.Get(ctx, "utility_rate_limit") - if err == nil { - if v, ok := settings["value"]; ok { - switch n := v.(type) { - case float64: - limit = int(n) - case int: - limit = n - } - } - } - - if limit <= 0 { - return nil // unlimited - } - - since := time.Now().Add(-1 * time.Hour) - count, err := s.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since) - if err != nil { - log.Printf("⚠ Rate limit check failed: %v", err) - return nil // fail open — don't block on DB errors - } - - if count >= limit { - return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit) - } - return nil -} - -// ── Helpers ───────────────────────────────── - -// GetUserTeamID returns the user's first team ID (for role resolution). -func (s *Service) GetUserTeamID(ctx context.Context, userID string) *string { - teamID, _ := s.stores.Teams.GetFirstTeamIDForUser(ctx, userID) - if teamID == "" { - return nil - } - return &teamID -} - -func (s *Service) logUsage(ctx context.Context, channelID, userID, trigger string, result *roles.CompletionResult) { - role := roles.RoleUtility - entry := &models.UsageEntry{ - ChannelID: &channelID, - UserID: userID, - ProviderConfigID: &result.ConfigID, - ProviderScope: result.ProviderScope, - ModelID: result.Model, - Role: &role, - PromptTokens: result.InputTokens, - CompletionTokens: result.OutputTokens, - CacheCreationTokens: result.CacheCreationTokens, - CacheReadTokens: result.CacheReadTokens, - } - - // Calculate cost from pricing - pricing, err := s.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model) - if err == nil && pricing != nil { - if pricing.InputPerM != nil { - costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM - entry.CostInput = &costIn - } - if pricing.OutputPerM != nil { - costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM - entry.CostOutput = &costOut - } - } - - if err := s.stores.Usage.Log(ctx, entry); err != nil { - log.Printf("⚠ Failed to log compaction usage: %v", err) - } -} diff --git a/server/compaction/compaction_test.go b/server/compaction/compaction_test.go deleted file mode 100644 index 95755bd..0000000 --- a/server/compaction/compaction_test.go +++ /dev/null @@ -1,409 +0,0 @@ -package compaction - -import ( - "context" - "encoding/json" - "testing" - "time" - - "switchboard-core/database" - "switchboard-core/models" - postgres "switchboard-core/store/postgres" -) - -// ── Helpers ───────────────────────────────── - -func requireDB(t *testing.T) { - t.Helper() - database.RequireTestDB(t) - database.TruncateAll(t) -} - -func seedChannel(t *testing.T, userID, title string, msgCount, contentSize int) (channelID string, msgIDs []string) { - t.Helper() - channelID = database.SeedTestChannel(t, userID, title) - msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize) - if len(msgIDs) > 0 { - database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1]) - } - return -} - -// setGlobalSetting writes a global_settings key for scanner config tests. -func setGlobalSetting(t *testing.T, key string, value interface{}) { - t.Helper() - valMap := models.JSONMap{"value": value} - valJSON, _ := json.Marshal(valMap) - _, err := database.DB.Exec(` - INSERT INTO global_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2 - `, key, string(valJSON)) - if err != nil { - t.Fatalf("setGlobalSetting(%s): %v", key, err) - } -} - -// seedChannelBackdated creates a channel with updated_at set to (now - age). -// Uses INSERT with an explicit timestamp so the BEFORE UPDATE trigger on -// channels (which overwrites updated_at = NOW()) never fires. -func seedChannelBackdated(t *testing.T, userID, title string, age time.Duration, msgCount, contentSize int) (channelID string, msgIDs []string) { - t.Helper() - target := time.Now().Add(-age) - err := database.DB.QueryRow(` - INSERT INTO channels (user_id, title, type, updated_at) - VALUES ($1, $2, 'direct', $3) - RETURNING id - `, userID, title, target).Scan(&channelID) - if err != nil { - t.Fatalf("seedChannelBackdated: %v", err) - } - msgIDs = database.SeedTestMessages(t, channelID, msgCount, contentSize) - if len(msgIDs) > 0 { - database.SeedTestCursor(t, channelID, userID, msgIDs[len(msgIDs)-1]) - } - return -} - -// setChannelSettings sets the channel.settings JSONB. -func setChannelSettings(t *testing.T, channelID string, settings models.JSONMap) { - t.Helper() - sJSON, _ := json.Marshal(settings) - _, err := database.DB.Exec(`UPDATE channels SET settings = $1 WHERE id = $2`, string(sJSON), channelID) - if err != nil { - t.Fatalf("setChannelSettings: %v", err) - } -} - -// ═══════════════════════════════════════════ -// Estimator Tests (no DB — duplicated here for package-level access) -// ═══════════════════════════════════════════ -// Note: estimator_test.go covers the unit tests more thoroughly. -// These are here to verify the package compiles with DB tests. - -func TestEstimateTokens_Basic(t *testing.T) { - if got := EstimateTokens("hello world!"); got != 3 { // 12 chars → 3 - t.Errorf("EstimateTokens = %d, want 3", got) - } -} - -// ═══════════════════════════════════════════ -// Scanner: Candidate Query -// ═══════════════════════════════════════════ - -func TestScanner_FindCandidates_ReturnsQualifying(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) // no resolver needed for this test - sc := NewScanner(svc, stores, ScannerConfig{}) - - userID := database.SeedTestUser(t, "scanuser1", "scan1@test.com") - - // Create a channel with enough messages to qualify - // 20 messages × 2000 chars = 40K chars (> candidateMinChars=20K) - // Backdated 5min so it passes activity gap (>2min) and recency (<7 days) - channelID, _ := seedChannelBackdated(t, userID, "Big Chat", 5*time.Minute, 20, 2000) - - ctx := context.Background() - candidates := sc.findCandidates(ctx) - - if len(candidates) == 0 { - t.Fatal("expected at least 1 candidate, got 0") - } - - found := false - for _, c := range candidates { - if c.ID == channelID { - found = true - break - } - } - if !found { - t.Fatalf("channel %s not in candidates (got %d candidates)", channelID, len(candidates)) - } -} - -func TestScanner_FindCandidates_ExcludesTooFewMessages(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - - userID := database.SeedTestUser(t, "scanuser2", "scan2@test.com") - - // Only 4 messages — below candidateMinMessages=10 - channelID, _ := seedChannelBackdated(t, userID, "Small Chat", 5*time.Minute, 4, 2000) - - ctx := context.Background() - candidates := sc.findCandidates(ctx) - - for _, c := range candidates { - if c.ID == channelID { - t.Fatal("channel with <10 messages should not be a candidate") - } - } -} - -func TestScanner_FindCandidates_ExcludesTooRecent(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - - userID := database.SeedTestUser(t, "scanuser3", "scan3@test.com") - - // Enough messages but updated just now (< candidateActivityGap=2min) - seedChannel(t, userID, "Fresh Chat", 20, 2000) - // Don't backdate — should be excluded - - ctx := context.Background() - candidates := sc.findCandidates(ctx) - - for _, c := range candidates { - if c.UserID == userID { - t.Fatal("channel updated just now should not be a candidate (activity gap)") - } - } -} - -func TestScanner_FindCandidates_ExcludesArchived(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - - userID := database.SeedTestUser(t, "scanuser4", "scan4@test.com") - channelID, _ := seedChannelBackdated(t, userID, "Archived Chat", 5*time.Minute, 20, 2000) - - // Archive the channel - database.DB.Exec(`UPDATE channels SET is_archived = true WHERE id = $1`, channelID) - - ctx := context.Background() - candidates := sc.findCandidates(ctx) - - for _, c := range candidates { - if c.ID == channelID { - t.Fatal("archived channel should not be a candidate") - } - } -} - -// ═══════════════════════════════════════════ -// Scanner: Cooldown + Dedup -// ═══════════════════════════════════════════ - -func TestScanner_Cooldown(t *testing.T) { - sc := &Scanner{ - lastCompacted: make(map[string]time.Time), - } - - channelID := "test-cooldown-channel" - - // No cooldown initially - sc.mu.Lock() - _, inCooldown := sc.lastCompacted[channelID] - sc.mu.Unlock() - if inCooldown { - t.Fatal("should not be in cooldown initially") - } - - // Record compaction - sc.mu.Lock() - sc.lastCompacted[channelID] = time.Now() - sc.mu.Unlock() - - // Now it's in cooldown - sc.mu.Lock() - last, ok := sc.lastCompacted[channelID] - sc.mu.Unlock() - if !ok { - t.Fatal("should be in cooldown after recording") - } - if time.Since(last) > time.Second { - t.Fatal("cooldown timestamp should be recent") - } -} - -func TestScanner_InFlightDedup(t *testing.T) { - sc := &Scanner{} - // sync.Map zero value is ready to use - - channelID := "test-dedup-channel" - - // First load — not in flight - _, loaded := sc.inFlight.LoadOrStore(channelID, struct{}{}) - if loaded { - t.Fatal("should not be loaded on first attempt") - } - - // Second load — already in flight - _, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{}) - if !loaded { - t.Fatal("should be loaded on second attempt (dedup)") - } - - // Clean up - sc.inFlight.Delete(channelID) - _, loaded = sc.inFlight.LoadOrStore(channelID, struct{}{}) - if loaded { - t.Fatal("should not be loaded after delete") - } -} - -// ═══════════════════════════════════════════ -// Scanner: Channel Opt-Out -// ═══════════════════════════════════════════ - -func TestScanner_ShouldCompact_ChannelOptOut(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) // no resolver → IsConfigured returns false - sc := NewScanner(svc, stores, ScannerConfig{}) - - userID := database.SeedTestUser(t, "optout_user", "optout@test.com") - channelID, _ := seedChannel(t, userID, "Opt-Out Chat", 20, 2000) - - // Opt out via channel settings - setChannelSettings(t, channelID, models.JSONMap{"auto_compaction": false}) - - ch := models.Channel{Settings: models.JSONMap{"auto_compaction": false}} - ch.ID = channelID - ch.UserID = userID - - ctx := context.Background() - if sc.shouldCompact(ctx, &ch) { - t.Fatal("shouldCompact should return false for opted-out channel") - } -} - -// ═══════════════════════════════════════════ -// Scanner: Settings Helpers -// ═══════════════════════════════════════════ - -func TestScanner_IsEnabled(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - ctx := context.Background() - - // Default: disabled - if sc.isEnabled(ctx) { - t.Fatal("should be disabled by default") - } - - // Enable - setGlobalSetting(t, "auto_compaction_enabled", true) - if !sc.isEnabled(ctx) { - t.Fatal("should be enabled after setting to true") - } - - // Disable again - setGlobalSetting(t, "auto_compaction_enabled", false) - if sc.isEnabled(ctx) { - t.Fatal("should be disabled after setting to false") - } -} - -func TestScanner_GetThreshold_Default(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - - ch := &models.Channel{} - got := sc.getThreshold(ch) - if got != DefaultThreshold { - t.Errorf("default threshold = %f, want %f", got, DefaultThreshold) - } -} - -func TestScanner_GetThreshold_GlobalOverride(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - - setGlobalSetting(t, "auto_compaction_threshold", 0.85) - - ch := &models.Channel{} - got := sc.getThreshold(ch) - if got != 0.85 { - t.Errorf("global threshold = %f, want 0.85", got) - } -} - -func TestScanner_GetThreshold_ChannelOverride(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - - setGlobalSetting(t, "auto_compaction_threshold", 0.85) - - ch := &models.Channel{ - Settings: models.JSONMap{"compaction_threshold": 0.60}, - } - got := sc.getThreshold(ch) - if got != 0.60 { - t.Errorf("channel threshold = %f, want 0.60", got) - } -} - -func TestScanner_GetCooldownDuration_Default(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - ctx := context.Background() - - got := sc.getCooldownDuration(ctx) - if got != DefaultCooldown { - t.Errorf("default cooldown = %s, want %s", got, DefaultCooldown) - } -} - -func TestScanner_GetCooldownDuration_Override(t *testing.T) { - requireDB(t) - stores := postgres.NewStores(database.TestDB) - svc := NewService(stores, nil) - sc := NewScanner(svc, stores, ScannerConfig{}) - ctx := context.Background() - - setGlobalSetting(t, "auto_compaction_cooldown_minutes", float64(15)) - - got := sc.getCooldownDuration(ctx) - want := 15 * time.Minute - if got != want { - t.Errorf("cooldown = %s, want %s", got, want) - } -} - -// ═══════════════════════════════════════════ -// Context Budget Guard Rail -// ═══════════════════════════════════════════ - -func TestEstimateTokens_GuardRailMath(t *testing.T) { - // Simulate a large conversation that exceeds a 32K utility model - // 120K chars ≈ 30K tokens of conversation + system prompt overhead - // inputCeiling = 32K * 0.80 = 25.6K → 30K exceeds it - largeContent := make([]byte, 120000) - contentTokens := EstimateTokens(string(largeContent)) - systemTokens := EstimateTokens("You are a conversation summarizer...") + 8 // +overhead - - totalPrompt := contentTokens + systemTokens - utilityBudget := 32000 // 32K model - inputCeiling := int(float64(utilityBudget) * 0.80) - - if totalPrompt <= inputCeiling { - t.Errorf("120K chars (%d tokens) should exceed 32K model ceiling (%d tokens)", - totalPrompt, inputCeiling) - } - - // Smaller conversation should fit - smallContent := make([]byte, 50000) - smallTokens := EstimateTokens(string(smallContent)) + systemTokens - if smallTokens > inputCeiling { - t.Errorf("50K chars (%d tokens) should fit in 32K model ceiling (%d tokens)", - smallTokens, inputCeiling) - } -} diff --git a/server/compaction/estimator.go b/server/compaction/estimator.go deleted file mode 100644 index f998577..0000000 --- a/server/compaction/estimator.go +++ /dev/null @@ -1,57 +0,0 @@ -package compaction - -import ( - "switchboard-core/treepath" -) - -// ── Token Estimation ──────────────────────── -// -// Rough heuristic matching the frontend Tokens.estimate() (~4 chars/token). -// Good enough for threshold checks. Swap in a real tokenizer later if -// over/under-triggering becomes a problem. - -// EstimateTokens returns a rough token count for a string. -// Uses the ~4 chars/token heuristic for English (GPT/Claude average). -func EstimateTokens(text string) int { - return (len(text) + 3) / 4 -} - -// EstimatePath returns total estimated tokens for a message path. -// Adds +4 per message for role/delimiter overhead, matching the frontend. -func EstimatePath(path []treepath.PathMessage) int { - total := 0 - for i := range path { - total += EstimateTokens(path[i].Content) + 4 - } - return total -} - -// EstimatePathFromSummary returns estimated tokens for messages after -// the most recent summary boundary. Returns 0 and false if a summary -// exists but fewer than minMessages follow it. -func EstimatePathFromSummary(path []treepath.PathMessage, minMessages int) (tokens int, msgCount int, ok bool) { - startIdx := 0 - for i := range path { - if treepath.IsSummaryMessage(&path[i]) { - startIdx = i + 1 - } - } - - post := path[startIdx:] - - // Count non-system, non-summary messages - count := 0 - est := 0 - for i := range post { - if post[i].Role == "system" || treepath.IsSummaryMessage(&post[i]) { - continue - } - count++ - est += EstimateTokens(post[i].Content) + 4 - } - - if count < minMessages { - return 0, count, false - } - return est, count, true -} diff --git a/server/compaction/estimator_test.go b/server/compaction/estimator_test.go deleted file mode 100644 index 5f77017..0000000 --- a/server/compaction/estimator_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package compaction - -import ( - "testing" - - "switchboard-core/treepath" -) - -func TestEstimateTokens(t *testing.T) { - tests := []struct { - input string - want int - }{ - {"", 0}, - {"a", 1}, - {"abcd", 1}, - {"abcde", 2}, - {"Hello, world!", 4}, // 13 chars → ceil(13/4) = 4 - {string(make([]byte, 100)), 25}, - {string(make([]byte, 1000)), 250}, - } - for _, tt := range tests { - got := EstimateTokens(tt.input) - if got != tt.want { - t.Errorf("EstimateTokens(%d chars) = %d, want %d", len(tt.input), got, tt.want) - } - } -} - -func TestEstimatePath(t *testing.T) { - path := []treepath.PathMessage{ - {Content: string(make([]byte, 40))}, // 10 tokens + 4 overhead = 14 - {Content: string(make([]byte, 80))}, // 20 tokens + 4 overhead = 24 - {Content: string(make([]byte, 120))}, // 30 tokens + 4 overhead = 34 - } - got := EstimatePath(path) - // (10+4) + (20+4) + (30+4) = 72 - if got != 72 { - t.Errorf("EstimatePath = %d, want 72", got) - } -} - -func TestEstimatePathFromSummary_NoSummary(t *testing.T) { - path := make([]treepath.PathMessage, 10) - for i := range path { - path[i] = treepath.PathMessage{Role: "user", Content: string(make([]byte, 40))} - } - - tokens, count, ok := EstimatePathFromSummary(path, 8) - if !ok { - t.Fatal("expected ok=true for 10 messages >= minMessages=8") - } - if count != 10 { - t.Errorf("count = %d, want 10", count) - } - // Each: 10 tokens + 4 overhead = 14, × 10 = 140 - if tokens != 140 { - t.Errorf("tokens = %d, want 140", tokens) - } -} - -func TestEstimatePathFromSummary_TooFew(t *testing.T) { - path := make([]treepath.PathMessage, 3) - for i := range path { - path[i] = treepath.PathMessage{Role: "user", Content: "hello"} - } - - _, _, ok := EstimatePathFromSummary(path, 8) - if ok { - t.Error("expected ok=false for 3 messages < minMessages=8") - } -} - -func TestEstimatePathFromSummary_SkipsSystemMessages(t *testing.T) { - path := []treepath.PathMessage{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: string(make([]byte, 40))}, - {Role: "assistant", Content: string(make([]byte, 40))}, - } - - tokens, count, ok := EstimatePathFromSummary(path, 1) - if !ok { - t.Fatal("expected ok=true") - } - // system skipped, 2 non-system messages - if count != 2 { - t.Errorf("count = %d, want 2", count) - } - if tokens != 28 { // 2 × (10 + 4) = 28 - t.Errorf("tokens = %d, want 28", tokens) - } -} diff --git a/server/compaction/scanner.go b/server/compaction/scanner.go deleted file mode 100644 index 64a8645..0000000 --- a/server/compaction/scanner.go +++ /dev/null @@ -1,361 +0,0 @@ -package compaction - -import ( - "context" - "log" - "sync" - "time" - - "switchboard-core/models" - "switchboard-core/roles" - "switchboard-core/store" - "switchboard-core/treepath" -) - -// ── Defaults ──────────────────────────────── - -const ( - DefaultInterval = 5 * time.Minute - DefaultConcurrency = 2 - DefaultThreshold = 0.70 - DefaultCooldown = 30 * time.Minute - DefaultBudget = 128000 // 128K tokens — conservative fallback - - // Candidate query filters - candidateMinMessages = 10 - candidateMinChars = 20000 // ~5K tokens - candidateActivityGap = 2 * time.Minute - candidateMaxAge = 7 * 24 * time.Hour - candidateBatchSize = 50 - - // shouldCompact requires this many post-summary messages - minPostSummaryMessages = 8 -) - -// ── Scanner Config ────────────────────────── - -// ScannerConfig holds startup configuration for the scanner. -// Settings are re-read from global_settings each tick so changes -// take effect without restart. -type ScannerConfig struct { - Interval time.Duration - Concurrency int -} - -// ── Scanner ───────────────────────────────── - -// Scanner runs a periodic background loop that finds channels exceeding -// their context threshold and auto-compacts them via the utility model role. -// -// Follows the Ingester pattern: goroutine pool with semaphore, WaitGroup -// for graceful shutdown. -type Scanner struct { - service *Service - stores store.Stores - - sem chan struct{} - wg sync.WaitGroup - stopCh chan struct{} - - // Cooldown: channelID → last compaction time - mu sync.Mutex - lastCompacted map[string]time.Time - - // In-flight dedup - inFlight sync.Map // channelID → struct{} - - interval time.Duration -} - -// NewScanner creates a compaction scanner. Call Start() to begin scanning. -func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner { - interval := cfg.Interval - if interval <= 0 { - interval = DefaultInterval - } - concurrency := cfg.Concurrency - if concurrency <= 0 { - concurrency = DefaultConcurrency - } - - return &Scanner{ - service: svc, - stores: stores, - sem: make(chan struct{}, concurrency), - stopCh: make(chan struct{}), - lastCompacted: make(map[string]time.Time), - interval: interval, - } -} - -// Start begins the scan loop in a background goroutine. -func (sc *Scanner) Start() { - sc.wg.Add(1) - go func() { - defer sc.wg.Done() - sc.loop() - }() - log.Printf("🔍 compaction scanner started (interval=%s)", sc.interval) -} - -// Stop signals the scanner to stop and waits for in-flight compactions -// to drain. Safe to call from main's defer chain. -func (sc *Scanner) Stop() { - close(sc.stopCh) - sc.wg.Wait() - log.Printf("🔍 compaction scanner stopped") -} - -// ── Main Loop ─────────────────────────────── - -func (sc *Scanner) loop() { - ticker := time.NewTicker(sc.interval) - defer ticker.Stop() - - for { - select { - case <-sc.stopCh: - return - case <-ticker.C: - sc.tick() - } - } -} - -func (sc *Scanner) tick() { - ctx := context.Background() - - // Re-read kill switch each tick (no restart required) - if !sc.isEnabled(ctx) { - return - } - - candidates := sc.findCandidates(ctx) - if len(candidates) == 0 { - return - } - - log.Printf("🔍 compaction: scanning %d candidates", len(candidates)) - - cooldown := sc.getCooldownDuration(ctx) - - for i := range candidates { - ch := candidates[i] - - // Skip if in-flight - if _, loaded := sc.inFlight.LoadOrStore(ch.ID, struct{}{}); loaded { - continue - } - - // Skip if in cooldown - sc.mu.Lock() - if last, ok := sc.lastCompacted[ch.ID]; ok && time.Since(last) < cooldown { - sc.mu.Unlock() - sc.inFlight.Delete(ch.ID) - remaining := cooldown - time.Since(last) - log.Printf("⏭ compaction: channel %s skipped (cooldown, %s remaining)", ch.ID, remaining.Round(time.Second)) - continue - } - sc.mu.Unlock() - - // Precise check (loads full path, estimates tokens) - if !sc.shouldCompact(ctx, &ch) { - sc.inFlight.Delete(ch.ID) - continue - } - - // Rate limit check (auto-compaction is always org-funded) - if err := sc.service.CheckRateLimit(ctx, ch.UserID); err != nil { - sc.inFlight.Delete(ch.ID) - log.Printf("⏭ compaction: channel %s skipped (rate limit)", ch.ID) - continue - } - - // Dispatch compaction - sc.wg.Add(1) - go func(ch models.Channel) { - defer sc.wg.Done() - defer sc.inFlight.Delete(ch.ID) - - // Acquire semaphore - sc.sem <- struct{}{} - defer func() { <-sc.sem }() - - sc.doCompact(ch) - }(ch) - } -} - -// ── Compact One Channel ───────────────────── - -func (sc *Scanner) doCompact(ch models.Channel) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - teamID := sc.service.GetUserTeamID(ctx, ch.UserID) - - result, err := sc.service.Compact(ctx, CompactRequest{ - ChannelID: ch.ID, - UserID: ch.UserID, - TeamID: teamID, - Trigger: "auto", - }) - if err != nil { - log.Printf("⚠ compaction: channel %s failed: %v", ch.ID, err) - return - } - - // Record cooldown - sc.mu.Lock() - sc.lastCompacted[ch.ID] = time.Now() - sc.mu.Unlock() - - // Audit log - sc.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: nil, // system action - Action: "compaction.auto", - ResourceType: "channel", - ResourceID: ch.ID, - Metadata: models.JSONMap{ - "summarized_count": result.SummarizedCount, - "model": result.Model, - "trigger": "auto", - "user_id": ch.UserID, - }, - }) - - log.Printf("✅ compaction: channel %s done (%d messages → %d chars, model=%s)", - ch.ID, result.SummarizedCount, len(result.Content), result.Model) -} - -// ── Candidate Query ───────────────────────── - -func (sc *Scanner) findCandidates(ctx context.Context) []models.Channel { - activityGap := time.Now().Add(-candidateActivityGap) - maxAge := time.Now().Add(-candidateMaxAge) - - candidates, err := sc.stores.Channels.FindCompactionCandidates(ctx, - activityGap, maxAge, candidateMinMessages, candidateMinChars, candidateBatchSize) - if err != nil { - log.Printf("⚠ compaction: candidate query failed: %v", err) - return nil - } - - return candidates -} - -// ── Precise Check ─────────────────────────── - -func (sc *Scanner) shouldCompact(ctx context.Context, ch *models.Channel) bool { - // 1. Channel-level opt-out - if ch.Settings != nil { - if v, ok := ch.Settings["auto_compaction"]; ok { - if b, ok := v.(bool); ok && !b { - return false - } - } - } - - // 2. Check utility role is configured (global level — personal overrides - // aren't checked because auto-compaction is org-funded) - if !sc.service.Resolver().IsConfigured(ctx, roles.RoleUtility) { - return false - } - - // 3. Load active path - path, err := treepath.GetActivePath(ch.ID, ch.UserID) - if err != nil || len(path) < candidateMinMessages { - return false - } - - // 4. Estimate tokens from post-summary messages - tokens, msgCount, ok := EstimatePathFromSummary(path, minPostSummaryMessages) - if !ok { - return false - } - - // 5. Compare against threshold - budget := sc.getContextBudget(ctx, ch) - threshold := sc.getThreshold(ch) - ratio := float64(tokens) / float64(budget) - - if ratio >= threshold { - log.Printf("🔍 compaction: channel %s qualifies (%.0f%% of %dK context, %d messages post-summary)", - ch.ID, ratio*100, budget/1000, msgCount) - return true - } - return false -} - -// ── Settings Helpers ──────────────────────── - -func (sc *Scanner) isEnabled(ctx context.Context) bool { - val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_enabled") - if err != nil || val == nil { - return false // default: off - } - if v, ok := val["value"]; ok { - switch b := v.(type) { - case bool: - return b - case string: - return b == "true" - } - } - return false -} - -func (sc *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int { - // Try channel's model from catalog - if ch.Model != "" { - entry, err := sc.stores.Catalog.GetByModelIDAny(ctx, ch.Model) - if err == nil && entry.Capabilities.MaxContext > 0 { - return entry.Capabilities.MaxContext - } - } - return DefaultBudget -} - -func (sc *Scanner) getThreshold(ch *models.Channel) float64 { - // Channel override - if ch.Settings != nil { - if v, ok := ch.Settings["compaction_threshold"]; ok { - switch f := v.(type) { - case float64: - if f > 0 && f < 1 { - return f - } - } - } - } - // Global override - ctx := context.Background() - val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_threshold") - if err == nil && val != nil { - if v, ok := val["value"]; ok { - if f, ok := v.(float64); ok && f > 0 && f < 1 { - return f - } - } - } - return DefaultThreshold -} - -func (sc *Scanner) getCooldownDuration(ctx context.Context) time.Duration { - val, err := sc.stores.GlobalConfig.Get(ctx, "auto_compaction_cooldown_minutes") - if err == nil && val != nil { - if v, ok := val["value"]; ok { - switch n := v.(type) { - case float64: - if n > 0 { - return time.Duration(n) * time.Minute - } - case int: - if n > 0 { - return time.Duration(n) * time.Minute - } - } - } - } - return DefaultCooldown -} diff --git a/server/compaction/testmain_test.go b/server/compaction/testmain_test.go deleted file mode 100644 index 6b4f65e..0000000 --- a/server/compaction/testmain_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package compaction - -import ( - "os" - "testing" - - "switchboard-core/database" -) - -func TestMain(m *testing.M) { - teardown := database.SetupTestDB() - code := m.Run() - teardown() - os.Exit(code) -} diff --git a/server/export/chatgpt.go b/server/export/chatgpt.go deleted file mode 100644 index 9e7d288..0000000 --- a/server/export/chatgpt.go +++ /dev/null @@ -1,244 +0,0 @@ -package export - -// chatgpt.go — v0.34.0 CS4 -// -// Parses ChatGPT conversation exports (conversations.json) and converts -// them to Switchboard channel/message models. - -import ( - "encoding/json" - "fmt" - "io" - "sort" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── ChatGPT format types ───────────────────── - -// ChatGPTExport is the top-level conversations.json structure. -type ChatGPTExport []ChatGPTConversation - -// ChatGPTConversation represents one conversation in the ChatGPT export. -type ChatGPTConversation struct { - Title string `json:"title"` - CreateTime float64 `json:"create_time"` - UpdateTime float64 `json:"update_time"` - Mapping map[string]ChatGPTMappingNode `json:"mapping"` - ConversationID string `json:"conversation_id"` -} - -// ChatGPTMappingNode is a node in the conversation DAG. -type ChatGPTMappingNode struct { - ID string `json:"id"` - Parent *string `json:"parent"` - Children []string `json:"children"` - Message *ChatGPTMessage `json:"message"` -} - -// ChatGPTMessage is a message within a mapping node. -type ChatGPTMessage struct { - ID string `json:"id"` - Author ChatGPTAuthor `json:"author"` - CreateTime *float64 `json:"create_time"` - Content ChatGPTContent `json:"content"` - Metadata json.RawMessage `json:"metadata"` -} - -// ChatGPTAuthor identifies the message sender. -type ChatGPTAuthor struct { - Role string `json:"role"` -} - -// ChatGPTContent holds the message content. -type ChatGPTContent struct { - ContentType string `json:"content_type"` - Parts []interface{} `json:"parts"` -} - -// ── Parsing ────────────────────────────────── - -// ParseChatGPTExport reads and parses a conversations.json file. -func ParseChatGPTExport(r io.Reader) (ChatGPTExport, error) { - var out ChatGPTExport - if err := json.NewDecoder(r).Decode(&out); err != nil { - return nil, fmt.Errorf("chatgpt: parse error: %w", err) - } - return out, nil -} - -// ── Conversion ─────────────────────────────── - -// ChatGPTImportResult holds converted channels and messages. -type ChatGPTImportResult struct { - Channels []models.Channel - Messages []models.Message -} - -// ConvertChatGPTExport converts ChatGPT conversations to Switchboard models. -func ConvertChatGPTExport(convs ChatGPTExport, userID string) *ChatGPTImportResult { - result := &ChatGPTImportResult{} - - for _, conv := range convs { - ch, msgs := convertConversation(conv, userID) - if ch != nil { - result.Channels = append(result.Channels, *ch) - result.Messages = append(result.Messages, msgs...) - } - } - - return result -} - -func convertConversation(conv ChatGPTConversation, userID string) (*models.Channel, []models.Message) { - channelID := store.NewID() - - title := conv.Title - if title == "" { - title = "Imported Conversation" - } - - createdAt := floatToTime(conv.CreateTime) - updatedAt := floatToTime(conv.UpdateTime) - if updatedAt.IsZero() { - updatedAt = createdAt - } - - ch := &models.Channel{ - BaseModel: models.BaseModel{ - ID: channelID, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - }, - UserID: userID, - Title: title, - Type: "direct", - } - - // Walk the DAG depth-first to produce ordered messages - msgs := walkDAG(conv.Mapping, channelID, userID) - - return ch, msgs -} - -func walkDAG(mapping map[string]ChatGPTMappingNode, channelID, userID string) []models.Message { - // Find root nodes (no parent or parent not in mapping) - var roots []string - for id, node := range mapping { - if node.Parent == nil { - roots = append(roots, id) - } else if _, ok := mapping[*node.Parent]; !ok { - roots = append(roots, id) - } - } - - var msgs []models.Message - idMap := make(map[string]string) // chatgpt node ID → switchboard message ID - - var walk func(nodeID string, parentMsgID *string, siblingIdx int) - walk = func(nodeID string, parentMsgID *string, siblingIdx int) { - node, ok := mapping[nodeID] - if !ok { - return - } - - if node.Message != nil && node.Message.Content.ContentType != "" { - content := extractParts(node.Message.Content.Parts) - if content != "" { - msgID := store.NewID() - idMap[nodeID] = msgID - - role := mapRole(node.Message.Author.Role) - createdAt := time.Now().UTC() - if node.Message.CreateTime != nil { - createdAt = floatToTime(*node.Message.CreateTime) - } - - msg := models.Message{ - BaseModel: models.BaseModel{ - ID: msgID, - CreatedAt: createdAt, - UpdatedAt: createdAt, - }, - ChannelID: channelID, - Role: role, - Content: content, - ParentID: parentMsgID, - SiblingIndex: siblingIdx, - ParticipantType: "user", - ParticipantID: userID, - } - - if role == "assistant" || role == "system" || role == "tool" { - msg.ParticipantType = role - msg.ParticipantID = "" - } - - msgs = append(msgs, msg) - parentMsgID = &msgID - } - } - - // Sort children for deterministic order - children := make([]string, len(node.Children)) - copy(children, node.Children) - sort.Strings(children) - - for i, childID := range children { - walk(childID, parentMsgID, i) - } - } - - sort.Strings(roots) - for _, root := range roots { - walk(root, nil, 0) - } - - return msgs -} - -func mapRole(role string) string { - switch role { - case "user": - return "user" - case "assistant": - return "assistant" - case "system": - return "system" - case "tool": - return "tool" - default: - return "user" - } -} - -func extractParts(parts []interface{}) string { - var texts []string - for _, part := range parts { - switch v := part.(type) { - case string: - if v != "" { - texts = append(texts, v) - } - } - } - if len(texts) == 0 { - return "" - } - result := texts[0] - for i := 1; i < len(texts); i++ { - result += "\n" + texts[i] - } - return result -} - -func floatToTime(f float64) time.Time { - if f <= 0 { - return time.Time{} - } - sec := int64(f) - nsec := int64((f - float64(sec)) * 1e9) - return time.Unix(sec, nsec).UTC() -} diff --git a/server/export/entities.go b/server/export/entities.go deleted file mode 100644 index cb33dce..0000000 --- a/server/export/entities.go +++ /dev/null @@ -1,216 +0,0 @@ -package export - -// entities.go — v0.34.0 CS0 -// -// Per-entity sanitization: strips sensitive, instance-specific, or -// non-portable fields before writing to the export archive. Each -// Sanitize* function returns a clean map ready for JSON marshalling. - -import ( - "switchboard-core/models" -) - -// ── User ───────────────────────────────────── - -// SanitizeUser strips password_hash (json:"-" already), vault fields, -// and external_id. The User struct's json tags already exclude -// password_hash, so standard marshalling is safe. We nil out a few -// more fields for cross-instance portability. -func SanitizeUser(u *models.User) *models.User { - out := *u // shallow copy - out.PasswordHash = "" - out.ExternalID = nil - return &out -} - -// ── Channel ────────────────────────────────── - -// ExportChannel is a Channel without instance-specific provider binding. -type ExportChannel struct { - models.Channel - ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil in export -} - -// SanitizeChannel strips provider_config_id. -func SanitizeChannel(ch *models.Channel) ExportChannel { - return ExportChannel{ - Channel: *ch, - ProviderConfigID: nil, - } -} - -// SanitizeChannels batch-sanitizes a slice. -func SanitizeChannels(chs []models.Channel) []ExportChannel { - out := make([]ExportChannel, len(chs)) - for i := range chs { - chs[i].ProviderConfigID = nil - out[i] = SanitizeChannel(&chs[i]) - } - return out -} - -// ── Message ────────────────────────────────── - -// ExportMessage is a Message without provider_config_id. -type ExportMessage struct { - models.Message - ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil -} - -// SanitizeMessages strips provider_config_id from each message. -func SanitizeMessages(msgs []models.Message) []ExportMessage { - out := make([]ExportMessage, len(msgs)) - for i := range msgs { - msgs[i].ProviderConfigID = nil - out[i] = ExportMessage{Message: msgs[i]} - } - return out -} - -// ── Channel Model ──────────────────────────── - -// ExportChannelModel strips provider_config_id and persona_id. -type ExportChannelModel struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - ModelID string `json:"model_id"` - Handle string `json:"handle,omitempty"` - DisplayName string `json:"display_name,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - IsDefault bool `json:"is_default"` -} - -// SanitizeChannelModels strips instance-specific refs. -func SanitizeChannelModels(cms []models.ChannelModel) []ExportChannelModel { - out := make([]ExportChannelModel, len(cms)) - for i, cm := range cms { - out[i] = ExportChannelModel{ - ID: cm.ID, - ChannelID: cm.ChannelID, - ModelID: cm.ModelID, - Handle: cm.Handle, - DisplayName: cm.DisplayName, - SystemPrompt: cm.SystemPrompt, - IsDefault: cm.IsDefault, - } - } - return out -} - -// ── User Model Settings ────────────────────── - -// ExportUserModelSetting strips provider_config_id. -type ExportUserModelSetting struct { - models.UserModelSetting - ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil -} - -// SanitizeUserModelSettings strips instance-specific provider binding. -func SanitizeUserModelSettings(ums []models.UserModelSetting) []ExportUserModelSetting { - out := make([]ExportUserModelSetting, len(ums)) - for i := range ums { - ums[i].ProviderConfigID = nil - out[i] = ExportUserModelSetting{UserModelSetting: ums[i]} - } - return out -} - -// ── Usage Entry ────────────────────────────── - -// ExportUsageEntry strips provider_config_id and routing_decision. -type ExportUsageEntry struct { - ID string `json:"id"` - ChannelID *string `json:"channel_id,omitempty"` - UserID string `json:"user_id"` - ModelID string `json:"model_id"` - Role *string `json:"role,omitempty"` - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - CacheCreationTokens int `json:"cache_creation_tokens"` - CacheReadTokens int `json:"cache_read_tokens"` - CostInput *float64 `json:"cost_input,omitempty"` - CostOutput *float64 `json:"cost_output,omitempty"` - CreatedAt string `json:"created_at"` -} - -// SanitizeUsageEntries strips instance-specific fields. -func SanitizeUsageEntries(ues []models.UsageEntry) []ExportUsageEntry { - out := make([]ExportUsageEntry, len(ues)) - for i, ue := range ues { - out[i] = ExportUsageEntry{ - ID: ue.ID, - ChannelID: ue.ChannelID, - UserID: ue.UserID, - ModelID: ue.ModelID, - Role: ue.Role, - PromptTokens: ue.PromptTokens, - CompletionTokens: ue.CompletionTokens, - CacheCreationTokens: ue.CacheCreationTokens, - CacheReadTokens: ue.CacheReadTokens, - CostInput: ue.CostInput, - CostOutput: ue.CostOutput, - CreatedAt: ue.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - } - } - return out -} - -// ── Workspace ──────────────────────────────── - -// ExportWorkspace strips root_path (json:"-" already) and git credentials. -type ExportWorkspace struct { - models.Workspace - GitCredentialID *string `json:"git_credential_id,omitempty"` // always nil -} - -// SanitizeWorkspaces strips git credential refs. -func SanitizeWorkspaces(ws []models.Workspace) []ExportWorkspace { - out := make([]ExportWorkspace, len(ws)) - for i := range ws { - ws[i].GitCredentialID = nil - ws[i].GitLastSync = nil - out[i] = ExportWorkspace{Workspace: ws[i]} - } - return out -} - -// ── Persona ────────────────────────────────── - -// ExportPersona strips provider_config_id. -type ExportPersona struct { - models.Persona - ProviderConfigID *string `json:"provider_config_id,omitempty"` // always nil -} - -// SanitizePersonas strips instance-specific provider binding. -func SanitizePersonas(ps []models.Persona) []ExportPersona { - out := make([]ExportPersona, len(ps)) - for i := range ps { - ps[i].ProviderConfigID = nil - out[i] = ExportPersona{Persona: ps[i]} - } - return out -} - -// ── Workflow ───────────────────────────────── - -// SanitizeWorkflows strips webhook secrets. -func SanitizeWorkflows(wfs []models.Workflow) []models.Workflow { - out := make([]models.Workflow, len(wfs)) - for i := range wfs { - out[i] = wfs[i] - out[i].WebhookSecret = "" - } - return out -} - -// Note links use models.ExportNoteLink (includes source_note_id). -// See models/models.go for the type definition. - -// Note, Memory, Folder, NotificationPreference, -// ChannelParticipant, ChannelCursor, Project, ProjectChannel, -// ProjectKB, ProjectNote, WorkspaceFile, PersonaGroup, -// PersonaGroupMember, KnowledgeBase, KBDocument, Group, -// GroupMember, ResourceGrant, Team, TeamMember, WorkflowVersion, -// WorkflowStage — these are exported as-is (no sensitive fields -// after query-level exclusion of embeddings). diff --git a/server/export/format.go b/server/export/format.go deleted file mode 100644 index 431b3ce..0000000 --- a/server/export/format.go +++ /dev/null @@ -1,211 +0,0 @@ -package export - -// format.go — v0.34.0 CS0 -// -// Defines the .switchboard archive format: a zip file containing a -// manifest.json and per-entity JSON files under data/. File blobs -// live under files/{id}/{filename}. - -import ( - "archive/zip" - "encoding/json" - "fmt" - "io" - "time" -) - -// ── Constants ──────────────────────────────── - -const ( - FormatVersion = 1 - MaxExportArchiveSize = 500 * 1024 * 1024 // 500 MB total - MaxExportFileSize = 100 * 1024 * 1024 // 100 MB per file blob - MaxExportFiles = 10000 - ExportContentType = "application/zip" - ExportExtension = ".switchboard" -) - -// ── Manifest ───────────────────────────────── - -// Manifest is the top-level metadata written to manifest.json. -type Manifest struct { - Version string `json:"version"` // server version, e.g. "0.34.0" - FormatVersion int `json:"format_version"` // always 1 for now - ExportType string `json:"export_type"` // "user", "team" - CreatedAt time.Time `json:"created_at"` - ExportedBy string `json:"exported_by"` // user ID - EntityCounts map[string]int `json:"entity_counts"` - Scope *ManifestScope `json:"scope,omitempty"` -} - -// ManifestScope narrows the export to a user or team. -type ManifestScope struct { - UserID string `json:"user_id,omitempty"` - TeamID string `json:"team_id,omitempty"` -} - -// ── ArchiveWriter ──────────────────────────── - -// ArchiveWriter wraps zip.Writer with size tracking and convenience -// methods for writing manifest and entity JSON files. -type ArchiveWriter struct { - zw *zip.Writer - written int64 - maxBytes int64 -} - -// NewArchiveWriter creates an ArchiveWriter that streams to w. -func NewArchiveWriter(w io.Writer) *ArchiveWriter { - return &ArchiveWriter{ - zw: zip.NewWriter(w), - maxBytes: MaxExportArchiveSize, - } -} - -// WriteManifest serializes m as indented JSON into manifest.json. -func (aw *ArchiveWriter) WriteManifest(m *Manifest) error { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return fmt.Errorf("export: marshal manifest: %w", err) - } - return aw.writeEntry("manifest.json", data) -} - -// WriteEntityJSON serializes data as indented JSON into data/{name}.json. -func (aw *ArchiveWriter) WriteEntityJSON(name string, data interface{}) (int, error) { - buf, err := json.MarshalIndent(data, "", " ") - if err != nil { - return 0, fmt.Errorf("export: marshal %s: %w", name, err) - } - if err := aw.writeEntry("data/"+name+".json", buf); err != nil { - return 0, err - } - // Return entity count for manifest - switch v := data.(type) { - case []interface{}: - return len(v), nil - default: - // Use json.RawMessage length heuristic — caller tracks count - return 0, nil - } -} - -// WriteFile streams a file blob into files/{zipPath}. Returns bytes -// written or an error if the file exceeds MaxExportFileSize. -func (aw *ArchiveWriter) WriteFile(zipPath string, r io.Reader) (int64, error) { - if aw.written+1 > aw.maxBytes { - return 0, fmt.Errorf("export: archive size limit exceeded") - } - f, err := aw.zw.Create("files/" + zipPath) - if err != nil { - return 0, fmt.Errorf("export: create zip entry %s: %w", zipPath, err) - } - lr := io.LimitReader(r, MaxExportFileSize+1) - n, err := io.Copy(f, lr) - if err != nil { - return n, fmt.Errorf("export: write file %s: %w", zipPath, err) - } - if n > MaxExportFileSize { - return n, fmt.Errorf("export: file %s exceeds %d bytes", zipPath, MaxExportFileSize) - } - aw.written += n - return n, nil -} - -// Close finalizes the zip archive. -func (aw *ArchiveWriter) Close() error { - return aw.zw.Close() -} - -// BytesWritten returns the approximate bytes written so far. -func (aw *ArchiveWriter) BytesWritten() int64 { - return aw.written -} - -// ── ArchiveReader ──────────────────────────── - -// ArchiveReader wraps zip.ReadCloser with validation helpers. -type ArchiveReader struct { - zr *zip.ReadCloser -} - -// OpenArchive opens and validates a .switchboard zip archive. -func OpenArchive(path string) (*ArchiveReader, error) { - zr, err := zip.OpenReader(path) - if err != nil { - return nil, fmt.Errorf("export: open archive: %w", err) - } - return &ArchiveReader{zr: zr}, nil -} - -// ReadManifest reads and validates manifest.json from the archive. -func (ar *ArchiveReader) ReadManifest() (*Manifest, error) { - for _, f := range ar.zr.File { - if f.Name == "manifest.json" { - rc, err := f.Open() - if err != nil { - return nil, fmt.Errorf("export: open manifest: %w", err) - } - defer rc.Close() - var m Manifest - if err := json.NewDecoder(rc).Decode(&m); err != nil { - return nil, fmt.Errorf("export: decode manifest: %w", err) - } - if m.FormatVersion > FormatVersion { - return nil, fmt.Errorf("export: unsupported format version %d (max %d)", m.FormatVersion, FormatVersion) - } - return &m, nil - } - } - return nil, fmt.Errorf("export: manifest.json not found in archive") -} - -// ReadEntityJSON reads data/{name}.json into target. -func (ar *ArchiveReader) ReadEntityJSON(name string, target interface{}) error { - path := "data/" + name + ".json" - for _, f := range ar.zr.File { - if f.Name == path { - rc, err := f.Open() - if err != nil { - return fmt.Errorf("export: open %s: %w", path, err) - } - defer rc.Close() - if err := json.NewDecoder(rc).Decode(target); err != nil { - return fmt.Errorf("export: decode %s: %w", path, err) - } - return nil - } - } - // Entity file not present — not an error (sparse archives are valid) - return nil -} - -// FileEntries returns zip entries under files/. -func (ar *ArchiveReader) FileEntries() []*zip.File { - var out []*zip.File - for _, f := range ar.zr.File { - if len(f.Name) > 6 && f.Name[:6] == "files/" && !f.FileInfo().IsDir() { - out = append(out, f) - } - } - return out -} - -// Close closes the underlying zip reader. -func (ar *ArchiveReader) Close() error { - return ar.zr.Close() -} - -// writeEntry writes raw bytes as a zip entry, tracking total size. -func (aw *ArchiveWriter) writeEntry(name string, data []byte) error { - if aw.written+int64(len(data)) > aw.maxBytes { - return fmt.Errorf("export: archive size limit exceeded writing %s", name) - } - f, err := aw.zw.Create(name) - if err != nil { - return fmt.Errorf("export: create entry %s: %w", name, err) - } - n, err := f.Write(data) - aw.written += int64(n) - return err -} diff --git a/server/extraction/queue.go b/server/extraction/queue.go deleted file mode 100644 index 82ca59f..0000000 --- a/server/extraction/queue.go +++ /dev/null @@ -1,294 +0,0 @@ -package extraction - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "sync" - "time" -) - -// ── Status Constants ─────────────────────── - -const ( - StatusPending = "pending" - StatusProcessing = "processing" - StatusComplete = "complete" - StatusFailed = "failed" - StatusSkipped = "skipped" // not extractable (images, etc.) -) - -// ── Queue Item ───────────────────────────── - -// QueueItem is the status.json written to the processing directory. -// The sidecar extractor watches this directory for pending items. -type QueueItem struct { - FileID string `json:"file_id"` - StorageKey string `json:"storage_key"` - ContentType string `json:"content_type"` - Filename string `json:"filename"` - Status string `json:"status"` - Error string `json:"error,omitempty"` - OutputKey string `json:"output_key,omitempty"` // path to extracted text - QueuedAt string `json:"queued_at"` - StartedAt string `json:"started_at,omitempty"` - CompletedAt string `json:"completed_at,omitempty"` -} - -// ── Queue Manager ────────────────────────── - -// Queue manages the filesystem-based extraction queue. -// Processing state lives in {storagePath}/processing/{file_id}/status.json. -// The sidecar extractor watches for pending items and updates status. -type Queue struct { - storagePath string - concurrency int - sem chan struct{} // semaphore for concurrent extraction limit - mu sync.Mutex -} - -// NewQueue creates a new extraction queue manager. -// storagePath is the PVC mount (e.g., /data/storage). -// concurrency limits parallel extractions (default 3). -func NewQueue(storagePath string, concurrency int) (*Queue, error) { - if concurrency <= 0 { - concurrency = 3 - } - - procDir := filepath.Join(storagePath, "processing") - if err := os.MkdirAll(procDir, 0750); err != nil { - return nil, fmt.Errorf("extraction: create processing dir: %w", err) - } - - return &Queue{ - storagePath: storagePath, - concurrency: concurrency, - sem: make(chan struct{}, concurrency), - }, nil -} - -// Enqueue adds a file to the extraction queue. -// Creates {storagePath}/processing/{id}/status.json with status "pending". -func (q *Queue) Enqueue(fileID, storageKey, contentType, filename string) error { - q.mu.Lock() - defer q.mu.Unlock() - - itemDir := filepath.Join(q.storagePath, "processing", fileID) - if err := os.MkdirAll(itemDir, 0750); err != nil { - return fmt.Errorf("extraction: create item dir: %w", err) - } - - item := QueueItem{ - FileID: fileID, - StorageKey: storageKey, - ContentType: contentType, - Filename: filename, - Status: StatusPending, - QueuedAt: time.Now().UTC().Format(time.RFC3339), - } - - return q.writeStatus(fileID, &item) -} - -// GetStatus reads the current extraction status for a file. -func (q *Queue) GetStatus(fileID string) (*QueueItem, error) { - statusPath := filepath.Join(q.storagePath, "processing", fileID, "status.json") - data, err := os.ReadFile(statusPath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil // not queued - } - return nil, fmt.Errorf("extraction: read status: %w", err) - } - - var item QueueItem - if err := json.Unmarshal(data, &item); err != nil { - return nil, fmt.Errorf("extraction: parse status: %w", err) - } - return &item, nil -} - -// MarkComplete updates status to complete and records the output key. -func (q *Queue) MarkComplete(fileID, outputKey string) error { - q.mu.Lock() - defer q.mu.Unlock() - - item, err := q.GetStatus(fileID) - if err != nil || item == nil { - return fmt.Errorf("extraction: item not found: %s", fileID) - } - - item.Status = StatusComplete - item.OutputKey = outputKey - item.CompletedAt = time.Now().UTC().Format(time.RFC3339) - return q.writeStatus(fileID, item) -} - -// MarkFailed updates status to failed with an error message. -func (q *Queue) MarkFailed(fileID, errMsg string) error { - q.mu.Lock() - defer q.mu.Unlock() - - item, err := q.GetStatus(fileID) - if err != nil || item == nil { - return fmt.Errorf("extraction: item not found: %s", fileID) - } - - item.Status = StatusFailed - item.Error = errMsg - item.CompletedAt = time.Now().UTC().Format(time.RFC3339) - return q.writeStatus(fileID, item) -} - -// Cleanup removes the processing directory for a completed/failed item. -func (q *Queue) Cleanup(fileID string) error { - itemDir := filepath.Join(q.storagePath, "processing", fileID) - return os.RemoveAll(itemDir) -} - -// RecoverStale scans for items stuck in "processing" state (crash recovery). -// Items older than maxAge are reset to "pending" for re-processing. -func (q *Queue) RecoverStale(maxAge time.Duration) (int, error) { - procDir := filepath.Join(q.storagePath, "processing") - entries, err := os.ReadDir(procDir) - if err != nil { - if os.IsNotExist(err) { - return 0, nil - } - return 0, err - } - - recovered := 0 - cutoff := time.Now().Add(-maxAge) - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - item, err := q.GetStatus(entry.Name()) - if err != nil || item == nil { - continue - } - - if item.Status != StatusProcessing { - continue - } - - // Check if started_at is older than maxAge - if item.StartedAt != "" { - startedAt, err := time.Parse(time.RFC3339, item.StartedAt) - if err == nil && startedAt.Before(cutoff) { - q.mu.Lock() - item.Status = StatusPending - item.StartedAt = "" - item.Error = "recovered from stale processing state" - q.writeStatus(entry.Name(), item) - q.mu.Unlock() - recovered++ - log.Printf("extraction: recovered stale item %s", entry.Name()) - } - } - } - - return recovered, nil -} - -// ListPending returns all items with status "pending". -func (q *Queue) ListPending() ([]QueueItem, error) { - return q.listByStatus(StatusPending) -} - -// ListAll returns all items in the processing directory. -func (q *Queue) ListAll() ([]QueueItem, error) { - procDir := filepath.Join(q.storagePath, "processing") - entries, err := os.ReadDir(procDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var items []QueueItem - for _, entry := range entries { - if !entry.IsDir() { - continue - } - item, err := q.GetStatus(entry.Name()) - if err != nil || item == nil { - continue - } - items = append(items, *item) - } - return items, nil -} - -// ── Internal Helpers ─────────────────────── - -func (q *Queue) writeStatus(fileID string, item *QueueItem) error { - statusPath := filepath.Join(q.storagePath, "processing", fileID, "status.json") - data, err := json.MarshalIndent(item, "", " ") - if err != nil { - return err - } - - // Atomic write: temp + rename - tmpPath := statusPath + ".tmp" - if err := os.WriteFile(tmpPath, data, 0640); err != nil { - return err - } - return os.Rename(tmpPath, statusPath) -} - -func (q *Queue) listByStatus(status string) ([]QueueItem, error) { - all, err := q.ListAll() - if err != nil { - return nil, err - } - - var filtered []QueueItem - for _, item := range all { - if item.Status == status { - filtered = append(filtered, item) - } - } - return filtered, nil -} - -// ── Extractable Check ────────────────────── - -// IsExtractable returns true if the content type supports text extraction. -// Images and plain text do not need extraction (handled inline by upload handler). -var extractableTypes = map[string]bool{ - "application/pdf": true, - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, - "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, - "application/msword": true, - "application/vnd.ms-excel": true, - "application/vnd.oasis.opendocument.text": true, - "application/vnd.oasis.opendocument.spreadsheet": true, - "application/vnd.oasis.opendocument.presentation": true, - "application/rtf": true, -} - -// IsExtractable returns true if the given content type requires sidecar extraction. -func IsExtractable(contentType string) bool { - return extractableTypes[contentType] -} - -// ── Null Queue ───────────────────────────── - -// NullQueue is a no-op implementation for when extraction is disabled. -type NullQueue struct{} - -func (NullQueue) Enqueue(_, _, _, _ string) error { return nil } -func (NullQueue) GetStatus(_ string) (*QueueItem, error) { return nil, nil } -func (NullQueue) MarkComplete(_, _ string) error { return nil } -func (NullQueue) MarkFailed(_, _ string) error { return nil } -func (NullQueue) Cleanup(_ string) error { return nil } -func (NullQueue) RecoverStale(_ time.Duration) (int, error) { return 0, nil } -func (NullQueue) ListPending() ([]QueueItem, error) { return nil, nil } -func (NullQueue) ListAll() ([]QueueItem, error) { return nil, nil } \ No newline at end of file diff --git a/server/extraction/queue_test.go b/server/extraction/queue_test.go deleted file mode 100644 index cea394e..0000000 --- a/server/extraction/queue_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package extraction - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func tempQueue(t *testing.T) *Queue { - t.Helper() - dir := t.TempDir() - q, err := NewQueue(dir, 3) - if err != nil { - t.Fatalf("NewQueue: %v", err) - } - return q -} - -func TestEnqueue_CreatesStatusFile(t *testing.T) { - q := tempQueue(t) - - err := q.Enqueue("att-123", "files/ch/att-123_doc.pdf", "application/pdf", "doc.pdf") - if err != nil { - t.Fatalf("Enqueue: %v", err) - } - - item, err := q.GetStatus("att-123") - if err != nil { - t.Fatalf("GetStatus: %v", err) - } - if item == nil { - t.Fatal("expected non-nil item") - } - if item.Status != StatusPending { - t.Errorf("status = %q, want %q", item.Status, StatusPending) - } - if item.FileID != "att-123" { - t.Errorf("file_id = %q, want att-123", item.FileID) - } - if item.ContentType != "application/pdf" { - t.Errorf("content_type = %q, want application/pdf", item.ContentType) - } -} - -func TestGetStatus_NotQueued(t *testing.T) { - q := tempQueue(t) - - item, err := q.GetStatus("nonexistent") - if err != nil { - t.Fatalf("GetStatus: %v", err) - } - if item != nil { - t.Error("expected nil for non-queued item") - } -} - -func TestMarkComplete(t *testing.T) { - q := tempQueue(t) - q.Enqueue("att-456", "key", "application/pdf", "test.pdf") - - err := q.MarkComplete("att-456", "processing/att-456/extracted.txt") - if err != nil { - t.Fatalf("MarkComplete: %v", err) - } - - item, _ := q.GetStatus("att-456") - if item.Status != StatusComplete { - t.Errorf("status = %q, want %q", item.Status, StatusComplete) - } - if item.OutputKey != "processing/att-456/extracted.txt" { - t.Errorf("output_key = %q", item.OutputKey) - } - if item.CompletedAt == "" { - t.Error("completed_at should be set") - } -} - -func TestMarkFailed(t *testing.T) { - q := tempQueue(t) - q.Enqueue("att-789", "key", "application/pdf", "test.pdf") - - err := q.MarkFailed("att-789", "libreoffice crashed") - if err != nil { - t.Fatalf("MarkFailed: %v", err) - } - - item, _ := q.GetStatus("att-789") - if item.Status != StatusFailed { - t.Errorf("status = %q, want %q", item.Status, StatusFailed) - } - if item.Error != "libreoffice crashed" { - t.Errorf("error = %q", item.Error) - } -} - -func TestCleanup_RemovesDirectory(t *testing.T) { - q := tempQueue(t) - q.Enqueue("att-cleanup", "key", "application/pdf", "test.pdf") - - // Verify directory exists - dir := filepath.Join(q.storagePath, "processing", "att-cleanup") - if _, err := os.Stat(dir); err != nil { - t.Fatal("expected processing dir to exist before cleanup") - } - - if err := q.Cleanup("att-cleanup"); err != nil { - t.Fatalf("Cleanup: %v", err) - } - - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Error("expected processing dir to be removed after cleanup") - } -} - -func TestListPending(t *testing.T) { - q := tempQueue(t) - q.Enqueue("att-a", "key-a", "application/pdf", "a.pdf") - q.Enqueue("att-b", "key-b", "application/pdf", "b.pdf") - q.MarkComplete("att-b", "out") - - pending, err := q.ListPending() - if err != nil { - t.Fatalf("ListPending: %v", err) - } - if len(pending) != 1 { - t.Fatalf("expected 1 pending, got %d", len(pending)) - } - if pending[0].FileID != "att-a" { - t.Errorf("pending[0].FileID = %q, want att-a", pending[0].FileID) - } -} - -func TestListAll(t *testing.T) { - q := tempQueue(t) - q.Enqueue("att-1", "key-1", "application/pdf", "1.pdf") - q.Enqueue("att-2", "key-2", "application/pdf", "2.pdf") - q.Enqueue("att-3", "key-3", "application/pdf", "3.pdf") - - all, err := q.ListAll() - if err != nil { - t.Fatalf("ListAll: %v", err) - } - if len(all) != 3 { - t.Errorf("expected 3 items, got %d", len(all)) - } -} - -func TestRecoverStale(t *testing.T) { - q := tempQueue(t) - - // Create item and manually set it to "processing" with old timestamp - q.Enqueue("att-stale", "key", "application/pdf", "stale.pdf") - item, _ := q.GetStatus("att-stale") - item.Status = StatusProcessing - item.StartedAt = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339) - q.mu.Lock() - q.writeStatus("att-stale", item) - q.mu.Unlock() - - // Recover items stale for > 1 hour - recovered, err := q.RecoverStale(1 * time.Hour) - if err != nil { - t.Fatalf("RecoverStale: %v", err) - } - if recovered != 1 { - t.Errorf("recovered = %d, want 1", recovered) - } - - // Verify it's back to pending - item, _ = q.GetStatus("att-stale") - if item.Status != StatusPending { - t.Errorf("status = %q, want %q", item.Status, StatusPending) - } -} - -func TestRecoverStale_SkipsRecent(t *testing.T) { - q := tempQueue(t) - - // Create item "processing" but recent (not stale) - q.Enqueue("att-recent", "key", "application/pdf", "recent.pdf") - item, _ := q.GetStatus("att-recent") - item.Status = StatusProcessing - item.StartedAt = time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339) - q.mu.Lock() - q.writeStatus("att-recent", item) - q.mu.Unlock() - - recovered, err := q.RecoverStale(1 * time.Hour) - if err != nil { - t.Fatalf("RecoverStale: %v", err) - } - if recovered != 0 { - t.Errorf("recovered = %d, want 0 (item is recent)", recovered) - } -} - -func TestIsExtractable(t *testing.T) { - tests := []struct { - contentType string - want bool - }{ - {"application/pdf", true}, - {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", true}, - {"image/png", false}, - {"text/plain", false}, - {"image/jpeg", false}, - {"application/rtf", true}, - } - - for _, tc := range tests { - got := IsExtractable(tc.contentType) - if got != tc.want { - t.Errorf("IsExtractable(%q) = %v, want %v", tc.contentType, got, tc.want) - } - } -} - -func TestAtomicWrite(t *testing.T) { - q := tempQueue(t) - - // Enqueue and verify the file is valid JSON - q.Enqueue("att-atomic", "key", "application/pdf", "test.pdf") - - statusPath := filepath.Join(q.storagePath, "processing", "att-atomic", "status.json") - data, err := os.ReadFile(statusPath) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - - // Verify no .tmp file left behind - tmpPath := statusPath + ".tmp" - if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { - t.Error("temp file should not exist after atomic write") - } - - if len(data) == 0 { - t.Error("status.json should not be empty") - } -} diff --git a/server/filters/discover.go b/server/filters/discover.go deleted file mode 100644 index 6a28042..0000000 --- a/server/filters/discover.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package filters — discover.go -// -// v0.29.0 CS3: Discovers active Starlark packages with -// filters.pre_completion permission and registers them in the chain. -// Called at startup and after package install/permission changes. -package filters - -import ( - "context" - "log" - - "switchboard-core/models" - "switchboard-core/sandbox" - "switchboard-core/store" -) - -// DiscoverStarlarkFilters scans the package registry for active starlark -// extensions with granted filters.pre_completion permission and registers -// them in the filter chain. Extension filters start at order 100. -// -// Called once at startup. Future: also called on package install/activate -// to hot-register new filters without restart. -func DiscoverStarlarkFilters(ctx context.Context, chain *Chain, stores store.Stores, runner *sandbox.Runner) { - if stores.ExtPermissions == nil || stores.Packages == nil { - return - } - - pkgs, err := stores.Packages.ListEnabledByType(ctx, "extension") - if err != nil { - log.Printf("⚠ filter discovery: failed to list extensions: %v", err) - return - } - - // Also check "full" packages (surface + extension) - fullPkgs, err := stores.Packages.ListEnabledByType(ctx, "full") - if err == nil { - pkgs = append(pkgs, fullPkgs...) - } - - registered := 0 - for i := range pkgs { - pkg := &pkgs[i] - - // Must be starlark tier and active - if pkg.Tier != models.ExtTierStarlark { - continue - } - if pkg.Status != models.PackageStatusActive { - continue - } - - // Must have the pre_completion filter permission granted - granted, err := stores.ExtPermissions.GrantedForPackage(ctx, pkg.ID) - if err != nil { - continue - } - - hasFilterPerm := false - for _, p := range granted { - if p == models.ExtPermFiltersPreCompletion { - hasFilterPerm = true - break - } - } - if !hasFilterPerm { - continue - } - - // Must have a starlark script - if _, ok := pkg.Manifest["_starlark_script"].(string); !ok { - continue - } - - // Register with order 100+ (extension range, after built-ins) - order := 100 + registered - chain.Register(NewStarlarkFilter(runner, pkg, order)) - registered++ - } - - if registered > 0 { - log.Printf(" 🔗 Discovered %d Starlark pre-completion filter(s)", registered) - } -} diff --git a/server/filters/filters.go b/server/filters/filters.go deleted file mode 100644 index 6880048..0000000 --- a/server/filters/filters.go +++ /dev/null @@ -1,134 +0,0 @@ -// Package filters — pre-completion filter chain. -// -// v0.29.0: Reference implementation for the server-side filter model. -// Filters run before every completion request, injecting context -// (system messages, knowledge base results, external data) into the -// conversation. The chain is composable: Go built-in filters register -// at startup; Starlark extension filters register at package install. -// -// Architecture: -// -// user message → [filter chain] → LLM -// │ -// ├─ KB auto-inject (built-in, CS0) -// ├─ memory hint (future migration) -// └─ ext filters (Starlark, CS3) -// -// Each filter receives a CompletionContext and returns zero or more -// system messages to inject. Filters are executed in registration -// order. A filter failure is logged and skipped — it never aborts -// the completion. -package filters - -import ( - "context" - "log" - "sort" - "time" -) - -// ─── Context ───────────────────────────────── - -// CompletionContext carries the state available to pre-completion filters. -// Filters should treat this as read-only. -type CompletionContext struct { - ChannelID string - UserID string - PersonaID string - LastUserMessage string // current user message content (for semantic search) - TeamIDs []string -} - -// ─── Contribution ──────────────────────────── - -// InjectedMessage is a role + content pair that a filter wants to inject -// into the conversation. Kept simple to avoid coupling to provider types. -type InjectedMessage struct { - Role string // "system" (most common), "user", or "assistant" - Content string -} - -// Contribution holds what a filter wants to inject into the completion. -type Contribution struct { - // Messages are injected into the conversation in order, after the - // persona/project system prompts and before the message history. - Messages []InjectedMessage -} - -// ─── Filter Interface ──────────────────────── - -// PreCompletionFilter processes context before a completion request. -// Implementations must be safe for concurrent use. -type PreCompletionFilter interface { - // Name returns a human-readable identifier (e.g., "kb-auto-inject"). - // Used in logs and admin UI. - Name() string - - // Order returns the execution priority. Lower values run first. - // Built-in filters use 0–99; extension filters use 100+. - Order() int - - // Execute runs the filter and returns messages to inject. - // Returning nil or an empty Contribution is valid (no injection). - // Errors are logged and skipped — they never abort the completion. - Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) -} - -// ─── Chain ─────────────────────────────────── - -// Chain holds an ordered set of pre-completion filters. -type Chain struct { - filters []PreCompletionFilter - sorted bool -} - -// NewChain creates an empty filter chain. -func NewChain() *Chain { - return &Chain{} -} - -// Register adds a filter to the chain. Filters are sorted by Order() -// before the first execution. -func (c *Chain) Register(f PreCompletionFilter) { - c.filters = append(c.filters, f) - c.sorted = false - log.Printf(" 🔗 Pre-completion filter registered: %s (order=%d)", f.Name(), f.Order()) -} - -// Len returns the number of registered filters. -func (c *Chain) Len() int { - return len(c.filters) -} - -// Execute runs all filters in order, collecting their contributions. -// Returns the combined list of messages to inject. Individual filter -// errors are logged and skipped. -func (c *Chain) Execute(ctx context.Context, cc *CompletionContext) []InjectedMessage { - if len(c.filters) == 0 { - return nil - } - - if !c.sorted { - sort.Slice(c.filters, func(i, j int) bool { - return c.filters[i].Order() < c.filters[j].Order() - }) - c.sorted = true - } - - var messages []InjectedMessage - for _, f := range c.filters { - start := time.Now() - contrib, err := f.Execute(ctx, cc) - elapsed := time.Since(start) - - if err != nil { - log.Printf("⚠ filter %s failed (%.0fms): %v", f.Name(), elapsed.Seconds()*1000, err) - continue - } - if contrib != nil && len(contrib.Messages) > 0 { - messages = append(messages, contrib.Messages...) - log.Printf(" 🔗 filter %s: injected %d messages (%.0fms)", f.Name(), len(contrib.Messages), elapsed.Seconds()*1000) - } - } - return messages -} diff --git a/server/filters/filters_test.go b/server/filters/filters_test.go deleted file mode 100644 index f44bb28..0000000 --- a/server/filters/filters_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package filters - -import ( - "context" - "errors" - "testing" -) - -// ── Mock filter ───────────────────────────── - -type mockFilter struct { - name string - order int - messages []InjectedMessage - err error -} - -func (m *mockFilter) Name() string { return m.name } -func (m *mockFilter) Order() int { return m.order } -func (m *mockFilter) Execute(_ context.Context, _ *CompletionContext) (*Contribution, error) { - if m.err != nil { - return nil, m.err - } - if len(m.messages) == 0 { - return nil, nil - } - return &Contribution{Messages: m.messages}, nil -} - -// ── Tests ─────────────────────────────────── - -func TestChainEmpty(t *testing.T) { - c := NewChain() - result := c.Execute(context.Background(), &CompletionContext{}) - if len(result) != 0 { - t.Fatalf("expected 0 messages, got %d", len(result)) - } -} - -func TestChainSingleFilter(t *testing.T) { - c := NewChain() - c.Register(&mockFilter{ - name: "test", - order: 0, - messages: []InjectedMessage{ - {Role: "system", Content: "hello"}, - }, - }) - - result := c.Execute(context.Background(), &CompletionContext{ - ChannelID: "ch-1", - UserID: "u-1", - }) - - if len(result) != 1 { - t.Fatalf("expected 1 message, got %d", len(result)) - } - if result[0].Content != "hello" { - t.Fatalf("expected 'hello', got %q", result[0].Content) - } -} - -func TestChainOrderRespected(t *testing.T) { - c := NewChain() - // Register out of order — chain should sort by Order() - c.Register(&mockFilter{ - name: "second", - order: 20, - messages: []InjectedMessage{{Role: "system", Content: "B"}}, - }) - c.Register(&mockFilter{ - name: "first", - order: 10, - messages: []InjectedMessage{{Role: "system", Content: "A"}}, - }) - - result := c.Execute(context.Background(), &CompletionContext{}) - - if len(result) != 2 { - t.Fatalf("expected 2 messages, got %d", len(result)) - } - if result[0].Content != "A" { - t.Fatalf("expected 'A' first, got %q", result[0].Content) - } - if result[1].Content != "B" { - t.Fatalf("expected 'B' second, got %q", result[1].Content) - } -} - -func TestChainErrorIsolation(t *testing.T) { - c := NewChain() - c.Register(&mockFilter{ - name: "failing", - order: 0, - err: errors.New("boom"), - }) - c.Register(&mockFilter{ - name: "surviving", - order: 10, - messages: []InjectedMessage{{Role: "system", Content: "ok"}}, - }) - - result := c.Execute(context.Background(), &CompletionContext{}) - - if len(result) != 1 { - t.Fatalf("expected 1 message (failing filter skipped), got %d", len(result)) - } - if result[0].Content != "ok" { - t.Fatalf("expected 'ok', got %q", result[0].Content) - } -} - -func TestChainNilContribution(t *testing.T) { - c := NewChain() - c.Register(&mockFilter{ - name: "noop", - order: 0, - // nil messages → nil contribution - }) - - result := c.Execute(context.Background(), &CompletionContext{}) - if len(result) != 0 { - t.Fatalf("expected 0 messages from noop filter, got %d", len(result)) - } -} - -func TestChainMultipleMessages(t *testing.T) { - c := NewChain() - c.Register(&mockFilter{ - name: "multi", - order: 0, - messages: []InjectedMessage{ - {Role: "system", Content: "first"}, - {Role: "system", Content: "second"}, - }, - }) - - result := c.Execute(context.Background(), &CompletionContext{}) - - if len(result) != 2 { - t.Fatalf("expected 2 messages, got %d", len(result)) - } -} - -func TestChainLen(t *testing.T) { - c := NewChain() - if c.Len() != 0 { - t.Fatalf("expected 0, got %d", c.Len()) - } - c.Register(&mockFilter{name: "a", order: 0}) - c.Register(&mockFilter{name: "b", order: 1}) - if c.Len() != 2 { - t.Fatalf("expected 2, got %d", c.Len()) - } -} diff --git a/server/filters/kb_inject.go b/server/filters/kb_inject.go deleted file mode 100644 index 64d664d..0000000 --- a/server/filters/kb_inject.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package filters — kb_inject.go -// -// v0.29.0 CS0: Built-in pre-completion filter that auto-injects KB -// context into the system prompt. Refactored from the inline -// BuildKBHint() function in knowledge_bases.go. -// -// This is the reference implementation for the filter chain model -// that Starlark extensions will mirror in CS3. -package filters - -import ( - "context" - "fmt" - "log" - - "switchboard-core/store" -) - -// KBInjectFilter injects a system prompt listing active knowledge bases -// so the LLM knows to use the kb_search tool. -type KBInjectFilter struct { - stores store.Stores -} - -// NewKBInjectFilter creates the built-in KB auto-inject filter. -func NewKBInjectFilter(stores store.Stores) *KBInjectFilter { - return &KBInjectFilter{stores: stores} -} - -func (f *KBInjectFilter) Name() string { return "kb-auto-inject" } -func (f *KBInjectFilter) Order() int { return 10 } // built-in, runs early - -func (f *KBInjectFilter) Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) { - type kbInfo struct { - Name string - DocCount int - } - var kbs []kbInfo - seen := make(map[string]bool) - - // ── Persona-bound KBs (v0.17.0) ── - if cc.PersonaID != "" { - personaKBs, err := f.stores.Personas.GetKBs(ctx, cc.PersonaID) - if err == nil { - for _, pkb := range personaKBs { - if pkb.ChunkCount > 0 { - kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount}) - seen[pkb.KBID] = true - } - } - } - } - - // ── Project-bound KBs (v0.19.0) ── - if f.stores.Projects != nil { - projID, _ := f.stores.Projects.GetProjectIDForChannel(ctx, cc.ChannelID) - if projID != "" { - projKBIDs, projErr := f.stores.Projects.GetKBIDs(ctx, projID) - if projErr == nil { - for _, kbID := range projKBIDs { - if !seen[kbID] { - kb, kbErr := f.stores.KnowledgeBases.GetByID(ctx, kbID) - if kbErr == nil && kb.ChunkCount > 0 { - kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) - seen[kbID] = true - } - } - } - } - } - } - - // ── Channel-linked KBs ── - channelKBs, err := f.stores.KnowledgeBases.GetChannelKBs(ctx, cc.ChannelID) - if err == nil { - for _, ckb := range channelKBs { - if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] { - kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount}) - seen[ckb.KBID] = true - } - } - } - - // ── Personal KBs (always available to owner) ── - personalKBs, err := f.stores.KnowledgeBases.ListPersonal(ctx, cc.UserID) - if err == nil { - for _, kb := range personalKBs { - if !seen[kb.ID] && kb.ChunkCount > 0 { - kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) - seen[kb.ID] = true - } - } - } - - if len(kbs) == 0 { - return nil, nil - } - - hint := "\nYou have access to the following knowledge bases:\n" - for _, kb := range kbs { - hint += fmt.Sprintf("- \"%s\" (%d documents)\n", kb.Name, kb.DocCount) - } - hint += "Use the kb_search tool to find relevant information from these sources when the user asks questions that might be answered by their documents." - - log.Printf(" 🔗 kb-auto-inject: %d KBs active for channel %s", len(kbs), cc.ChannelID[:min(8, len(cc.ChannelID))]) - - return &Contribution{ - Messages: []InjectedMessage{ - {Role: "system", Content: hint}, - }, - }, nil -} diff --git a/server/filters/starlark_filter.go b/server/filters/starlark_filter.go deleted file mode 100644 index 8c804f8..0000000 --- a/server/filters/starlark_filter.go +++ /dev/null @@ -1,133 +0,0 @@ -// Package filters — starlark_filter.go -// -// v0.29.0 CS3: Bridges the pre-completion filter chain to Starlark -// extension scripts. When a package declares "filters.pre_completion" -// permission and is active, this filter runs the package's -// on_pre_completion(ctx) function and converts the return value -// into injected messages. -// -// Starlark contract: -// -// def on_pre_completion(ctx): -// """Called before each completion request. -// ctx is a dict: {"channel_id": "...", "user_id": "...", "persona_id": "..."} -// Return a list of dicts: [{"role": "system", "content": "..."}] -// Return None or [] to inject nothing. -// """ -// return [{"role": "system", "content": "Extra context from extension"}] -package filters - -import ( - "context" - "fmt" - "log" - - "go.starlark.net/starlark" - - "switchboard-core/sandbox" - "switchboard-core/store" -) - -// StarlarkFilter runs a Starlark package's on_pre_completion entry point. -type StarlarkFilter struct { - runner *sandbox.Runner - pkg *store.PackageRegistration - order int -} - -// NewStarlarkFilter creates a filter backed by a Starlark package. -func NewStarlarkFilter(runner *sandbox.Runner, pkg *store.PackageRegistration, order int) *StarlarkFilter { - return &StarlarkFilter{ - runner: runner, - pkg: pkg, - order: order, - } -} - -func (f *StarlarkFilter) Name() string { return "ext:" + f.pkg.ID } -func (f *StarlarkFilter) Order() int { return f.order } - -func (f *StarlarkFilter) Execute(ctx context.Context, cc *CompletionContext) (*Contribution, error) { - // Build context dict for the Starlark function - ctxDict := starlark.NewDict(4) - ctxDict.SetKey(starlark.String("channel_id"), starlark.String(cc.ChannelID)) - ctxDict.SetKey(starlark.String("user_id"), starlark.String(cc.UserID)) - ctxDict.SetKey(starlark.String("persona_id"), starlark.String(cc.PersonaID)) - ctxDict.SetKey(starlark.String("last_user_message"), starlark.String(cc.LastUserMessage)) - - val, output, err := f.runner.CallEntryPoint(ctx, f.pkg, "on_pre_completion", - starlark.Tuple{ctxDict}, nil, nil) - if err != nil { - return nil, fmt.Errorf("ext:%s: %w", f.pkg.ID, err) - } - - if output != "" { - log.Printf(" 🔧 ext:%s print: %s", f.pkg.ID, output) - } - - // Parse return value into messages - messages, err := parseStarlarkMessages(val) - if err != nil { - return nil, fmt.Errorf("ext:%s: invalid return value: %w", f.pkg.ID, err) - } - - if len(messages) == 0 { - return nil, nil - } - - return &Contribution{Messages: messages}, nil -} - -// parseStarlarkMessages converts a Starlark return value to InjectedMessages. -// Accepts: None, empty list, or list of dicts with "role" and "content" keys. -func parseStarlarkMessages(val starlark.Value) ([]InjectedMessage, error) { - if val == nil || val == starlark.None { - return nil, nil - } - - list, ok := val.(*starlark.List) - if !ok { - return nil, fmt.Errorf("expected list or None, got %s", val.Type()) - } - - if list.Len() == 0 { - return nil, nil - } - - var messages []InjectedMessage - iter := list.Iterate() - defer iter.Done() - - var item starlark.Value - for iter.Next(&item) { - dict, ok := item.(*starlark.Dict) - if !ok { - return nil, fmt.Errorf("expected dict in list, got %s", item.Type()) - } - - roleVal, found, _ := dict.Get(starlark.String("role")) - if !found { - return nil, fmt.Errorf("message dict missing 'role' key") - } - role, ok := starlark.AsString(roleVal) - if !ok { - return nil, fmt.Errorf("'role' must be a string") - } - - contentVal, found, _ := dict.Get(starlark.String("content")) - if !found { - return nil, fmt.Errorf("message dict missing 'content' key") - } - content, ok := starlark.AsString(contentVal) - if !ok { - return nil, fmt.Errorf("'content' must be a string") - } - - messages = append(messages, InjectedMessage{ - Role: role, - Content: content, - }) - } - - return messages, nil -} diff --git a/server/filters/starlark_filter_test.go b/server/filters/starlark_filter_test.go deleted file mode 100644 index d86fb10..0000000 --- a/server/filters/starlark_filter_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package filters - -import ( - "testing" - - "go.starlark.net/starlark" -) - -func TestParseStarlarkMessages_None(t *testing.T) { - msgs, err := parseStarlarkMessages(starlark.None) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if msgs != nil { - t.Fatalf("expected nil, got %d messages", len(msgs)) - } -} - -func TestParseStarlarkMessages_Nil(t *testing.T) { - msgs, err := parseStarlarkMessages(nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if msgs != nil { - t.Fatalf("expected nil, got %d messages", len(msgs)) - } -} - -func TestParseStarlarkMessages_EmptyList(t *testing.T) { - msgs, err := parseStarlarkMessages(starlark.NewList(nil)) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if msgs != nil { - t.Fatalf("expected nil, got %d messages", len(msgs)) - } -} - -func TestParseStarlarkMessages_SingleMessage(t *testing.T) { - d := starlark.NewDict(2) - d.SetKey(starlark.String("role"), starlark.String("system")) - d.SetKey(starlark.String("content"), starlark.String("injected context")) - list := starlark.NewList([]starlark.Value{d}) - - msgs, err := parseStarlarkMessages(list) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(msgs) != 1 { - t.Fatalf("expected 1 message, got %d", len(msgs)) - } - if msgs[0].Role != "system" { - t.Fatalf("expected role 'system', got %q", msgs[0].Role) - } - if msgs[0].Content != "injected context" { - t.Fatalf("expected content 'injected context', got %q", msgs[0].Content) - } -} - -func TestParseStarlarkMessages_MultipleMessages(t *testing.T) { - d1 := starlark.NewDict(2) - d1.SetKey(starlark.String("role"), starlark.String("system")) - d1.SetKey(starlark.String("content"), starlark.String("first")) - - d2 := starlark.NewDict(2) - d2.SetKey(starlark.String("role"), starlark.String("system")) - d2.SetKey(starlark.String("content"), starlark.String("second")) - - list := starlark.NewList([]starlark.Value{d1, d2}) - - msgs, err := parseStarlarkMessages(list) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(msgs) != 2 { - t.Fatalf("expected 2 messages, got %d", len(msgs)) - } - if msgs[0].Content != "first" || msgs[1].Content != "second" { - t.Fatalf("wrong content: %q, %q", msgs[0].Content, msgs[1].Content) - } -} - -func TestParseStarlarkMessages_MissingRole(t *testing.T) { - d := starlark.NewDict(1) - d.SetKey(starlark.String("content"), starlark.String("no role")) - list := starlark.NewList([]starlark.Value{d}) - - _, err := parseStarlarkMessages(list) - if err == nil { - t.Fatal("expected error for missing role") - } -} - -func TestParseStarlarkMessages_MissingContent(t *testing.T) { - d := starlark.NewDict(1) - d.SetKey(starlark.String("role"), starlark.String("system")) - list := starlark.NewList([]starlark.Value{d}) - - _, err := parseStarlarkMessages(list) - if err == nil { - t.Fatal("expected error for missing content") - } -} - -func TestParseStarlarkMessages_WrongType(t *testing.T) { - _, err := parseStarlarkMessages(starlark.String("not a list")) - if err == nil { - t.Fatal("expected error for wrong type") - } -} - -func TestParseStarlarkMessages_NonDictInList(t *testing.T) { - list := starlark.NewList([]starlark.Value{starlark.String("not a dict")}) - - _, err := parseStarlarkMessages(list) - if err == nil { - t.Fatal("expected error for non-dict in list") - } -} diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go deleted file mode 100644 index d06e0bb..0000000 --- a/server/handlers/apiconfigs.go +++ /dev/null @@ -1,297 +0,0 @@ -package handlers - -import ( - "errors" - "log" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/crypto" - "switchboard-core/models" - "switchboard-core/store" -) - -// ProviderConfigHandler handles user-facing provider config endpoints. -type ProviderConfigHandler struct { - stores store.Stores - vault *crypto.KeyResolver -} - -func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler { - return &ProviderConfigHandler{stores: s, vault: vault} -} - -// ListConfigs returns configs accessible to the user (global + personal + team). -func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) { - userID := getUserID(c) - - // User settings → Providers shows only personal (BYOK) configs. - // Global/team providers are managed by admins and surfaced via the model list. - cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) - return - } - - // Mask API keys - type safeConfig struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - Endpoint string `json:"endpoint"` - HasKey bool `json:"has_key"` - ModelDefault string `json:"model_default,omitempty"` - Scope string `json:"scope"` - IsActive bool `json:"is_active"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - - out := make([]safeConfig, len(cfgs)) - for i, cfg := range cfgs { - out[i] = safeConfig{ - ID: cfg.ID, - Name: cfg.Name, - Provider: cfg.Provider, - Endpoint: cfg.Endpoint, - HasKey: cfg.HasKey(), - ModelDefault: cfg.ModelDefault, - Scope: cfg.Scope, - IsActive: cfg.IsActive, - CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } - } - - c.JSON(http.StatusOK, gin.H{"data": out}) -} - -// GetConfig returns a single config by ID (if user has access). -func (h *ProviderConfigHandler) GetConfig(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "config not found"}) - return - } - - cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "config not found"}) - return - } - - c.JSON(http.StatusOK, cfg) -} - -// CreateConfig creates a personal provider config (BYOK). -// After creation, automatically fetches models from the provider API and enables them. -func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) { - userID := getUserID(c) - - // Check policy - allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok") - if !allowed { - c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"}) - return - } - - var req struct { - Name string `json:"name" binding:"required"` - Provider string `json:"provider" binding:"required"` - Endpoint string `json:"endpoint" binding:"required"` - APIKey string `json:"api_key"` - ModelDefault string `json:"model_default,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - cfg := &models.ProviderConfig{ - Name: req.Name, - Provider: req.Provider, - Endpoint: req.Endpoint, - ModelDefault: req.ModelDefault, - Scope: models.ScopePersonal, - KeyScope: models.ScopePersonal, - OwnerID: &userID, - IsActive: true, - } - - // Encrypt the API key with user's UEK (personal scope) - if req.APIKey != "" { - if h.vault != nil { - enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID) - if err != nil { - if err == crypto.ErrVaultLocked { - c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - cfg.APIKeyEnc = enc - cfg.KeyNonce = nonce - } else { - cfg.APIKeyEnc = []byte(req.APIKey) - } - } - - if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) - return - } - - // Auto-fetch models from the provider API and enable them. - // The user added this key to USE it — don't make them hunt for a fetch button. - resp := gin.H{ - "id": cfg.ID, - "name": cfg.Name, - "provider": cfg.Provider, - "endpoint": cfg.Endpoint, - "scope": cfg.Scope, - } - - result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey) - if err != nil { - // Provider created successfully but model fetch failed. - // Return 201 (provider exists) with a warning so the frontend can show it. - resp["warning"] = "Provider created but model fetch failed: " + err.Error() - resp["models_fetched"] = 0 - log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err) - } else { - resp["models_fetched"] = result.Total - } - - c.JSON(http.StatusCreated, resp) -} - -// UpdateConfig updates a personal provider config. -func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - existing, err := h.stores.Providers.GetByID(c.Request.Context(), id) - if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"}) - return - } - - // Bind standard fields - var req struct { - models.ProviderConfigPatch - APIKey *string `json:"api_key,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - patch := req.ProviderConfigPatch - // Transfer api_key → encrypted fields - if req.APIKey != nil && *req.APIKey != "" { - if h.vault != nil { - enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "personal", userID) - if err != nil { - if err == crypto.ErrVaultLocked { - c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - patch.APIKeyEnc = enc - patch.KeyNonce = nonce - } else { - patch.APIKeyEnc = []byte(*req.APIKey) - } - } - - if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "config updated"}) -} - -// DeleteConfig deletes a personal provider config. -func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - existing, err := h.stores.Providers.GetByID(c.Request.Context(), id) - if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"}) - return - } - - h.stores.Catalog.DeleteForProvider(c.Request.Context(), id) - if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "config deleted"}) -} - -// ListModels returns models for a specific provider config. -func (h *ProviderConfigHandler) ListModels(c *gin.Context) { - id := c.Param("id") - - entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) - return - } - - c.JSON(http.StatusOK, gin.H{"data": entries}) -} - -// FetchModels fetches models from the provider API and auto-enables them. -// This is the user-facing equivalent of admin POST /models/fetch. -// Allows refreshing models for existing BYOK providers. -func (h *ProviderConfigHandler) FetchModels(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id) - if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) - return - } - - // Decrypt the API key for the provider API call - apiKey := "" - if len(cfg.APIKeyEnc) > 0 { - if h.vault != nil { - apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"}) - return - } - } else { - apiKey = string(cfg.APIKeyEnc) - } - } - - result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey) - if err != nil { - if errors.Is(err, ErrUpstreamTimeout) { - c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "models synced", - "added": result.Added, - "updated": result.Updated, - "total": result.Total, - }) -} diff --git a/server/handlers/avatar.go b/server/handlers/avatar.go deleted file mode 100644 index 419b1fb..0000000 --- a/server/handlers/avatar.go +++ /dev/null @@ -1,228 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/base64" - "image" - "image/color" - "image/png" - "net/http" - "strings" - - // Register decoders so image.Decode works for common formats - _ "image/gif" - _ "image/jpeg" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -const avatarSize = 128 -const maxUploadBytes = 2 * 1024 * 1024 // 2MB raw input limit - -type uploadAvatarRequest struct { - Image string `json:"image" binding:"required"` // base64 data URI or raw base64 -} - -// ── Upload Avatar ─────────────────────────── -// POST /api/v1/profile/avatar -// Accepts { "image": "data:image/png;base64,..." } or { "image": "" } -// Decodes, resizes to 128×128 PNG, stores as data URI. -func (h *SettingsHandler) UploadAvatar(c *gin.Context) { - userID := getUserID(c) - - var req uploadAvatarRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"}) - return - } - - // Strip data URI prefix if present - b64 := req.Image - if idx := strings.Index(b64, ","); idx >= 0 { - b64 = b64[idx+1:] - } - - // Decode base64 - raw, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"}) - return - } - if len(raw) > maxUploadBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"}) - return - } - - // Decode image (supports PNG, JPEG, GIF via registered decoders) - src, _, err := image.Decode(bytes.NewReader(raw)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"}) - return - } - - // Resize to 128×128 - resized := resizeBilinear(src, avatarSize, avatarSize) - - // Encode to PNG - var buf bytes.Buffer - if err := png.Encode(&buf, resized); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"}) - return - } - - // Build data URI - dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) - - // Store in DB - err = h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": dataURI}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"}) - return - } - - c.JSON(http.StatusOK, gin.H{"avatar": dataURI}) -} - -// ── Delete Avatar ─────────────────────────── -// DELETE /api/v1/profile/avatar -func (h *SettingsHandler) DeleteAvatar(c *gin.Context) { - userID := getUserID(c) - - err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": nil}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "avatar removed"}) -} - -// ── Bilinear Resize ───────────────────────── -// Pure stdlib bilinear interpolation. Quality is fine for 128×128 avatars. -func resizeBilinear(src image.Image, w, h int) *image.RGBA { - dst := image.NewRGBA(image.Rect(0, 0, w, h)) - sb := src.Bounds() - sw := float64(sb.Dx()) - sh := float64(sb.Dy()) - - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - // Map destination pixel to source coordinates - sx := (float64(x) + 0.5) * sw / float64(w) - 0.5 - sy := (float64(y) + 0.5) * sh / float64(h) - 0.5 - - x0 := int(sx) - y0 := int(sy) - xf := sx - float64(x0) - yf := sy - float64(y0) - - // Clamp - if x0 < sb.Min.X { x0 = sb.Min.X } - if y0 < sb.Min.Y { y0 = sb.Min.Y } - x1 := x0 + 1 - y1 := y0 + 1 - if x1 >= sb.Max.X { x1 = sb.Max.X - 1 } - if y1 >= sb.Max.Y { y1 = sb.Max.Y - 1 } - - // Sample 4 neighbors - c00 := src.At(x0, y0) - c10 := src.At(x1, y0) - c01 := src.At(x0, y1) - c11 := src.At(x1, y1) - - dst.Set(x, y, bilinearMix(c00, c10, c01, c11, xf, yf)) - } - } - return dst -} - -func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color { - r00, g00, b00, a00 := c00.RGBA() - r10, g10, b10, a10 := c10.RGBA() - r01, g01, b01, a01 := c01.RGBA() - r11, g11, b11, a11 := c11.RGBA() - - mix := func(v00, v10, v01, v11 uint32) uint8 { - top := float64(v00)*(1-xf) + float64(v10)*xf - bot := float64(v01)*(1-xf) + float64(v11)*xf - return uint8((top*(1-yf) + bot*yf) / 256) - } - - return color.RGBA{ - R: mix(r00, r10, r01, r11), - G: mix(g00, g10, g01, g11), - B: mix(b00, b10, b01, b11), - A: mix(a00, a10, a01, a11), - } -} - -// ── Persona Avatar Upload ──────────────────── -// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin) -// UploadPersonaAvatar uploads and resizes a persona avatar. -// v0.29.0: accepts PersonaStore instead of using database.DB directly. -func UploadPersonaAvatar(personas store.PersonaStore, c *gin.Context) { - personaID := c.Param("id") - - var req uploadAvatarRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"}) - return - } - - b64 := req.Image - if idx := strings.Index(b64, ","); idx >= 0 { - b64 = b64[idx+1:] - } - - raw, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"}) - return - } - if len(raw) > maxUploadBytes { - c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"}) - return - } - - src, _, err := image.Decode(bytes.NewReader(raw)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"}) - return - } - - resized := resizeBilinear(src, avatarSize, avatarSize) - - var buf bytes.Buffer - if err := png.Encode(&buf, resized); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"}) - return - } - - dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) - - err = personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &dataURI}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"}) - return - } - - c.JSON(http.StatusOK, gin.H{"avatar": dataURI}) -} - -// DeletePersonaAvatar clears a persona's avatar. -// v0.29.0: accepts PersonaStore instead of using database.DB directly. -func DeletePersonaAvatar(personas store.PersonaStore, c *gin.Context) { - personaID := c.Param("id") - - empty := "" - err := personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &empty}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "avatar removed"}) -} diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go deleted file mode 100644 index 54a87e6..0000000 --- a/server/handlers/capabilities.go +++ /dev/null @@ -1,142 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "log" - "net/http" - - "github.com/gin-gonic/gin" - - capspkg "switchboard-core/capabilities" - "switchboard-core/auth" - "switchboard-core/health" - "switchboard-core/models" - "switchboard-core/store" -) - -// ModelHandler provides the unified models endpoint. -type ModelHandler struct { - stores store.Stores - healthStore HealthStatusQuerier // provider health for status enrichment (v0.22.3, nil = disabled) -} - -func NewModelHandler(s store.Stores) *ModelHandler { - return &ModelHandler{stores: s} -} - -// SetHealthStore attaches the health store for model status enrichment (v0.22.3). -func (h *ModelHandler) SetHealthStore(hs HealthStatusQuerier) { - h.healthStore = hs -} - -// ListEnabledModels returns all models the user can access (catalog + personas), -// with user preferences (hidden, sort order) applied. -func (h *ModelHandler) ListEnabledModels(c *gin.Context) { - userID := getUserID(c) - - userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID) - if err != nil { - log.Printf("error: ModelsForUser(%s): %v", userID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"}) - return - } - - // Enrich with provider health status (v0.22.3) - if h.healthStore != nil { - healthMap := h.buildHealthMap(c.Request.Context()) - for i := range userModels { - if s, ok := healthMap[userModels[i].ProviderConfigID]; ok { - userModels[i].ProviderStatus = s - } - } - } - - // Include admin default model so frontend can use it in resolution chain - defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model") - - // Model allowlist filtering (v0.24.2) — non-admin users with group-level - // model restrictions only see permitted models. Persona entries whose - // underlying model is disallowed are also filtered. - role, _ := c.Get("role") - if role != "admin" { - allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID) - if err == nil && allowlist != nil { - filtered := make([]models.UserModel, 0, len(userModels)) - for _, m := range userModels { - if allowlist[m.ModelID] { - filtered = append(filtered, m) - } - } - userModels = filtered - } - } - - c.JSON(http.StatusOK, gin.H{"data": userModels, "default_model": defaultModel}) -} - -// buildHealthMap returns a map of configID → ProviderStatus from current health windows. -func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.ProviderStatus { - m := make(map[string]models.ProviderStatus) - windows, err := h.healthStore.ListAllCurrent(ctx) - if err != nil { - return m - } - for _, w := range windows { - m[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount) - } - return m -} - -// ResolveModelCaps is the canonical capability resolver for any model. -// v0.29.0: accepts CatalogStore instead of using database.DB directly. -func ResolveModelCaps(catalog store.CatalogStore, modelID, configID string) models.ModelCapabilities { - // 1. Exact match: model_id + provider_config_id - if configID != "" { - caps, ok := capsFromCatalog(catalog, modelID, configID) - if ok { - caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) - return caps - } - } - - // 2. Any provider: same model_id, any config - caps, ok := capsFromCatalog(catalog, modelID, "") - if ok { - caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) - return caps - } - - // 3. Heuristic inference - caps = capspkg.InferCapabilities(modelID) - caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) - return caps -} - -// capsFromCatalog looks up capabilities from the model_catalog table. -func capsFromCatalog(catalog store.CatalogStore, modelID, configID string) (models.ModelCapabilities, bool) { - if catalog == nil { - return models.ModelCapabilities{}, false - } - - var capsJSON []byte - var err error - if configID != "" { - capsJSON, err = catalog.GetCapabilities(context.Background(), modelID, configID) - } else { - capsJSON, err = catalog.GetCapabilitiesAny(context.Background(), modelID) - } - if err != nil || len(capsJSON) == 0 { - return models.ModelCapabilities{}, false - } - - var caps models.ModelCapabilities - if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() { - return models.ModelCapabilities{}, false - } - - // Merge with known data to fill gaps - resolved := capspkg.ResolveIntrinsic(modelID, &caps, nil) - return resolved, true -} - diff --git a/server/handlers/channel_models.go b/server/handlers/channel_models.go deleted file mode 100644 index ee0dc9b..0000000 --- a/server/handlers/channel_models.go +++ /dev/null @@ -1,240 +0,0 @@ -package handlers - -import ( - "database/sql" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Channel Models Handler ────────────────── -// Manages multi-model assignments per channel (v0.20.0 Phase 2). -// Routes: -// GET /channels/:id/models — list -// POST /channels/:id/models — add -// PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt) -// DELETE /channels/:id/models/:modelId — remove - -const maxModelsPerChannel = 5 - -type ChannelModelHandler struct { - stores store.Stores -} - -func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler { - return &ChannelModelHandler{stores: stores} -} - -// ── List ───────────────────────────────────── - -func (h *ChannelModelHandler) List(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - if !userOwnsChannel(c, channelID, userID) { - return - } - - roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) - return - } - if roster == nil { - roster = []models.ChannelModel{} - } - c.JSON(http.StatusOK, gin.H{"data": roster}) -} - -// ── Add ────────────────────────────────────── - -type addChannelModelRequest struct { - ModelID string `json:"model_id" binding:"required"` - ProviderConfigID string `json:"provider_config_id"` - DisplayName string `json:"display_name" binding:"required"` - SystemPrompt string `json:"system_prompt"` - IsDefault bool `json:"is_default"` -} - -func (h *ChannelModelHandler) Add(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - if !userOwnsChannel(c, channelID, userID) { - return - } - - var req addChannelModelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Check max models limit - existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"}) - return - } - if len(existing) >= maxModelsPerChannel { - c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"}) - return - } - - // If this is the first model or marked as default, ensure only one default - isDefault := req.IsDefault || len(existing) == 0 - - cm := &models.ChannelModel{ - ChannelID: channelID, - ModelID: req.ModelID, - ProviderConfigID: req.ProviderConfigID, - DisplayName: req.DisplayName, - SystemPrompt: req.SystemPrompt, - IsDefault: isDefault, - } - - // SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id) - if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"}) - return - } - - // If this is the new default, clear default on others - if isDefault { - h.clearOtherDefaults(c, channelID, req.ModelID) - } - - // Return the updated roster - roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) - if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusCreated, gin.H{"data": roster}) -} - -// ── Update ─────────────────────────────────── - -type updateChannelModelRequest struct { - DisplayName *string `json:"display_name"` - SystemPrompt *string `json:"system_prompt"` - IsDefault *bool `json:"is_default"` -} - -func (h *ChannelModelHandler) Update(c *gin.Context) { - channelID := c.Param("id") - modelRecordID := c.Param("modelId") - userID := getUserID(c) - - if !userOwnsChannel(c, channelID, userID) { - return - } - - var req updateChannelModelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify model belongs to this channel - cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"}) - return - } - if cm.ChannelID != channelID { - c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"}) - return - } - - fields := make(map[string]interface{}) - if req.DisplayName != nil { - fields["display_name"] = *req.DisplayName - } - if req.SystemPrompt != nil { - fields["system_prompt"] = *req.SystemPrompt - } - if req.IsDefault != nil { - fields["is_default"] = *req.IsDefault - if *req.IsDefault { - h.clearOtherDefaults(c, channelID, cm.ModelID) - } - } - - if len(fields) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return - } - - if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"}) - return - } - - roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) - if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusOK, gin.H{"data": roster}) -} - -// ── Delete ─────────────────────────────────── - -func (h *ChannelModelHandler) Delete(c *gin.Context) { - channelID := c.Param("id") - modelRecordID := c.Param("modelId") - userID := getUserID(c) - - if !userOwnsChannel(c, channelID, userID) { - return - } - - // Verify model belongs to this channel - cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"}) - return - } - if cm.ChannelID != channelID { - c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"}) - return - } - - if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) - return - } - - // If deleted model was default, promote the first remaining model - if cm.IsDefault { - remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) - if len(remaining) > 0 { - h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID, - map[string]interface{}{"is_default": true}) - } - } - - roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) - if roster == nil { roster = []models.ChannelModel{} } - c.JSON(http.StatusOK, gin.H{"data": roster}) -} - -// ── Helpers ────────────────────────────────── - -// clearOtherDefaults sets is_default=false on all models in the channel -// except the one with the given modelID. -func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) { - roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID) - for _, m := range roster { - if m.ModelID != keepModelID && m.IsDefault { - h.stores.Channels.UpdateModel(c.Request.Context(), m.ID, - map[string]interface{}{"is_default": false}) - } - } -} diff --git a/server/handlers/channels.go b/server/handlers/channels.go deleted file mode 100644 index 1864cde..0000000 --- a/server/handlers/channels.go +++ /dev/null @@ -1,641 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "log" - "math" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/database" - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Request / Response types ──────────────── - -type createChannelRequest struct { - Title string `json:"title" binding:"required,max=500"` - Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow - Description string `json:"description,omitempty"` - Model string `json:"model,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - Folder string `json:"folder,omitempty"` - FolderID *string `json:"folder_id,omitempty"` - Tags []string `json:"tags,omitempty"` - // v0.23.2: DM creation - Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user) - AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off -} - -type updateChannelRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Model *string `json:"model,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` - IsPinned *bool `json:"is_pinned,omitempty"` - Folder *string `json:"folder,omitempty"` - FolderID *string `json:"folder_id,omitempty"` - Tags []string `json:"tags,omitempty"` - Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings - WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5) - AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off - Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic -} - -type channelResponse struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Title string `json:"title"` - Type string `json:"type"` - AiMode string `json:"ai_mode,omitempty"` - Topic *string `json:"topic,omitempty"` - Description *string `json:"description"` - Model *string `json:"model"` - ProviderConfigID *string `json:"provider_config_id"` - SystemPrompt *string `json:"system_prompt"` - IsArchived bool `json:"is_archived"` - IsPinned bool `json:"is_pinned"` - Folder *string `json:"folder"` - FolderID *string `json:"folder_id,omitempty"` - ProjectID *string `json:"project_id,omitempty"` - WorkspaceID *string `json:"workspace_id,omitempty"` - Tags []string `json:"tags"` - Settings json.RawMessage `json:"settings,omitempty"` - MessageCount int `json:"message_count"` - UnreadCount int `json:"unread_count,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type paginatedResponse struct { - Data interface{} `json:"data"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Total int `json:"total"` - TotalPages int `json:"total_pages"` -} - -// ChannelHandler holds dependencies for channel endpoints. -type ChannelHandler struct { - stores store.Stores -} - -// NewChannelHandler creates a new channel handler. -func NewChannelHandler(stores store.Stores) *ChannelHandler { - return &ChannelHandler{stores: stores} -} - -// channelDeleteHook is called after a channel is successfully deleted. -// Set at startup by SetChannelDeleteHook to clean up storage files. -var channelDeleteHook func(channelID string) - -// SetChannelDeleteHook registers a callback invoked after channel deletion. -// Used to clean up channel files on the storage backend. -func SetChannelDeleteHook(fn func(channelID string)) { - channelDeleteHook = fn -} - -// ── Helpers ───────────────────────────────── - -// getUserID extracts the authenticated user's ID from context. -func getUserID(c *gin.Context) string { - uid, _ := c.Get("user_id") - s, _ := uid.(string) - return s -} - -// isSessionAuth returns true if the request is from a session participant (v0.24.3). -func isSessionAuth(c *gin.Context) bool { - return c.GetString("auth_type") == "session" -} - -// sessionCanAccessChannel validates that a session participant is authorized -// for the given channel. -func sessionCanAccessChannel(c *gin.Context, channelID string) bool { - if !isSessionAuth(c) { - return false - } - return c.GetString("channel_id") == channelID -} - -// parsePagination reads page and per_page from query params with defaults. -func parsePagination(c *gin.Context) (page, perPage, offset int) { - page, _ = strconv.Atoi(c.DefaultQuery("page", "1")) - perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50")) - - if page < 1 { - page = 1 - } - if perPage < 1 { - perPage = 50 - } - if perPage > 100 { - perPage = 100 - } - offset = (page - 1) * perPage - return -} - -// listItemToResponse converts a store.ChannelListItem to a handler response. -func listItemToResponse(item store.ChannelListItem) channelResponse { - tags := item.Tags - if tags == nil { - tags = []string{} - } - return channelResponse{ - ID: item.ID, - UserID: item.UserID, - Title: item.Title, - Type: item.Type, - AiMode: item.AiMode, - Topic: item.Topic, - Description: item.Description, - Model: item.Model, - ProviderConfigID: item.ProviderConfigID, - SystemPrompt: item.SystemPrompt, - IsArchived: item.IsArchived, - IsPinned: item.IsPinned, - Folder: item.Folder, - FolderID: item.FolderID, - ProjectID: item.ProjectID, - WorkspaceID: item.WorkspaceID, - Tags: tags, - Settings: item.Settings, - MessageCount: item.MessageCount, - UnreadCount: item.UnreadCount, - CreatedAt: item.CreatedAt, - UpdatedAt: item.UpdatedAt, - } -} - -// ── List Channels ─────────────────────────── - -func (h *ChannelHandler) ListChannels(c *gin.Context) { - userID := getUserID(c) - page, perPage, offset := parsePagination(c) - - // Optional filters - archived := c.DefaultQuery("archived", "false") - - var channelTypes []string - if raw := c.Query("types"); raw != "" { - for _, t := range strings.Split(raw, ",") { - t = strings.TrimSpace(t) - if t != "" { - channelTypes = append(channelTypes, t) - } - } - } - if len(channelTypes) == 0 { - if ct := c.DefaultQuery("type", ""); ct != "" { - channelTypes = []string{ct} - } - } - - filter := store.ChannelListFilter{ - ListOptions: store.ListOptions{Limit: perPage, Offset: offset}, - Archived: archived == "true", - Types: channelTypes, - Folder: c.Query("folder"), - FolderID: c.Query("folder_id"), - Search: strings.TrimSpace(c.Query("search")), - ProjectID: c.Query("project_id"), - } - - items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) - return - } - - channels := make([]channelResponse, 0, len(items)) - for _, item := range items { - channels = append(channels, listItemToResponse(item)) - } - - // Resolve DM titles: show the other participant's name, not the creator's label - resolveDMTitles(c.Request.Context(), channels, userID) - - SafeJSON(c, http.StatusOK, paginatedResponse{ - Data: channels, - Page: page, - PerPage: perPage, - Total: total, - TotalPages: int(math.Ceil(float64(total) / float64(perPage))), - }) -} - -// ── Create Channel ────────────────────────── - -func (h *ChannelHandler) CreateChannel(c *gin.Context) { - userID := getUserID(c) - - var req createChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Tags == nil { - req.Tags = []string{} - } - - // Default type is "direct" (1:1 AI chat) - channelType := req.Type - if channelType == "" { - channelType = "direct" - } - - // Default ai_mode: mention_only for DMs, auto for everything else - aiMode := req.AiMode - if aiMode == "" { - if channelType == "dm" { - aiMode = "mention_only" - } else { - aiMode = "auto" - } - } - - ctx := c.Request.Context() - - // ── DM dedup: check for existing DM between these two users ── - if channelType == "dm" && len(req.Participants) == 1 { - otherUserID := req.Participants[0] - if otherUserID == userID { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"}) - return - } - - existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID) - if existingID != "" { - // Return existing channel instead of creating duplicate - item, err := h.stores.Channels.GetForUser(ctx, existingID, userID) - if err == nil { - SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource - return - } - // If read fails, fall through and create new (shouldn't happen) - } - } - - // Build channel model - ch := &models.Channel{ - UserID: userID, - Title: req.Title, - Type: channelType, - Description: req.Description, - Model: req.Model, - SystemPrompt: req.SystemPrompt, - ProviderConfigID: req.ProviderConfigID, - FolderID: req.FolderID, - } - - dmPartners := []string{} - if channelType == "dm" { - dmPartners = req.Participants - } - - defaultConfigID := "" - if req.ProviderConfigID != nil { - defaultConfigID = *req.ProviderConfigID - } - - if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode, - userID, dmPartners, req.Model, defaultConfigID); err != nil { - log.Printf("[channels] CreateFull error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) - return - } - - // Fetch full response with message count - item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"}) - return - } - - SafeJSON(c, http.StatusCreated, listItemToResponse(*item)) -} - -// ── Get Channel ───────────────────────────── - -func (h *ChannelHandler) GetChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - - SafeJSON(c, http.StatusOK, listItemToResponse(*item)) -} - -// ── Update Channel ────────────────────────── - -func (h *ChannelHandler) UpdateChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - ctx := c.Request.Context() - - var req updateChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify ownership (participants can only move to folder) - owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - if !owns { - // Participants may move channels to their own folders - folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil && - req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil && - req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil && - req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil && - req.Topic == nil && req.Settings == nil - if !folderOnly { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return - } - // Verify they are a participant - canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID) - if !canAccess { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return - } - } - - // Build fields map for store.Update - fields := map[string]interface{}{} - - if req.Title != nil { - fields["title"] = *req.Title - } - if req.Description != nil { - fields["description"] = *req.Description - } - if req.Model != nil { - fields["model"] = *req.Model - } - if req.SystemPrompt != nil { - fields["system_prompt"] = *req.SystemPrompt - } - if req.ProviderConfigID != nil { - if *req.ProviderConfigID == "" { - fields["provider_config_id"] = nil - } else { - fields["provider_config_id"] = *req.ProviderConfigID - } - } - if req.IsArchived != nil { - fields["is_archived"] = *req.IsArchived - } - if req.IsPinned != nil { - fields["is_pinned"] = *req.IsPinned - } - if req.Folder != nil { - fields["folder"] = *req.Folder - } - if req.FolderID != nil { - if *req.FolderID == "" { - fields["folder_id"] = nil // unbind from folder - } else { - fields["folder_id"] = *req.FolderID - } - } - if req.Tags != nil { - fields["tags"] = req.Tags - } - if req.WorkspaceID != nil { - if *req.WorkspaceID == "" { - fields["workspace_id"] = nil // unbind - } else { - fields["workspace_id"] = *req.WorkspaceID - } - } - if req.AiMode != nil { - mode := *req.AiMode - if mode != "auto" && mode != "mention_only" && mode != "off" { - c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"}) - return - } - fields["ai_mode"] = mode - } - if req.Topic != nil { - fields["topic"] = *req.Topic - } - - hasFields := len(fields) > 0 - hasSettings := req.Settings != nil - - if !hasFields && !hasSettings { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return - } - - if hasSettings { - if !json.Valid([]byte(*req.Settings)) { - c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"}) - return - } - } - - if hasFields { - if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) - return - } - } - - if hasSettings { - if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"}) - return - } - } - - // Return updated channel - h.GetChannel(c) -} - -// ── Delete Channel ────────────────────────── - -func (h *ChannelHandler) DeleteChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - ctx := c.Request.Context() - - // Check ownership first (without deleting) - owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - if !owns { - // Not the owner — if participant, leave the channel instead of deleting. - res, _ := database.DB.ExecContext(ctx, database.Q(` - DELETE FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2 - `), channelID, userID) - if rows, _ := res.RowsAffected(); rows > 0 { - c.JSON(http.StatusOK, gin.H{"message": "left channel"}) - return - } - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - - // Check if retention policy applies (global/team provider + TTL > 0) - if h.shouldRetain(ctx, channelID) { - ttl := h.retentionTTL(ctx) - purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour) - if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil { - log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "message": "channel archived for retention", - "purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"), - }) - return - } - - // Exempt — hard delete - n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID) - if err != nil || n == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) - return - } - - if channelDeleteHook != nil { - go channelDeleteHook(channelID) - } - - c.JSON(http.StatusOK, gin.H{"message": "channel deleted"}) -} - -// shouldRetain returns true if a channel uses a global or team provider -// and the retention TTL is configured (> 0). -func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool { - ttl := h.retentionTTL(ctx) - if ttl <= 0 { - return false - } - - // Only BYOK (personal) provider channels are exempt. - // All others — global, team, or no explicit provider — follow retention. - var providerConfigID *string - _ = database.DB.QueryRowContext(ctx, database.Q(` - SELECT provider_config_id FROM channels WHERE id = $1 - `), channelID).Scan(&providerConfigID) - - if providerConfigID == nil || *providerConfigID == "" { - return true // no explicit provider → defaults to global → retain - } - - cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID) - if err != nil { - return true // provider deleted (FK SET NULL already handled above) → retain - } - - return cfg.Scope != models.ScopePersonal -} - -// retentionTTL reads the configured retention TTL in days from global settings. -func (h *ChannelHandler) retentionTTL(ctx context.Context) int { - cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days") - if err != nil { - return 0 - } - if v, ok := cfg["value"].(float64); ok { - return int(v) - } - return 0 -} - -// ── Mark Read (v0.23.2) ─────────────────────── - -func (h *ChannelHandler) MarkRead(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - err := h.stores.Channels.MarkRead(c.Request.Context(), channelID, userID) - if err != nil { - // Participant row may not exist for legacy direct chats — that's OK - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// ── DM Title Resolution ────────────────────── - -// resolveDMTitles replaces DM channel titles with the other participant's -// display name so each user sees "DM " instead of the -// creator's original label. -func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) { - // Collect DM channel IDs - var dmIDs []string - dmIdx := map[string]int{} // channel_id → index in channels slice - for i, ch := range channels { - if ch.Type == "dm" { - dmIDs = append(dmIDs, ch.ID) - dmIdx[ch.ID] = i - } - } - if len(dmIDs) == 0 { - return - } - - // Query: for each DM, get the OTHER participant's display name - // Uses channel_participants to find the peer, then joins users for name - placeholders := make([]string, len(dmIDs)) - args := make([]interface{}, 0, len(dmIDs)+1) - for i, id := range dmIDs { - placeholders[i] = fmt.Sprintf("$%d", i+1) - args = append(args, id) - } - args = append(args, viewerID) - viewerPlaceholder := fmt.Sprintf("$%d", len(args)) - - query := database.Q(fmt.Sprintf(` - SELECT cp.channel_id, - COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown') - FROM channel_participants cp - JOIN users u ON u.id = cp.participant_id - WHERE cp.channel_id IN (%s) - AND cp.participant_type = 'user' - AND cp.participant_id != %s - LIMIT %d - `, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs))) - - rows, err := database.DB.QueryContext(ctx, query, args...) - if err != nil { - return // non-fatal: keep original titles - } - defer rows.Close() - - for rows.Next() { - var chID, peerName string - if rows.Scan(&chID, &peerName) == nil { - if idx, ok := dmIdx[chID]; ok { - channels[idx].Title = "DM " + peerName - } - } - } -} diff --git a/server/handlers/completion.go b/server/handlers/completion.go deleted file mode 100644 index 2d0e181..0000000 --- a/server/handlers/completion.go +++ /dev/null @@ -1,2003 +0,0 @@ -package handlers - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/auth" - capspkg "switchboard-core/capabilities" - "switchboard-core/crypto" - "switchboard-core/database" - "switchboard-core/events" - "switchboard-core/filters" - "switchboard-core/health" - "switchboard-core/knowledge" - "switchboard-core/metrics" - "switchboard-core/models" - "switchboard-core/notifications" - "switchboard-core/providers" - "switchboard-core/routing" - "switchboard-core/sandbox" - "switchboard-core/storage" - "switchboard-core/store" - "switchboard-core/tools" -) - -// ── Request Types ─────────────────────────── - -type completionRequest struct { - ChannelID string `json:"channel_id"` - Content string `json:"content" binding:"required"` - Model string `json:"model,omitempty"` - PersonaID string `json:"persona_id,omitempty"` - ProviderConfigID string `json:"provider_config_id,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream *bool `json:"stream,omitempty"` - FileIDs []string `json:"file_ids,omitempty"` // staged file UUIDs to include in request - DisabledTools []string `json:"disabled_tools,omitempty"` // tool names to exclude from this request -} - -// CompletionHandler proxies LLM requests through the backend. -type CompletionHandler struct { - vault *crypto.KeyResolver - stores store.Stores - hub *events.Hub // WebSocket hub for browser tool bridge - objStore storage.ObjectStore // file storage for uploaded/generated content (nil = disabled) - embedder *knowledge.Embedder // for memory semantic recall (v0.18.0) - health HealthRecorder // provider health tracking (v0.22.0, nil = disabled) - healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled) - router *routing.Evaluator // routing policy evaluator (v0.22.2, nil = disabled) - filterChain *filters.Chain // pre-completion filter chain (v0.29.0, nil = disabled) - runner *sandbox.Runner // v0.29.2: Starlark extension tool dispatch (nil = disabled) -} - -// HealthRecorder is the interface for recording provider call outcomes. -// Matches health.Accumulator methods without importing the package. -type HealthRecorder interface { - RecordSuccess(providerConfigID string, latencyMs int) - RecordError(providerConfigID string, latencyMs int, errMsg string) - RecordTimeout(providerConfigID string, latencyMs int, errMsg string) - RecordRateLimit(providerConfigID string, latencyMs int, errMsg string) // v0.22.4 - RecordToolSuccess(toolName string, latencyMs int) // v0.22.4 - RecordToolError(toolName string, latencyMs int, errMsg string) // v0.22.4 -} - -// HealthStatusQuerier retrieves current provider health for routing decisions. -// Matches health.Store.ListAllCurrent without importing the package. -type HealthStatusQuerier interface { - ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) -} - -// NewCompletionHandler creates a new handler. -func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore, embedder *knowledge.Embedder) *CompletionHandler { - return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore, embedder: embedder} -} - -// SetHealthRecorder attaches the provider health accumulator. -func (h *CompletionHandler) SetHealthRecorder(hr HealthRecorder) { - h.health = hr -} - -// SetHealthStore attaches the health store for routing status queries (v0.22.2). -func (h *CompletionHandler) SetHealthStore(hs HealthStatusQuerier) { - h.healthStore = hs -} - -// SetRoutingEvaluator attaches the routing policy engine (v0.22.2). -func (h *CompletionHandler) SetRoutingEvaluator(r *routing.Evaluator) { - h.router = r -} - -// SetFilterChain attaches the pre-completion filter chain (v0.29.0). -func (h *CompletionHandler) SetFilterChain(fc *filters.Chain) { - h.filterChain = fc -} - -// SetRunner attaches the Starlark sandbox runner for extension tool dispatch (v0.29.2). -func (h *CompletionHandler) SetRunner(r *sandbox.Runner) { - h.runner = r -} - -// buildExtToolMap returns the extension tool map for the current user. -func (h *CompletionHandler) buildExtToolMap(ctx context.Context, userID string) map[string]*store.PackageRegistration { - return BuildExtToolMap(ctx, h.stores, userID) -} - -// evaluateRouting applies routing policies to select the best provider config -// for this request. Returns the winning config details and a routing decision -// for observability. If routing is disabled or no policies match, returns -// the original resolved config unchanged. -func (h *CompletionHandler) evaluateRouting( - ctx context.Context, - userID, model, configID, providerID string, -) (winConfigID, winProviderID string, decision *routing.Decision) { - // Fallthrough: return original config if routing is disabled - if h.router == nil || h.stores.RoutingPolicies == nil { - return configID, providerID, nil - } - - // Load active policies (scoped to user's teams) - var dbPolicies []models.RoutingPolicy - var err error - - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) - if len(teamIDs) > 0 { - // Load global + team-scoped policies for user's teams - dbPolicies, err = h.stores.RoutingPolicies.ListActive(ctx) - } else { - dbPolicies, err = h.stores.RoutingPolicies.ListActive(ctx) - } - if err != nil || len(dbPolicies) == 0 { - return configID, providerID, nil - } - policies := routing.FromModels(dbPolicies) - - // Build candidates from all configs accessible to this user - configs, err := h.stores.Providers.ListAccessible(ctx, userID) - if err != nil || len(configs) < 2 { - // No point routing with a single config - return configID, providerID, nil - } - - candidates := make([]routing.Candidate, 0, len(configs)) - for _, cfg := range configs { - if !cfg.IsActive { - continue - } - candidates = append(candidates, routing.Candidate{ - ConfigID: cfg.ID, - ProviderID: cfg.Provider, - Model: model, - Endpoint: cfg.Endpoint, - }) - } - if len(candidates) < 2 { - return configID, providerID, nil - } - - // Build health status map - healthStatus := make(map[string]models.ProviderStatus) - if h.healthStore != nil { - windows, _ := h.healthStore.ListAllCurrent(ctx) - for _, w := range windows { - healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount) - } - } - - routingCtx := &routing.Context{ - UserID: userID, - TeamIDs: teamIDs, - Model: model, - ConfigID: configID, - HealthStatus: healthStatus, - Pricing: map[string]*models.ModelPricing{}, // populated when cost_limit routing is enabled (deferred) - } - - ranked, dec := h.router.Evaluate(routingCtx, policies, candidates) - if len(ranked) > 0 && ranked[0].ConfigID != configID { - return ranked[0].ConfigID, ranked[0].ProviderID, dec - } - - return configID, providerID, dec -} - -// ── Chat Completion ───────────────────────── -// POST /api/v1/chat/completions -// -// Flow: -// 1. Validate request, verify chat ownership -// 2. Resolve api_config (from request, chat, or user default) -// 3. Load conversation history from messages -// 4. Persist user message -// 5. Attach tool definitions if model supports tools -// 6. Call provider (stream or non-stream) -// 7. Tool loop: if LLM requests tool calls, execute them and re-call -// 8. Stream SSE to client / return JSON -// 9. Persist assistant message with token counts - -func (h *CompletionHandler) Complete(c *gin.Context) { - var req completionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - channelID := req.ChannelID - if channelID == "" { - // v0.24.3: Workflow API routes have channel in URL param - channelID = c.Param("id") - } - if channelID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"}) - return - } - - userID := getUserID(c) - - // v0.33.0: Correlation ID — same as request_id, propagated through - // completion chain for cross-referencing logs. - correlationID, _ := c.Get("request_id") - slog.Info("completion.start", - "request_id", correlationID, - "channel_id", channelID, - "user_id", userID, - "model", req.Model, - ) - - // v0.24.3: Session participants are pre-validated by AuthOrSession middleware - if isSessionAuth(c) { - if !sessionCanAccessChannel(c, channelID) { - c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"}) - return - } - // Use session_id as effective identity for cursor/participant tracking. - // persistMessage will record participant_type=user with this ID — - // acceptable for v0.24.3; v0.25.0 can refine to participant_type=session. - userID = c.GetString("session_id") - } else { - // Verify participation: check channel_participants first, fall back to - // legacy channels.user_id ownership for direct channels. - isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID) - if !isParticipant { - if !userOwnsChannel(c, channelID, userID) { - return - } - } - } - - // ── ai_mode guard (v0.23.1) ──────────────────────────────────────────────── - // Check channel ai_mode before doing any work. For DM channels with - // mention_only mode and no @mention in the content, persist the message - // but skip the completion entirely. - { - var aiMode string - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 - `), channelID).Scan(&aiMode) - - if aiMode == "off" || (aiMode == "mention_only" && extractFirstMention(req.Content) == "") { - // Persist the user message before returning - msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") - if err != nil { - log.Printf("[ai_skip] Failed to persist user message: %v", err) - } - if h.hub != nil && msgID != "" { - broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID) - } - c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID}) - return - } - } - - // ── Persona unwrap: persona overrides defaults, explicit request fields win ── - var personaSystemPrompt string - var personaID string // tracks active persona for KB scoping - var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1) - - // v0.25.0: Query channel metadata once — used by group leader check, tool context, - // and execution context. Avoids redundant SELECTs on channels. - var channelType string - var channelTeamID *string - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1 - `), channelID).Scan(&channelType, &channelTeamID) - - teamID := "" - if channelTeamID != nil { - teamID = *channelTeamID - } - - // v0.23.2: Group leader default — when type=group and no @mention, use the leader persona - if req.PersonaID == "" && extractFirstMention(req.Content) == "" { - if channelType == "group" { - // Find the leader persona via channel_participants → persona_group_members - var leaderPersonaID string - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT cp.participant_id - FROM channel_participants cp - JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id - WHERE cp.channel_id = $1 - AND cp.participant_type = 'persona' - AND pgm.is_leader = true - LIMIT 1 - `), channelID).Scan(&leaderPersonaID) - // Fallback: first persona participant if no leader flag set - if leaderPersonaID == "" { - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'persona' - ORDER BY created_at LIMIT 1 - `), channelID).Scan(&leaderPersonaID) - } - if leaderPersonaID != "" { - req.PersonaID = leaderPersonaID - log.Printf("[group] channel %s: leader persona %s", channelID[:min(8, len(channelID))], leaderPersonaID[:min(8, len(leaderPersonaID))]) - } - } - } - - if req.PersonaID != "" { - persona := ResolvePersona(h.stores, req.PersonaID, userID) - if persona == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found or not accessible"}) - return - } - personaID = persona.ID - // Persona provides defaults; explicit request fields take priority - if req.Model == "" { - req.Model = persona.BaseModelID - } - if req.ProviderConfigID == "" && persona.ProviderConfigID != nil { - req.ProviderConfigID = *persona.ProviderConfigID - } - if req.Temperature == nil && persona.Temperature != nil { - req.Temperature = persona.Temperature - } - if req.MaxTokens == 0 && persona.MaxTokens != nil { - req.MaxTokens = *persona.MaxTokens - } - if persona.SystemPrompt != "" { - personaSystemPrompt = persona.SystemPrompt - } - personaThinkingBudget = persona.ThinkingBudget - } - - // ── Project persona fallback (v0.19.2): if no explicit persona, check project ── - if req.PersonaID == "" && h.stores.Projects != nil { - projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID) - if projID != "" { - if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil { - if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" { - if persona := ResolvePersona(h.stores, pid, userID); persona != nil { - personaID = persona.ID - if req.Model == "" { - req.Model = persona.BaseModelID - } - if req.ProviderConfigID == "" && persona.ProviderConfigID != nil { - req.ProviderConfigID = *persona.ProviderConfigID - } - if req.Temperature == nil && persona.Temperature != nil { - req.Temperature = persona.Temperature - } - if req.MaxTokens == 0 && persona.MaxTokens != nil { - req.MaxTokens = *persona.MaxTokens - } - if persona.SystemPrompt != "" { - personaSystemPrompt = persona.SystemPrompt - } - personaThinkingBudget = persona.ThinkingBudget - } - } - } - } - } - - // Resolve provider config - providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // ── Routing policy evaluation (v0.22.2) ────────── - // After resolving the primary config, evaluate routing policies to - // potentially select a different (better/healthier) provider. - var routingDecision *routing.Decision - if h.router != nil { - winConfigID, _, dec := h.evaluateRouting(c.Request.Context(), userID, model, configID, providerID) - routingDecision = dec - if winConfigID != configID { - // Routing selected a different provider — reload its credentials - req.ProviderConfigID = winConfigID - providerCfg2, providerID2, model2, configID2, providerScope2, err := h.resolveConfig(userID, channelID, req) - if err == nil { - providerCfg = providerCfg2 - providerID = providerID2 - if model2 != "" { - model = model2 - } - configID = configID2 - providerScope = providerScope2 - } else { - // Routing target failed to load — fall back to original. - log.Printf("[routing] Failed to load winning config %s: %v — using original %s", winConfigID, err, configID) - } - } - } - - // ── Inject persona-level thinking budget into provider settings (v0.22.1) ── - // When a persona specifies a thinking budget, promote it into provider - // settings so hooks can activate extended thinking automatically. - if personaThinkingBudget != nil && *personaThinkingBudget > 0 { - if providerCfg.Settings == nil { - providerCfg.Settings = make(map[string]interface{}) - } - providerCfg.Settings["extended_thinking"] = true - providerCfg.Settings["thinking_budget"] = *personaThinkingBudget - } - - // ── Team policy: require_private_providers ── - if err := enforcePrivateProviderPolicy(c.Request.Context(), h.stores, userID, configID); err != nil { - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) - return - } - - provider, err := providers.Get(providerID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Routing observability header (v0.22.2) - c.Header("X-Switchboard-Provider", providerID+"/"+configID) - if routingDecision != nil { - c.Header("X-Switchboard-Route", routingDecision.PolicyName) - if routingDecision.FallbackDepth > 0 { - c.Header("X-Switchboard-Fallback", fmt.Sprintf("%d", routingDecision.FallbackDepth)) - } - } - - // Load conversation history - messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) - return - } - - // ── Workflow form template injection (v0.26.4) ── - // When the channel is a workflow instance, inject the current stage's - // form_template as a system message so the persona knows what to collect. - if channelType == "workflow" { - if hint := h.buildWorkflowFormHint(c.Request.Context(), channelID); hint != "" { - messages = append(messages[:1], append([]providers.Message{{ - Role: "system", - Content: hint, - }}, messages[1:]...)...) - } - } - - // Resolve capabilities early — needed for vision gating below - caps := h.getModelCapabilities(c, model, configID) - - // Build user message — multimodal if files are present - userMsg := providers.Message{ - Role: "user", - Content: req.Content, - } - - if len(req.FileIDs) > 0 && h.objStore != nil { - parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.FileIDs, caps) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if len(parts) > 0 { - // Multimodal (images present): use ContentParts - userMsg.ContentParts = parts - } else if augContent != req.Content { - // Doc-only: use augmented text content - userMsg.Content = augContent - } - } - - messages = append(messages, userMsg) - - // Persist user message (text-only for storage — multimodal parts are ephemeral) - msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") - if err != nil { - log.Printf("Failed to persist user message: %v", err) - } - - // Broadcast user message to all channel participants - if h.hub != nil && msgID != "" { - broadcastUserMessage(c.Request.Context(), h.hub, channelID, msgID, req.Content, userID) - } - - // Link files to the persisted message - if msgID != "" && len(req.FileIDs) > 0 { - for _, fID := range req.FileIDs { - if err := h.stores.Files.SetMessageID(c.Request.Context(), fID, msgID); err != nil { - log.Printf("Failed to link file %s to message %s: %v", fID, msgID, err) - } - } - } - - // ── Workspace resolution (v0.21.1) ──────── - // Resolve effective workspace: channel.workspace_id > project.workspace_id - workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) - - // ── Tool context (v0.25.0) ───────────────── - // Uses channel metadata queried above (channelType, teamID). - tctx := tools.ToolContext{ - ChannelType: channelType, - WorkspaceID: workspaceID, - TeamID: teamID, - IsVisitor: isSessionAuth(c), - // PersonaID set below after persona resolution - } - - // ── @mention routing (v0.23.0 / v0.23.1) ───────────────────────────────── - // Resolve @persona-handle, @model-id, or @username. - // - User mention → skip completion, notify recipient (v0.23.1) - // - AI mention → override model/persona for this turn (v0.23.0) - mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content) - - // v0.23.2: @all fan-out — route to every persona participant (depth-1 only) - if strings.Contains(strings.ToLower(req.Content), "@all") { - // Get all persona participants for this channel - allRows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'persona' - ORDER BY created_at - `), channelID) - if err == nil { - defer allRows.Close() - var allPersonaIDs []string - for allRows.Next() { - var pid string - if allRows.Scan(&pid) == nil { - allPersonaIDs = append(allPersonaIDs, pid) - } - } - if len(allPersonaIDs) > 0 { - // Use the first persona for the streamed response - first := ResolvePersona(h.stores, allPersonaIDs[0], userID) - if first != nil { - mentionModel = first.BaseModelID - if first.ProviderConfigID != nil { - mentionConfig = *first.ProviderConfigID - } - mentionPersona = first - } - // Chain remaining personas via goroutine after the stream completes - if len(allPersonaIDs) > 1 && h.hub != nil { - remaining := allPersonaIDs[1:] - go func() { - for _, pid := range remaining { - time.Sleep(500 * time.Millisecond) - h.chainToPersona(channelID, userID, pid, req.Content, 1) - } - }() - } - } - } - } - - if mentionedUserID != "" { - // Human @mention — message already persisted; notify recipient, skip AI. - if h.hub != nil { - payload, _ := json.Marshal(map[string]any{ - "channel_id": channelID, - "from_user": userID, - "content": truncateContent(req.Content, 120), - }) - h.hub.PublishToUser(mentionedUserID, events.Event{ - Label: "user.mentioned", - Payload: payload, - Ts: time.Now().UnixMilli(), - }) - } - - // Persist notification for bell/inbox (v0.28.2) - if svc := notifications.Default(); svc != nil { - notifications.NotifyUserMentioned(svc, mentionedUserID, channelID, userID, truncateContent(req.Content, 120)) - } - - c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID}) - return - } - - if mentionModel != "" { - req.Model = mentionModel - if mentionConfig != "" { - req.ProviderConfigID = mentionConfig - } - if mentionPersona != nil { - personaID = mentionPersona.ID - personaThinkingBudget = mentionPersona.ThinkingBudget - if mentionPersona.SystemPrompt != "" { - if len(messages) > 0 && messages[0].Role == "system" { - messages[0].Content = mentionPersona.SystemPrompt - } else { - messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...) - } - } - // Context boundary: tell this persona to ignore other personas' styles - boundary := fmt.Sprintf( - "[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]", - mentionPersona.Name, - ) - if len(messages) >= 2 { - last := messages[len(messages)-1] - messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary} - messages = append(messages, last) - } - } - // Re-resolve provider config for the @mentioned target - providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - caps = h.getModelCapabilities(c, model, configID) - if personaThinkingBudget != nil && *personaThinkingBudget > 0 { - if providerCfg.Settings == nil { - providerCfg.Settings = make(map[string]interface{}) - } - providerCfg.Settings["extended_thinking"] = true - providerCfg.Settings["thinking_budget"] = *personaThinkingBudget - } - provider, err = providers.Get(providerID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - } else { - // No @mention: inject a boundary if conversation has persona responses - // to prevent the default model from mimicking persona styles. - if h.conversationHasPersonaMessages(c.Request.Context(), channelID) { - boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]" - if len(messages) >= 2 { - last := messages[len(messages)-1] - messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary} - messages = append(messages, last) - } - } - } - - // Build provider request - provReq := providers.CompletionRequest{ - Model: model, - Messages: messages, - } - - if req.MaxTokens > 0 { - provReq.MaxTokens = req.MaxTokens - } else { - // ResolveMaxOutput checks: caps → known models → context/8 → 4096 - provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps) - } - - if req.Temperature != nil { - provReq.Temperature = req.Temperature - } - if req.TopP != nil { - provReq.TopP = req.TopP - } - - // Attach tool definitions if model supports tool calling and tools are available - tctx.PersonaID = personaID // finalize after all persona resolution (@mention, group leader, etc.) - hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) - if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID) - } - - // ── Permission pre-flight ───────────────────────────────────────────── - // Budget enforcement and model allowlist only apply when the request draws - // from a team or global provider. BYOK (personal scope) is the user's money - // — no ceiling applies. - // v0.24.3: Session participants bypass user-level budget/allowlist checks. - // They use the workflow channel's allocated resources. - role, _ := c.Get("role") - if role != "admin" && providerScope != "personal" && !isSessionAuth(c) { - // Token budget - if h.stores.Usage != nil { - budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID) - if err == nil && budget != nil { - var opts store.UsageQueryOptions - now := time.Now() - if budget.Period == "daily" { - dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - opts.Since = &dayStart - } else { - monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) - opts.Since = &monthStart - } - totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts) - if err == nil && totals != nil { - used := int64(totals.InputTokens + totals.OutputTokens) - if used >= budget.Limit { - c.JSON(http.StatusTooManyRequests, gin.H{"error": "token budget exceeded"}) - return - } - } - } - } - - // Model allowlist - allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID) - if err == nil && allowlist != nil && !allowlist[model] { - c.JSON(http.StatusForbidden, gin.H{"error": "model not permitted for your account: " + model}) - return - } - } - - // Determine streaming - stream := true // default - if req.Stream != nil { - stream = *req.Stream - } - - if stream { - h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID) - } else { - h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID) - } -} - -// ── Multi-Model Sequential Stream (v0.20.0) ── - -// multiModelStream handles the case where a message @mentions multiple -// channel models. It streams each model's response sequentially within -// a single SSE response, sending model_start/model_end delimiters. -func (h *CompletionHandler) multiModelStream( - c *gin.Context, - targets []models.ChannelModel, - messages []providers.Message, - channelID, userID, personaID, personaSystemPrompt, workspaceID, teamID string, - req completionRequest, -) { - // Set SSE headers once for the entire multi-model stream - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - c.Status(http.StatusOK) - flusher, _ := c.Writer.(http.Flusher) - flush := func() { - if flusher != nil { - flusher.Flush() - } - } - sendSSE := func(data string) { - fmt.Fprintf(c.Writer, "data: %s\n\n", data) - flush() - } - sendEvent := func(event, data string) { - fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) - flush() - } - - for _, target := range targets { - // Notify client which model is about to respond - startJSON, _ := json.Marshal(map[string]string{ - "model_id": target.ModelID, - "display_name": target.DisplayName, - }) - sendEvent("model_start", string(startJSON)) - - // Resolve config for this specific model - targetReq := req - targetReq.Model = target.ModelID - if target.ProviderConfigID != "" { - targetReq.ProviderConfigID = target.ProviderConfigID - } - - providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq) - if err != nil { - sendSSE(fmt.Sprintf(`{"error":"failed to resolve config for %s: %s"}`, target.DisplayName, escapeJSON(err.Error()))) - sendEvent("model_end", string(startJSON)) - continue - } - - provider, err := providers.Get(providerID) - if err != nil { - sendSSE(fmt.Sprintf(`{"error":"provider unavailable for %s"}`, target.DisplayName)) - sendEvent("model_end", string(startJSON)) - continue - } - - // Build per-model messages (inject model-specific system prompt if set) - modelMessages := make([]providers.Message, len(messages)) - copy(modelMessages, messages) - if target.SystemPrompt != "" { - // Prepend model-specific system prompt - modelMessages = append([]providers.Message{{ - Role: "system", - Content: target.SystemPrompt, - }}, modelMessages...) - } - - caps := h.getModelCapabilities(c, model, configID) - - provReq := providers.CompletionRequest{ - Model: model, - Messages: modelMessages, - } - if targetReq.MaxTokens > 0 { - provReq.MaxTokens = targetReq.MaxTokens - } else { - provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps) - } - if targetReq.Temperature != nil { - provReq.Temperature = targetReq.Temperature - } - if targetReq.TopP != nil { - provReq.TopP = targetReq.TopP - } - hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) - if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - tctx := tools.ToolContext{ - WorkspaceID: workspaceID, - PersonaID: personaID, - IsVisitor: isSessionAuth(c), - } - provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID) - } - - // Apply provider-specific request hooks (v0.22.1) - if hooks := providers.GetHooks(providerID); hooks != nil { - hooks.PreRequest(providerCfg, &provReq) - } - - // Stream this model's response using the shared streaming core - multiExtTools := h.buildExtToolMap(c.Request.Context(), userID) - result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health, h.runner, multiExtTools) - - // Persist assistant message with model attribution - if result.Content != "" { - pID := "" - if target.PersonaID != nil { - pID = *target.PersonaID - } - if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil { - log.Printf("Failed to persist multi-model assistant message: %v", err) - } - } - - // Log usage - h.logUsage(c, channelID, userID, configID, providerScope, model, - result.InputTokens, result.OutputTokens, - result.CacheCreationTokens, result.CacheReadTokens) - - sendEvent("model_end", string(startJSON)) - } - - sendSSE("[DONE]") -} - -// buildToolDefs delegates to the standalone BuildToolDefs function. -// See resolve.go for the full implementation. -func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, tctx tools.ToolContext, personaID string) []providers.ToolDef { - return BuildToolDefs(ctx, h.stores, userID, includeBrowser, disabledTools, tctx, personaID) -} - -// ── List Available Tools ──────────────────── -// GET /api/v1/tools -// -// Returns all registered server-side tools plus browser extension tools -// for the current user. Used by the frontend to build the tools toggle menu. - -type toolInfo struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Category string `json:"category"` -} - -func (h *CompletionHandler) ListTools(c *gin.Context) { - userID := c.GetString("user_id") - - // Server-side tools - allDefs := tools.AllDefinitions() - result := make([]toolInfo, 0, len(allDefs)) - for _, d := range allDefs { - result = append(result, toolInfo{ - Name: d.Name, - DisplayName: d.DisplayName, - Description: d.Description, - Category: d.Category, - }) - } - - // Browser extension tools - if h.hub != nil && h.hub.IsConnected(userID) { - pkgs, err := h.stores.Packages.ListForUser(c.Request.Context(), userID) - if err == nil { - for _, pkg := range pkgs { - if pkg.Tier != "browser" { - continue - } - var manifest struct { - Tools []struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - } `json:"tools"` - } - if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil { - continue - } - for _, t := range manifest.Tools { - displayName := t.DisplayName - if displayName == "" { - displayName = t.Name - } - result = append(result, toolInfo{ - Name: t.Name, - DisplayName: displayName, - Description: t.Description, - Category: "browser", - }) - } - } - } - } - - c.JSON(http.StatusOK, gin.H{"data": result}) -} - -// ── Streaming Completion (SSE) with Tool Loop ── - -func (h *CompletionHandler) streamCompletion( - c *gin.Context, - provider providers.Provider, - cfg providers.ProviderConfig, - req providers.CompletionRequest, - channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string, -) { - // Apply provider-specific request hooks (v0.22.1) - if hooks := providers.GetHooks(providerID); hooks != nil { - hooks.PreRequest(cfg, &req) - } - - extTools := h.buildExtToolMap(c.Request.Context(), userID) - result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health, h.runner, extTools) - - // Persist assistant response - if result.Content != "" { - asstMsgID, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID) - if err != nil { - log.Printf("Failed to persist assistant message: %v", err) - } - - // Record tool_output file refs (v0.37.18) - h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, asstMsgID) - - // Broadcast assistant message to other channel participants - if h.hub != nil && asstMsgID != "" { - broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID) - } - - // AI-to-AI chaining: if the response @mentions another persona participant, - // trigger a follow-up completion asynchronously via WebSocket delivery. - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("[chain] PANIC recovered: %v", r) - } - }() - h.chainIfMentioned(channelID, userID, personaID, result.Content, 0) - }() - } - - // Log usage - h.logUsage(c, channelID, userID, configID, providerScope, model, - result.InputTokens, result.OutputTokens, - result.CacheCreationTokens, result.CacheReadTokens) -} - -// ── Non-Streaming Completion with Tool Loop ── - -func (h *CompletionHandler) syncCompletion( - c *gin.Context, - provider providers.Provider, - cfg providers.ProviderConfig, - req providers.CompletionRequest, - channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string, -) { - // Apply provider-specific request hooks (v0.22.1) - if hooks := providers.GetHooks(providerID); hooks != nil { - hooks.PreRequest(cfg, &req) - } - - extTools := h.buildExtToolMap(c.Request.Context(), userID) - result := CoreToolLoop(c.Request.Context(), LoopConfig{ - Provider: provider, - Cfg: cfg, - Req: &req, - Model: model, - ProviderType: providerID, - ExecCtx: tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - PersonaID: personaID, - WorkspaceID: workspaceID, - TeamID: teamID, - }, - Hub: h.hub, - Health: h.health, - ConfigID: configID, - Budget: LoopBudget{}, - Streaming: false, - Runner: h.runner, - ExtTools: extTools, - }, accumSink{}) - - // Provider error - if result.Error != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + result.Error.Error()}) - return - } - - // Max iterations hit with no final content - if result.BudgetExceeded == "max_rounds" && result.Content == "" { - c.JSON(http.StatusOK, gin.H{ - "model": model, - "choices": []gin.H{ - { - "message": gin.H{ - "role": "assistant", - "content": "[Tool execution limit reached]", - }, - "finish_reason": "stop", - }, - }, - }) - return - } - - finalContent := result.Content - - // Persist assistant response - syncAsstMsgID, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID) - if err != nil { - log.Printf("Failed to persist assistant message: %v", err) - } - - // Record tool_output file refs (v0.37.18) - h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, syncAsstMsgID) - - // AI-to-AI chaining - go func() { - defer func() { - if r := recover(); r != nil { - log.Printf("[chain] PANIC recovered: %v", r) - } - }() - h.chainIfMentioned(channelID, userID, personaID, finalContent, 0) - }() - - // Log usage - h.logUsage(c, channelID, userID, configID, providerScope, model, - result.InputTokens, result.OutputTokens, - result.CacheCreationTokens, result.CacheReadTokens) - - // Return OpenAI-compatible response - finishReason := "stop" - if result.BudgetExceeded != "" { - finishReason = "budget_exceeded" - } - c.JSON(http.StatusOK, gin.H{ - "model": model, - "choices": []gin.H{ - { - "message": gin.H{ - "role": "assistant", - "content": finalContent, - }, - "finish_reason": finishReason, - }, - }, - "usage": gin.H{ - "prompt_tokens": result.InputTokens, - "completion_tokens": result.OutputTokens, - "total_tokens": result.InputTokens + result.OutputTokens, - }, - }) -} - -// ── Model Capabilities ────────────────────── - -// escapeJSON escapes a string for safe embedding in a JSON string value. -func escapeJSON(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - s = strings.ReplaceAll(s, "\n", `\n`) - s = strings.ReplaceAll(s, "\r", `\r`) - s = strings.ReplaceAll(s, "\t", `\t`) - return s -} - -// getModelCapabilities looks up capabilities from model_catalog DB, -// then overlays with known model defaults and heuristic detection. -func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities { - return ResolveModelCaps(h.stores.Catalog, model, apiConfigID) -} - -// ── Multimodal Assembly ───────────────────── -// Builds content parts from files for the user message. -// -// Returns: -// - parts: ContentParts array (non-nil only when images are present) -// - augContent: enriched text content with document context (for doc-only case) -// - validIDs: file IDs that were successfully processed -// - error: if any file is invalid or vision is needed but missing -// -// Rules: -// - Images → base64 data URI (requires vision capability) -// - Documents with extracted_text → text injection -// - Documents without extraction → filename placeholder -// - Text is always the first part - -func (h *CompletionHandler) buildMultimodalParts( - c *gin.Context, - channelID, textContent string, - fileIDs []string, - caps models.ModelCapabilities, -) ([]providers.ContentPart, string, []string, error) { - - parts := []providers.ContentPart{ - {Type: "text", Text: textContent}, - } - var docTexts []string - var validIDs []string - hasImage := false - - for _, fileID := range fileIDs { - att, err := h.stores.Files.GetByID(c.Request.Context(), fileID) - if err != nil { - return nil, "", nil, fmt.Errorf("file %s not found", fileID) - } - - // Security: verify file belongs to this channel - if att.ChannelID != channelID { - return nil, "", nil, fmt.Errorf("file %s does not belong to this channel", fileID) - } - - if isImageContentType(att.ContentType) { - // Vision gating: reject images if model lacks vision - if !caps.Vision { - return nil, "", nil, fmt.Errorf("model does not support image input; remove image files or choose a vision-capable model") - } - - // Read image from storage, base64 encode, build data URI - reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey) - if err != nil { - log.Printf("Failed to read file %s from storage: %v", fileID, err) - parts = append(parts, providers.ContentPart{ - Type: "text", - Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename), - }) - validIDs = append(validIDs, fileID) - continue - } - data, err := io.ReadAll(reader) - reader.Close() - if err != nil { - log.Printf("Failed to read file %s bytes: %v", fileID, err) - validIDs = append(validIDs, fileID) - continue - } - - b64 := base64.StdEncoding.EncodeToString(data) - dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) - - parts = append(parts, providers.ContentPart{ - Type: "image_url", - ImageURL: &providers.ImageURL{ - URL: dataURI, - Detail: "auto", - }, - }) - hasImage = true - - } else if att.ExtractedText != nil && *att.ExtractedText != "" { - // Document with extracted text → inject as context - docText := fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText) - parts = append(parts, providers.ContentPart{ - Type: "text", - Text: docText, - }) - docTexts = append(docTexts, docText) - - } else { - // Document without extraction (pending, failed, or not extractable) - status := "pending" - if s, ok := att.Metadata["extraction_status"].(string); ok { - status = s - } - placeholder := fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status) - parts = append(parts, providers.ContentPart{ - Type: "text", - Text: placeholder, - }) - docTexts = append(docTexts, placeholder) - } - - validIDs = append(validIDs, fileID) - } - - // If images present → use ContentParts (multimodal array) - if hasImage { - return parts, textContent, validIDs, nil - } - - // Doc-only: merge document context into a single text string (more efficient) - augmented := textContent - for _, dt := range docTexts { - augmented += "\n\n" + dt - } - return nil, augmented, validIDs, nil -} - -// isImageContentType returns true for MIME types that should be sent as -// base64 image content parts rather than text extraction. -func isImageContentType(ct string) bool { - return strings.HasPrefix(ct, "image/") -} - -// ── @mention Resolution (v0.23.0) ─────────── - -// conversationHasPersonaMessages checks if any assistant message in the -// channel was generated by a persona (participant_type = 'persona'). -// Used to decide whether to inject a context boundary for the default model. -func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool { - var id string - err := database.DB.QueryRowContext(ctx, database.Q(` - SELECT id FROM messages - WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona' - LIMIT 1 - `), channelID).Scan(&id) - return err == nil && id != "" -} - -// buildWorkflowFormHint loads the current workflow stage's form_template -// and returns a system message instructing the persona what to collect. -// Returns empty string if not a workflow or no template defined. -// v0.29.3: migrated from raw database.DB to stores, stage_mode aware. -func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string { - ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil || ws.WorkflowID == nil || *ws.WorkflowID == "" { - return "" - } - - if h.stores.Workflows == nil { - return "" - } - - stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) - if err != nil || ws.CurrentStage >= len(stages) { - return "" - } - - stage := stages[ws.CurrentStage] - - // v0.29.3: form_only stages don't use LLM — no hint needed - if stage.StageMode == models.StageModeFormOnly { - return "" - } - - if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" { - return "" - } - - // v0.29.3: Try typed form template first - if tpl := models.ParseTypedFormTemplate(stage.FormTemplate); tpl != nil { - hint := "You are in workflow stage: " + stage.Name + ".\n\n" - if stage.StageMode == models.StageModeFormChat { - hint += "A form has been provided for the visitor to fill in structured data. " + - "You can assist them with questions about the form fields or discuss related topics.\n\n" + - "Form fields:\n" - } else { - hint += "Collect the following information from the user through natural conversation:\n\n" - } - for _, f := range tpl.Fields { - hint += "- " + f.Label - if f.Required { - hint += " (required)" - } - hint += "\n" - } - if stage.StageMode != models.StageModeFormChat { - hint += "\nWhen you have gathered all required information, call the workflow_advance tool " + - "with the collected data as a JSON object. Do not advance until all required fields are filled." - } - return hint - } - - // Legacy format: map[string]interface{} with {description, required} - var fields map[string]interface{} - if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil { - return "" - } - if len(fields) == 0 { - return "" - } - - hint := "You are in workflow stage: " + stage.Name + ".\n\n" - hint += "Collect the following information from the user through natural conversation:\n\n" - for name, spec := range fields { - switch v := spec.(type) { - case map[string]interface{}: - desc, _ := v["description"].(string) - required, _ := v["required"].(bool) - if desc != "" { - hint += "- " + name + ": " + desc - } else { - hint += "- " + name - } - if required { - hint += " (required)" - } - hint += "\n" - default: - hint += "- " + name + "\n" - } - } - - hint += "\nWhen you have gathered all required information, call the workflow_advance tool " + - "with the collected data as a JSON object. Do not advance until all required fields are filled." - - return hint -} - -// buildParticipantHint builds a system message listing available @mentionable -// personas so the LLM knows who it can direct responses to. -// Only includes personas accessible to this user. Excludes the current persona. -func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string { - ctx := context.Background() - - personas, err := h.stores.Personas.ListForUser(ctx, userID) - if err != nil || len(personas) == 0 { - return "" - } - - var lines []string - for _, p := range personas { - if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID { - continue - } - desc := p.Description - if len(desc) > 80 { - desc = desc[:80] + "..." - } - if desc != "" { - lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc)) - } else { - lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name)) - } - } - - if len(lines) == 0 { - return "" - } - - hint := "Available conversation participants you can @mention to direct a response:\n" - for _, l := range lines { - hint += l + "\n" - } - hint += "To ask another participant to respond, include their @handle in your message." - return hint -} - -// Resolves @tokens in message content directly against the model catalog -// and personas table. Works in any chat — no channel roster needed. -// -// Resolution order: -// 1. Persona handle (exact match, then prefix) -// 2. User handle/username (exact, then prefix) — v0.23.1 -// When a user is @mentioned in a DM/channel, skip completion and deliver -// a notification instead. Returns non-empty mentionedUserID in that case. -// 3. Model ID in enabled catalog (exact match, then prefix) -// -// Returns (modelID, providerConfigID, *Persona, mentionedUserID). -// Exactly one of (modelID, mentionedUserID) will be non-empty on a successful -// match, or all empty if no @mention found/resolved. - -func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona, string) { - // Extract first @token - token := extractFirstMention(content) - if token == "" { - return "", "", nil, "" - } - - // Normalize: lowercase, hyphens - normalized := strings.ToLower(strings.TrimSpace(token)) - - // 1. Try persona handle (exact) - var personaID, personaHandle string - err := database.DB.QueryRowContext(ctx, database.Q(` - SELECT id, handle FROM personas - WHERE LOWER(handle) = $1 AND is_active = true - `), normalized).Scan(&personaID, &personaHandle) - if err == nil && personaID != "" { - p := ResolvePersona(h.stores, personaID, userID) - if p != nil { - cfgID := "" - if p.ProviderConfigID != nil { - cfgID = *p.ProviderConfigID - } - log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID) - return p.BaseModelID, cfgID, p, "" - } - } - - // 2. Try persona handle (prefix — unambiguous only) - if personaID == "" { - var count int - database.DB.QueryRowContext(ctx, database.Q(` - SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true - `), normalized+"%").Scan(&count) - if count == 1 { - database.DB.QueryRowContext(ctx, database.Q(` - SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true - `), normalized+"%").Scan(&personaID) - if personaID != "" { - p := ResolvePersona(h.stores, personaID, userID) - if p != nil { - cfgID := "" - if p.ProviderConfigID != nil { - cfgID = *p.ProviderConfigID - } - log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID) - return p.BaseModelID, cfgID, p, "" - } - } - } - } - - // 3. Try user handle (exact match) — v0.24.0 - // Only resolves to a user if the caller is not the same user (no self-mention) - var mentionedUserID string - err = database.DB.QueryRowContext(ctx, database.Q(` - SELECT id FROM users - WHERE LOWER(handle) = $1 AND id != $2 AND is_active = true - LIMIT 1 - `), normalized, userID).Scan(&mentionedUserID) - if err == nil && mentionedUserID != "" { - log.Printf("[mention] @%s → user %s", token, mentionedUserID) - return "", "", nil, mentionedUserID - } - - // 4. Try user handle (prefix — unambiguous only) — v0.24.0 - var userCount int - database.DB.QueryRowContext(ctx, database.Q(` - SELECT COUNT(*) FROM users - WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true - `), normalized+"%", userID).Scan(&userCount) - if userCount == 1 { - database.DB.QueryRowContext(ctx, database.Q(` - SELECT id FROM users - WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true - LIMIT 1 - `), normalized+"%", userID).Scan(&mentionedUserID) - if mentionedUserID != "" { - log.Printf("[mention] @%s → user prefix %s", token, mentionedUserID) - return "", "", nil, mentionedUserID - } - } - var modelID, provConfigID string - err = database.DB.QueryRowContext(ctx, database.Q(` - SELECT mc.model_id, mc.provider_config_id - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) = $1 - AND mc.visibility = 'enabled' - AND pc.is_active = true - ORDER BY - CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END - LIMIT 1 - `), normalized).Scan(&modelID, &provConfigID) - if err == nil && modelID != "" { - log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID) - return modelID, provConfigID, nil, "" - } - - // 5. Try model_id prefix (unambiguous) - var prefixCount int - database.DB.QueryRowContext(ctx, database.Q(` - SELECT COUNT(DISTINCT mc.model_id) - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) LIKE $1 - AND mc.visibility = 'enabled' - AND pc.is_active = true - `), normalized+"%").Scan(&prefixCount) - if prefixCount == 1 { - database.DB.QueryRowContext(ctx, database.Q(` - SELECT mc.model_id, mc.provider_config_id - FROM model_catalog mc - JOIN provider_configs pc ON pc.id = mc.provider_config_id - WHERE LOWER(mc.model_id) LIKE $1 - AND mc.visibility = 'enabled' - AND pc.is_active = true - ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END - LIMIT 1 - `), normalized+"%").Scan(&modelID, &provConfigID) - if modelID != "" { - log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID) - return modelID, provConfigID, nil, "" - } - } - - return "", "", nil, "" -} - -// extractFirstMention finds the first @token in content. -// Returns the token without the @ prefix, or empty string. -func extractFirstMention(content string) string { - for i := 0; i < len(content); i++ { - if content[i] != '@' { - continue - } - // @ must be at start or preceded by whitespace - if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' { - continue - } - // Extract token - start := i + 1 - end := start - for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' { - end++ - } - if end > start { - // Strip trailing punctuation - for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') { - end-- - } - if end > start { - return content[start:end] - } - } - } - return "" -} - -// ── Config Resolution ─────────────────────── -// Priority: request.provider_config_id → chat.provider_config_id → user's first active config - -func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) { - res, err := ResolveProviderConfig(h.stores, h.vault, userID, channelID, req.ProviderConfigID, req.Model) - if err != nil { - return providers.ProviderConfig{}, "", "", "", "", err - } - return res.Config, res.ProviderID, res.Model, res.ConfigID, res.ProviderScope, nil -} - -// ── Conversation Loader ───────────────────── - -// loadConversation builds the message history for the LLM by walking the -// active path through the message tree. Only the current branch is sent — -// sibling branches are never included in context. -// -// Summary-aware: if the path contains a summary node (metadata.type = "summary"), -// messages before it are replaced by the summary content as a system message. -func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) { - messages := make([]providers.Message, 0) - - // ── Admin system prompt (always injected first, no opt out) ── - adminPrompt, err := h.stores.GlobalConfig.Get(context.Background(), "system_prompt") - if err == nil { - if content, ok := adminPrompt["content"].(string); ok && content != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: content, - }) - } - } - - // ── User/persona system prompt (appended after admin prompt) ── - var systemPrompt *string - _ = database.DB.QueryRow( - database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID, - ).Scan(&systemPrompt) - - // Persona system prompt takes priority; channel system prompt is fallback - if personaSystemPrompt != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: personaSystemPrompt, - }) - } else if systemPrompt != nil && *systemPrompt != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: *systemPrompt, - }) - } - - // ── Project system prompt (v0.19.1) ── - if h.stores.Projects != nil { - projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID) - if projID != "" { - if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil { - if sp, ok := proj.Settings["system_prompt"].(string); ok && sp != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: sp, - }) - } - } - } - } - - // ── Pre-completion filter chain (v0.29.0) ── - // Runs registered filters (KB auto-inject, future extension filters). - // Replaces the inline BuildKBHint call. Each filter contributes zero - // or more system messages. Failures are logged and skipped. - if h.filterChain != nil && h.filterChain.Len() > 0 { - cc := &filters.CompletionContext{ - ChannelID: channelID, - UserID: userID, - PersonaID: personaID, - } - for _, injected := range h.filterChain.Execute(context.Background(), cc) { - messages = append(messages, providers.Message{ - Role: injected.Role, - Content: injected.Content, - }) - } - } - - // ── Memory hint (inject known facts about this user — v0.18.0) ── - // We pass empty lastUserMessage here; the current message content is available - // at the call site but not in loadConversation. Semantic recall will improve - // when the hint is built closer to the user's message. - if memHint := BuildMemoryHint(context.Background(), h.stores, h.embedder, userID, personaID, ""); memHint != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: memHint, - }) - } - - // ── Participant roster (v0.23.0) ── - // Inject the list of available @mentionable personas so the LLM - // knows who it can direct responses to. This enables LLM→LLM chaining. - if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: participantHint, - }) - } - - // Walk the active path (root → leaf) instead of loading all messages - path, err := getActivePath(channelID, userID) - if err != nil { - return nil, err - } - - // Find the most recent summary node in the path - summaryIdx := -1 - for i, m := range path { - if isSummaryMessage(&m) { - summaryIdx = i - } - } - - // If we found a summary, inject it as a system message and skip prior messages - startIdx := 0 - if summaryIdx >= 0 { - messages = append(messages, providers.Message{ - Role: "system", - Content: "Previous conversation summary:\n" + path[summaryIdx].Content, - }) - startIdx = summaryIdx + 1 - } - - // ── Build persona name cache for history rewrite (v0.23.0) ── - // Collect unique persona IDs from the path so we can attribute - // foreign persona messages by name instead of raw UUID. - personaNameCache := make(map[string]string) - hasForeignPersona := false - { - seen := make(map[string]bool) - for _, m := range path { - if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID { - if !seen[m.ParticipantID] { - seen[m.ParticipantID] = true - } - } - } - for pid := range seen { - var name string - database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name) - if name != "" { - personaNameCache[pid] = name - } - } - } - - for _, m := range path[startIdx:] { - if m.Role == "system" { - continue // system prompts handled above - } - // Skip summary nodes that aren't the boundary (shouldn't happen, but defensive) - if isSummaryMessage(&m) { - continue - } - - // ── Foreign speaker attribution (v0.23.0) ── - // ALL assistant messages get a Name field identifying who generated them. - // Providers that support "name" (OpenAI, OpenRouter, Venice) use it - // to distinguish speakers. Anthropic rewrites named foreign messages - // to user role with attribution. - msg := providers.Message{ - Role: m.Role, - Content: m.Content, - } - if m.Role == "assistant" { - isForeign := false - - if m.ParticipantType == "persona" && m.ParticipantID != "" { - if m.ParticipantID != personaID { - // Foreign persona — tag with name - isForeign = true - name := personaNameCache[m.ParticipantID] - if name != "" { - msg.Name = name - } - } - } else if m.Model != nil && *m.Model != "" { - // Raw model — foreign if model differs or we're a persona - if *m.Model != currentModel || personaID != "" { - isForeign = true - msg.Name = *m.Model - } - } - - if isForeign { - hasForeignPersona = true - } - } - - messages = append(messages, msg) - } - - // If foreign persona messages exist, add a system hint explaining - // the multi-participant context (helps all providers) - if hasForeignPersona { - hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters." - // Insert after the last system message, before conversation history - insertIdx := 0 - for i, m := range messages { - if m.Role == "system" { - insertIdx = i + 1 - } else { - break - } - } - messages = append(messages[:insertIdx+1], messages[insertIdx:]...) - messages[insertIdx] = providers.Message{Role: "system", Content: hint} - } - - return messages, nil -} - -// ── Message Persistence ───────────────────── - -// recordToolFileRefs creates file records with origin=tool_output for workspace -// files produced by tools during a completion. Links each file to the assistant -// message so the UI can show "files generated by this response". -func (h *CompletionHandler) recordToolFileRefs(ctx context.Context, refs []FileRef, channelID, userID, asstMsgID string) { - if len(refs) == 0 || asstMsgID == "" { - return - } - for _, ref := range refs { - f := &models.File{ - ChannelID: channelID, - UserID: userID, - Origin: models.FileOriginToolOutput, - Filename: ref.Path, - ContentType: "application/octet-stream", - DisplayHint: "download", - } - if asstMsgID != "" { - f.MessageID = &asstMsgID - } - if err := h.stores.Files.Create(ctx, f); err != nil { - log.Printf("[file_ref] Failed to record tool_output file ref %s: %v", ref.Path, err) - } - } -} - -// persistMessage inserts a message into the tree, updates the cursor, and -// returns the new message's ID. -// -// Parent resolution: -// - parentOverride (explicit parent for edit/regen operations) -// - cursor's active_leaf_id (normal conversation flow) -// - latest message by created_at (fallback for legacy/missing cursor) -// toolActivityJSON marshals tool activity for persistence. Returns nil if empty. -func toolActivityJSON(activity []map[string]interface{}) json.RawMessage { - if len(activity) == 0 { - return nil - } - b, _ := json.Marshal(activity) - return b -} - -func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) { - var tokensUsed *int - if inputTokens > 0 || outputTokens > 0 { - total := inputTokens + outputTokens - tokensUsed = &total - } - - // Determine parent - var parentID *string - if parentOverride != nil { - parentID = parentOverride - } else { - parentID, _ = getActiveLeaf(channelID, userID) - } - - // Compute sibling_index - siblingIdx := nextSiblingIndex(channelID, parentID) - - // Determine participant: persona for assistant messages with personaID, otherwise user/model - participantType := "user" - participantID := userID - if role == "assistant" { - if personaID != "" { - participantType = "persona" - participantID = personaID - } else { - participantType = "model" - participantID = model - } - } - - // provider_config_id: nil if empty - var provCfgVal interface{} - if providerConfigID != "" { - provCfgVal = providerConfigID - } - - // Prepare tool_calls JSONB (nil → NULL) - var tcVal interface{} - if len(toolCallsJSON) > 0 { - tcVal = string(toolCallsJSON) - } - - // Insert with RETURNING id (Postgres) or pre-generated id (SQLite) - var newID string - if database.IsSQLite() { - newID = store.NewID() - _, err := database.DB.Exec(` - INSERT INTO messages (id, channel_id, role, content, model, tokens_used, - tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index) - VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?) - `, newID, channelID, role, content, model, tokensUsed, - tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx) - if err != nil { - return "", err - } - } else { - err := database.DB.QueryRow(` - INSERT INTO messages (channel_id, role, content, model, tokens_used, - tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index) - VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11) - RETURNING id - `, channelID, role, content, model, tokensUsed, - tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx, - ).Scan(&newID) - if err != nil { - return "", err - } - } - - // Update cursor to point at new message - if userID != "" { - _ = updateCursor(channelID, userID, newID) - } - - // Touch channel updated_at - _, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID) - return newID, nil -} - -// ── Usage Logging ───────────────────────── - -// logUsage records token usage and cost in the usage_log table. -// Always logs even if tokens are zero — the request happened. -func (h *CompletionHandler) logUsage( - c *gin.Context, - channelID, userID, configID, providerScope, modelID string, - inputTokens, outputTokens, cacheCreation, cacheRead int, -) { - if h.stores.Usage == nil { - return - } - - entry := &models.UsageEntry{ - ChannelID: &channelID, - UserID: userID, - ProviderConfigID: &configID, - ProviderScope: providerScope, - ModelID: modelID, - PromptTokens: inputTokens, - CompletionTokens: outputTokens, - CacheCreationTokens: cacheCreation, - CacheReadTokens: cacheRead, - } - - // Look up pricing for cost calculation - if h.stores.Pricing != nil { - pricing, err := h.stores.Pricing.GetForModel(c.Request.Context(), configID, modelID) - if err == nil && pricing != nil { - entry.CostInput = calcCost(inputTokens, pricing.InputPerM) - entry.CostOutput = calcCost(outputTokens, pricing.OutputPerM) - } - } - - if err := h.stores.Usage.Log(c.Request.Context(), entry); err != nil { - log.Printf("⚠ Failed to log usage: %v", err) - } - - // v0.33.0: Prometheus token counters - metrics.CompletionTokensTotal.WithLabelValues("prompt", modelID).Add(float64(inputTokens)) - metrics.CompletionTokensTotal.WithLabelValues("completion", modelID).Add(float64(outputTokens)) - - // v0.33.0: Structured usage log for observability correlation - correlationID, _ := c.Get("request_id") - slog.Info("completion.usage", - "request_id", correlationID, - "model_id", modelID, - "provider_config_id", configID, - "input_tokens", inputTokens, - "output_tokens", outputTokens, - ) -} - -// calcCost computes the cost for a given token count and price-per-million. -func calcCost(tokens int, pricePerM *float64) *float64 { - if pricePerM == nil || tokens == 0 { - return nil - } - cost := float64(tokens) * *pricePerM / 1_000_000.0 - return &cost -} - -// recordHealth records provider call outcome for health tracking (v0.22.0) -// and Prometheus completion metrics (v0.33.0). -func (h *CompletionHandler) recordHealth(configID string, start time.Time, err error) { - duration := time.Since(start) - latencyMs := int(duration.Milliseconds()) - - // v0.33.0: Prometheus completion duration (always, even on error) - if configID != "" { - metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds()) - } - - if h.health == nil || configID == "" { - return - } - if err != nil { - errMsg := err.Error() - status := "error" - if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") { - h.health.RecordTimeout(configID, latencyMs, errMsg) - status = "timeout" - } else if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") { - h.health.RecordRateLimit(configID, latencyMs, errMsg) - status = "rate_limited" - } else { - h.health.RecordError(configID, latencyMs, errMsg) - } - metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc() - } else { - h.health.RecordSuccess(configID, latencyMs) - metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc() - } -} - -// truncateContent returns content truncated to maxLen runes with ellipsis. -func truncateContent(s string, maxLen int) string { - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - return string(runes[:maxLen]) + "…" -} diff --git a/server/handlers/completion_chain.go b/server/handlers/completion_chain.go deleted file mode 100644 index e411d8d..0000000 --- a/server/handlers/completion_chain.go +++ /dev/null @@ -1,324 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "log" - "time" - - "switchboard-core/events" - "switchboard-core/models" - "switchboard-core/providers" - - capspkg "switchboard-core/capabilities" -) - -// maxChainDepth limits AI-to-AI chaining to prevent runaway loops. -const maxChainDepth = 5 - -// chainIfMentioned checks if an assistant response @mentions another -// persona and triggers a follow-up completion. Uses resolveMention() -// directly — no roster or participant list needed. Works in any chat. -// -// Runs asynchronously (goroutine) after the SSE stream closes. -// The follow-up response is delivered to the client via WebSocket events. -func (h *CompletionHandler) chainIfMentioned( - channelID, userID, currentPersonaID, responseContent string, - depth int, -) { - if depth >= maxChainDepth { - return - } - - defer func() { - if r := recover(); r != nil { - log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r) - } - }() - - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - // Extract the @token for logging - token := extractFirstMention(responseContent) - if token == "" { - log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))]) - return - } - log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth) - - // Use the same resolveMention() that handles user @mentions - mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent) - if mentionModel == "" { - log.Printf("[chain] @%s did not resolve to any model", token) - return - } - if mentionPersona == nil { - log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel) - return // no persona @mention found, or it's a raw model (no chain for those) - } - - // Don't chain to self - if mentionPersona.ID == currentPersonaID { - log.Printf("[chain] @%s is self-mention, skipping", token) - return - } - - log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1) - - // Emit typing indicator - if h.hub != nil { - h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true) - defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false) - } - - // Brief pause for UX - time.Sleep(500 * time.Millisecond) - - // Resolve provider config - configID := mentionConfig - chainReq := completionRequest{ - ChannelID: channelID, - Model: mentionModel, - ProviderConfigID: configID, - } - providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq) - if err != nil { - log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err) - return - } - configID = resolvedConfigID - - provider, err := providers.Get(providerID) - if err != nil { - log.Printf("[chain] Provider %s unavailable: %v", providerID, err) - return - } - - // Load conversation with persona's system prompt - messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model) - if err != nil { - log.Printf("[chain] Failed to load conversation: %v", err) - return - } - - // Context boundary - boundary := fmt.Sprintf( - "[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]", - mentionPersona.Name, - ) - messages = append(messages, providers.Message{Role: "system", Content: boundary}) - - caps := capspkg.ResolveIntrinsic(model, nil, nil) - - provReq := providers.CompletionRequest{ - Model: model, - Messages: messages, - MaxTokens: capspkg.ResolveMaxOutput(model, caps), - } - if mentionPersona.Temperature != nil { - provReq.Temperature = mentionPersona.Temperature - } - if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 { - if providerCfg.Settings == nil { - providerCfg.Settings = make(map[string]interface{}) - } - providerCfg.Settings["extended_thinking"] = true - providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget - } - - // Apply provider-specific hooks - if hooks := providers.GetHooks(providerID); hooks != nil { - hooks.PreRequest(providerCfg, &provReq) - } - - // Synchronous completion — result delivered via WebSocket - callStart := time.Now() - resp, err := provider.ChatCompletion(ctx, providerCfg, provReq) - latencyMs := int(time.Since(callStart).Milliseconds()) - if err != nil { - log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err) - if h.health != nil { - h.health.RecordError(configID, latencyMs, err.Error()) - } - return - } - if h.health != nil { - h.health.RecordSuccess(configID, latencyMs) - } - - if resp.Content == "" { - return - } - - // Persist - msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, - resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID) - if err != nil { - log.Printf("[chain] Failed to persist chained message: %v", err) - return - } - - // Deliver via WebSocket to all channel participants - if h.hub != nil { - broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", mentionPersona.ID) - } - - // Log usage - if h.stores.Usage != nil { - entry := &models.UsageEntry{ - ChannelID: &channelID, - UserID: userID, - ProviderConfigID: &configID, - ProviderScope: "global", - ModelID: model, - PromptTokens: resp.InputTokens, - CompletionTokens: resp.OutputTokens, - } - _ = h.stores.Usage.Log(ctx, entry) - } - - log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s", - mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))]) - - // Recursive: check if this response @mentions yet another persona - h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1) -} - -// chainToPersona triggers a completion for a specific persona by ID. -// Used by @all fan-out. Does NOT recurse (depth-1 only). -func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) { - defer func() { - if r := recover(); r != nil { - log.Printf("[chain-all] PANIC: %v", r) - } - }() - - persona := ResolvePersona(h.stores, personaID, userID) - if persona == nil { - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - if h.hub != nil { - h.emitTyping(userID, channelID, persona.ID, persona.Name, true) - defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false) - } - - configID := "" - if persona.ProviderConfigID != nil { - configID = *persona.ProviderConfigID - } - chainReq := completionRequest{ - ChannelID: channelID, - Model: persona.BaseModelID, - ProviderConfigID: configID, - } - providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq) - if err != nil { - log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err) - return - } - configID = resolvedConfigID - - provider, err := providers.Get(providerID) - if err != nil { - return - } - - messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model) - if err != nil { - return - } - - boundary := fmt.Sprintf( - "[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]", - persona.Name, - ) - messages = append(messages, providers.Message{Role: "system", Content: boundary}) - - caps := capspkg.ResolveIntrinsic(model, nil, nil) - provReq := providers.CompletionRequest{ - Model: model, - Messages: messages, - MaxTokens: capspkg.ResolveMaxOutput(model, caps), - } - if persona.Temperature != nil { - provReq.Temperature = persona.Temperature - } - if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 { - if providerCfg.Settings == nil { - providerCfg.Settings = make(map[string]interface{}) - } - providerCfg.Settings["extended_thinking"] = true - providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget - } - - if hooks := providers.GetHooks(providerID); hooks != nil { - hooks.PreRequest(providerCfg, &provReq) - } - - callStart := time.Now() - resp, err := provider.ChatCompletion(ctx, providerCfg, provReq) - latencyMs := int(time.Since(callStart).Milliseconds()) - if err != nil { - if h.health != nil { - h.health.RecordError(configID, latencyMs, err.Error()) - } - return - } - if h.health != nil { - h.health.RecordSuccess(configID, latencyMs) - } - if resp.Content == "" { - return - } - - msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, - resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID) - if err != nil { - return - } - - if h.hub != nil { - broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", persona.ID) - } - - if h.stores.Usage != nil { - entry := &models.UsageEntry{ - ChannelID: &channelID, - UserID: userID, - ProviderConfigID: &configID, - ProviderScope: "global", - ModelID: model, - PromptTokens: resp.InputTokens, - CompletionTokens: resp.OutputTokens, - } - _ = h.stores.Usage.Log(ctx, entry) - } - - log.Printf("[chain-all] ✅ %s responded (%d tokens) in channel %s", - persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))]) -} - -// emitTyping sends a typing indicator via WebSocket. -func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) { - eventType := "typing.start" - if !start { - eventType = "typing.stop" - } - payload, _ := json.Marshal(map[string]string{ - "channel_id": channelID, - "participant_id": participantID, - "participant_type": "persona", - "display_name": displayName, - }) - h.hub.PublishToUser(userID, events.Event{ - Label: eventType, - Payload: payload, - Ts: time.Now().UnixMilli(), - }) -} diff --git a/server/handlers/dashboard_admin.go b/server/handlers/dashboard_admin.go deleted file mode 100644 index 7c726b6..0000000 --- a/server/handlers/dashboard_admin.go +++ /dev/null @@ -1,118 +0,0 @@ -package handlers - -import ( - "database/sql" - "net/http" - "runtime" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/database" - "switchboard-core/events" - "switchboard-core/health" - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Dashboard Admin Handler ───────────────── -// GET /api/v1/admin/dashboard -// Aggregates live operational data for the built-in admin monitoring page. - -var processStartTime = time.Now() - -type DashboardAdminHandler struct { - stores store.Stores - healthStore health.Store - hub *events.Hub -} - -func NewDashboardAdminHandler(stores store.Stores, hs health.Store, hub *events.Hub) *DashboardAdminHandler { - return &DashboardAdminHandler{stores: stores, healthStore: hs, hub: hub} -} - -type runtimeStats struct { - Goroutines int `json:"goroutines"` - HeapMB int `json:"heap_mb"` - SysMB int `json:"sys_mb"` - NumGC uint32 `json:"num_gc"` - GoVersion string `json:"go_version"` -} - -func (h *DashboardAdminHandler) GetDashboard(c *gin.Context) { - ctx := c.Request.Context() - - // Provider health summaries - var providerHealth []models.ProviderHealthSummary - windows, err := h.healthStore.ListAllCurrent(ctx) - if err == nil { - for _, w := range windows { - providerHealth = append(providerHealth, models.ProviderHealthSummary{ - ProviderConfigID: w.ProviderConfigID, - Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount), - RequestCount: w.RequestCount, - ErrorRate: w.ErrorRate(), - ErrorCount: w.ErrorCount, - RateLimitCount: w.RateLimitCount, - TimeoutCount: w.TimeoutCount, - AvgLatencyMs: w.AvgLatencyMs(), - MaxLatencyMs: w.MaxLatencyMs, - LastError: w.LastError, - LastErrorAt: w.LastErrorAt, - }) - } - } - - // 24h usage totals - since := time.Now().Add(-24 * time.Hour) - totals, _ := h.stores.Usage.GetTotals(ctx, store.UsageQueryOptions{ - Since: &since, - }) - - // DB pool stats - var dbPool *sql.DBStats - if database.DB != nil { - stats := database.DB.Stats() - dbPool = &stats - } - - // WebSocket connections - wsCount := 0 - if h.hub != nil { - wsCount = h.hub.ConnCount() - } - - // Recent errors from audit log (last 10 error-related entries) - var recentErrors []models.AuditEntry - entries, _, auditErr := h.stores.Audit.List(ctx, store.AuditListOptions{ - ListOptions: store.ListOptions{Limit: 10}, - ResourceType: "error", - }) - if auditErr == nil { - recentErrors = entries - } - - // Go runtime stats - var mem runtime.MemStats - runtime.ReadMemStats(&mem) - rt := runtimeStats{ - Goroutines: runtime.NumGoroutine(), - HeapMB: int(mem.HeapAlloc / 1024 / 1024), - SysMB: int(mem.Sys / 1024 / 1024), - NumGC: mem.NumGC, - GoVersion: runtime.Version(), - } - - // Uptime - uptime := time.Since(processStartTime).Truncate(time.Second).String() - - SafeJSON(c, http.StatusOK, gin.H{ - "provider_health": providerHealth, - "usage_24h": totals, - "db_pool": dbPool, - "ws_connections": wsCount, - "recent_errors": recentErrors, - "runtime": rt, - "uptime": uptime, - }) -} diff --git a/server/handlers/export.go b/server/handlers/export.go deleted file mode 100644 index 662cc51..0000000 --- a/server/handlers/export.go +++ /dev/null @@ -1,110 +0,0 @@ -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) -} diff --git a/server/handlers/export_data.go b/server/handlers/export_data.go deleted file mode 100644 index d80bcd1..0000000 --- a/server/handlers/export_data.go +++ /dev/null @@ -1,565 +0,0 @@ -package handlers - -// export_data.go — v0.34.0 CS0 -// -// Data export endpoints: user data export (GDPR "download my data") -// and team data export (admin). Streams .switchboard zip archives -// directly to the HTTP response. - -import ( - "fmt" - "log/slog" - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/export" - "switchboard-core/models" - "switchboard-core/storage" - "switchboard-core/store" -) - -// DataExportHandler serves data export endpoints. -type DataExportHandler struct { - stores store.Stores - objStore storage.ObjectStore -} - -// NewDataExportHandler creates a new handler for data export operations. -func NewDataExportHandler(s store.Stores, obj storage.ObjectStore) *DataExportHandler { - return &DataExportHandler{stores: s, objStore: obj} -} - -// ExportMyData streams the requesting user's data as a .switchboard zip. -// GET /api/v1/export/me -func (h *DataExportHandler) ExportMyData(c *gin.Context) { - ctx := c.Request.Context() - userID := c.GetString("user_id") - - // ── Fetch user ── - user, err := h.stores.Users.GetByID(ctx, userID) - if err != nil || user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) - return - } - - // ── Fetch all user-scoped entities ── - channels, err := h.stores.Export.UserChannels(ctx, userID) - if err != nil { - slog.Error("export: fetch channels", "error", err, "user_id", userID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channels"}) - return - } - - channelIDs := make([]string, len(channels)) - for i, ch := range channels { - channelIDs[i] = ch.ID - } - - messages, err := h.stores.Export.UserMessages(ctx, channelIDs) - if err != nil { - slog.Error("export: fetch messages", "error", err, "user_id", userID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export messages"}) - return - } - - participants, err := h.stores.Export.UserChannelParticipants(ctx, channelIDs) - if err != nil { - slog.Error("export: fetch participants", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export participants"}) - return - } - - channelModels, err := h.stores.Export.UserChannelModels(ctx, channelIDs) - if err != nil { - slog.Error("export: fetch channel models", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channel models"}) - return - } - - cursors, err := h.stores.Export.UserChannelCursors(ctx, userID, channelIDs) - if err != nil { - slog.Error("export: fetch cursors", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export cursors"}) - return - } - - notes, err := h.stores.Export.UserNotes(ctx, userID) - if err != nil { - slog.Error("export: fetch notes", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notes"}) - return - } - - noteIDs := make([]string, len(notes)) - for i, n := range notes { - noteIDs[i] = n.ID - } - - noteLinks, err := h.stores.Export.UserNoteLinks(ctx, noteIDs) - if err != nil { - slog.Error("export: fetch note links", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export note links"}) - return - } - - memories, err := h.stores.Export.UserMemories(ctx, userID) - if err != nil { - slog.Error("export: fetch memories", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export memories"}) - return - } - - projects, err := h.stores.Export.UserProjects(ctx, userID) - if err != nil { - slog.Error("export: fetch projects", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export projects"}) - return - } - - projectIDs := make([]string, len(projects)) - for i, p := range projects { - projectIDs[i] = p.ID - } - - projectChannels, err := h.stores.Export.UserProjectChannels(ctx, projectIDs) - if err != nil { - slog.Error("export: fetch project channels", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project channels"}) - return - } - - projectKBs, err := h.stores.Export.UserProjectKBs(ctx, projectIDs) - if err != nil { - slog.Error("export: fetch project KBs", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project KBs"}) - return - } - - projectNotes, err := h.stores.Export.UserProjectNotes(ctx, projectIDs) - if err != nil { - slog.Error("export: fetch project notes", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project notes"}) - return - } - - workspaces, err := h.stores.Export.UserWorkspaces(ctx, userID) - if err != nil { - slog.Error("export: fetch workspaces", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspaces"}) - return - } - - workspaceIDs := make([]string, len(workspaces)) - for i, w := range workspaces { - workspaceIDs[i] = w.ID - } - - workspaceFiles, err := h.stores.Export.UserWorkspaceFiles(ctx, workspaceIDs) - if err != nil { - slog.Error("export: fetch workspace files", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspace files"}) - return - } - - files, err := h.stores.Export.UserFiles(ctx, userID) - if err != nil { - slog.Error("export: fetch files", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export files"}) - return - } - - folders, err := h.stores.Export.UserFolders(ctx, userID) - if err != nil { - slog.Error("export: fetch folders", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export folders"}) - return - } - - userModelSettings, err := h.stores.Export.UserModelSettings(ctx, userID) - if err != nil { - slog.Error("export: fetch user model settings", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export user settings"}) - return - } - - notifPrefs, err := h.stores.Export.UserNotifPrefs(ctx, userID) - if err != nil { - slog.Error("export: fetch notification prefs", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notification prefs"}) - return - } - - usageEntries, err := h.stores.Export.UserUsageEntries(ctx, userID) - if err != nil { - slog.Error("export: fetch usage entries", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export usage entries"}) - return - } - - personaGroups, err := h.stores.Export.UserPersonaGroups(ctx, userID) - if err != nil { - slog.Error("export: fetch persona groups", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona groups"}) - return - } - - pgIDs := make([]string, len(personaGroups)) - for i, pg := range personaGroups { - pgIDs[i] = pg.ID - } - - personaGroupMembers, err := h.stores.Export.UserPersonaGroupMembers(ctx, pgIDs) - if err != nil { - slog.Error("export: fetch persona group members", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona group members"}) - return - } - - // ── Build manifest ── - sanitizedUser := export.SanitizeUser(user) - sanitizedChannels := export.SanitizeChannels(channels) - sanitizedMessages := export.SanitizeMessages(messages) - sanitizedChannelModels := export.SanitizeChannelModels(channelModels) - sanitizedUserModelSettings := export.SanitizeUserModelSettings(userModelSettings) - sanitizedUsageEntries := export.SanitizeUsageEntries(usageEntries) - sanitizedWorkspaces := export.SanitizeWorkspaces(workspaces) - - counts := map[string]int{ - "users": 1, - "channels": len(channels), - "messages": len(messages), - "channel_participants": len(participants), - "channel_models": len(channelModels), - "channel_cursors": len(cursors), - "notes": len(notes), - "note_links": len(noteLinks), - "memories": len(memories), - "projects": len(projects), - "project_channels": len(projectChannels), - "project_knowledge_bases": len(projectKBs), - "project_notes": len(projectNotes), - "workspaces": len(workspaces), - "workspace_files": len(workspaceFiles), - "files": len(files), - "folders": len(folders), - "user_settings": len(userModelSettings), - "notification_preferences": len(notifPrefs), - "usage_entries": len(usageEntries), - "persona_groups": len(personaGroups), - "persona_group_members": len(personaGroupMembers), - } - - manifest := &export.Manifest{ - Version: "0.34.0", - FormatVersion: export.FormatVersion, - ExportType: "user", - CreatedAt: time.Now().UTC(), - ExportedBy: userID, - EntityCounts: counts, - Scope: &export.ManifestScope{UserID: userID}, - } - - // ── Stream zip to response ── - filename := fmt.Sprintf("switchboard-export-%s%s", userID[:8], export.ExportExtension) - c.Header("Content-Type", export.ExportContentType) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) - - aw := export.NewArchiveWriter(c.Writer) - defer aw.Close() - - if err := aw.WriteManifest(manifest); err != nil { - slog.Error("export: write manifest", "error", err) - return - } - - // Write entity JSON files - writeEntity := func(name string, data interface{}) { - if _, err := aw.WriteEntityJSON(name, data); err != nil { - slog.Error("export: write entity", "name", name, "error", err) - } - } - - writeEntity("users", []interface{}{sanitizedUser}) - writeEntity("channels", sanitizedChannels) - writeEntity("messages", sanitizedMessages) - writeEntity("channel_participants", participants) - writeEntity("channel_models", sanitizedChannelModels) - writeEntity("channel_cursors", cursors) - writeEntity("notes", notes) - writeEntity("note_links", noteLinks) - writeEntity("memories", memories) - writeEntity("projects", projects) - writeEntity("project_channels", projectChannels) - writeEntity("project_knowledge_bases", projectKBs) - writeEntity("project_notes", projectNotes) - writeEntity("workspaces", sanitizedWorkspaces) - writeEntity("workspace_files", workspaceFiles) - writeEntity("folders", folders) - writeEntity("user_settings", sanitizedUserModelSettings) - writeEntity("notification_preferences", notifPrefs) - writeEntity("usage_entries", sanitizedUsageEntries) - writeEntity("persona_groups", personaGroups) - writeEntity("persona_group_members", personaGroupMembers) - - // Write file metadata (without storage_key — already excluded by json:"-" tag) - writeEntity("files", files) - - // ── Stream file blobs ── - var warnings []string - if h.objStore != nil { - fileCount := 0 - for _, f := range files { - if fileCount >= export.MaxExportFiles { - warnings = append(warnings, "file blob limit reached, some files skipped") - break - } - if f.SizeBytes > export.MaxExportFileSize { - warnings = append(warnings, fmt.Sprintf("file %s too large (%d bytes), skipped", f.Filename, f.SizeBytes)) - continue - } - rc, _, _, err := h.objStore.Get(ctx, f.StorageKey) - if err != nil { - warnings = append(warnings, fmt.Sprintf("file %s not found in storage, skipped", f.Filename)) - continue - } - zipPath := f.ID + "/" + f.Filename - if _, err := aw.WriteFile(zipPath, rc); err != nil { - rc.Close() - warnings = append(warnings, fmt.Sprintf("file %s write error: %v, skipped", f.Filename, err)) - continue - } - rc.Close() - fileCount++ - } - } - - if len(warnings) > 0 { - writeEntity("export_warnings", warnings) - } - - // Audit log - h.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: &userID, - Action: "user.export", - ResourceType: "user", - ResourceID: userID, - Metadata: models.JSONMap{"entity_counts": counts}, - }) -} - -// ExportTeam streams a team's data as a .switchboard zip. -// GET /api/v1/admin/teams/:id/export -func (h *DataExportHandler) ExportTeam(c *gin.Context) { - ctx := c.Request.Context() - userID := c.GetString("user_id") - teamID := c.Param("id") - - // Fetch team to verify it exists - team, err := h.stores.Teams.GetByID(ctx, teamID) - if err != nil || team == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) - return - } - - // Fetch all team-scoped entities - channels, err := h.stores.Export.TeamChannels(ctx, teamID) - if err != nil { - slog.Error("export: fetch team channels", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team channels"}) - return - } - - channelIDs := make([]string, len(channels)) - for i, ch := range channels { - channelIDs[i] = ch.ID - } - - messages, err := h.stores.Export.UserMessages(ctx, channelIDs) - if err != nil { - slog.Error("export: fetch team messages", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team messages"}) - return - } - - members, err := h.stores.Export.TeamMembers(ctx, teamID) - if err != nil { - slog.Error("export: fetch team members", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team members"}) - return - } - - personas, err := h.stores.Export.TeamPersonas(ctx, teamID) - if err != nil { - slog.Error("export: fetch team personas", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team personas"}) - return - } - - personaIDs := make([]string, len(personas)) - for i, p := range personas { - personaIDs[i] = p.ID - } - - personaKBs, err := h.stores.Export.TeamPersonaKBs(ctx, personaIDs) - if err != nil { - slog.Error("export: fetch persona KBs", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona KBs"}) - return - } - - kbs, err := h.stores.Export.TeamKnowledgeBases(ctx, teamID) - if err != nil { - slog.Error("export: fetch team KBs", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team KBs"}) - return - } - - kbIDs := make([]string, len(kbs)) - for i, kb := range kbs { - kbIDs[i] = kb.ID - } - - kbDocs, err := h.stores.Export.TeamKBDocuments(ctx, kbIDs) - if err != nil { - slog.Error("export: fetch KB docs", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export KB documents"}) - return - } - - workflows, err := h.stores.Export.TeamWorkflows(ctx, teamID) - if err != nil { - slog.Error("export: fetch workflows", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflows"}) - return - } - - workflowIDs := make([]string, len(workflows)) - for i, w := range workflows { - workflowIDs[i] = w.ID - } - - workflowVersions, err := h.stores.Export.TeamWorkflowVersions(ctx, workflowIDs) - if err != nil { - slog.Error("export: fetch workflow versions", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow versions"}) - return - } - - workflowStages, err := h.stores.Export.TeamWorkflowStages(ctx, workflowIDs) - if err != nil { - slog.Error("export: fetch workflow stages", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow stages"}) - return - } - - groups, err := h.stores.Export.TeamGroups(ctx, teamID) - if err != nil { - slog.Error("export: fetch groups", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export groups"}) - return - } - - groupIDs := make([]string, len(groups)) - for i, g := range groups { - groupIDs[i] = g.ID - } - - groupMembers, err := h.stores.Export.TeamGroupMembers(ctx, groupIDs) - if err != nil { - slog.Error("export: fetch group members", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export group members"}) - return - } - - resourceGrants, err := h.stores.Export.TeamResourceGrants(ctx, teamID) - if err != nil { - slog.Error("export: fetch resource grants", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export resource grants"}) - return - } - - projects, err := h.stores.Export.TeamProjects(ctx, teamID) - if err != nil { - slog.Error("export: fetch team projects", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team projects"}) - return - } - - // Sanitize - sanitizedChannels := export.SanitizeChannels(channels) - sanitizedMessages := export.SanitizeMessages(messages) - sanitizedPersonas := export.SanitizePersonas(personas) - sanitizedWorkflows := export.SanitizeWorkflows(workflows) - - counts := map[string]int{ - "team": 1, - "channels": len(channels), - "messages": len(messages), - "members": len(members), - "personas": len(personas), - "persona_kbs": len(personaKBs), - "knowledge_bases": len(kbs), - "kb_documents": len(kbDocs), - "workflows": len(workflows), - "workflow_versions": len(workflowVersions), - "workflow_stages": len(workflowStages), - "groups": len(groups), - "group_members": len(groupMembers), - "resource_grants": len(resourceGrants), - "projects": len(projects), - } - - manifest := &export.Manifest{ - Version: "0.34.0", - FormatVersion: export.FormatVersion, - ExportType: "team", - CreatedAt: time.Now().UTC(), - ExportedBy: userID, - EntityCounts: counts, - Scope: &export.ManifestScope{TeamID: teamID}, - } - - filename := fmt.Sprintf("switchboard-team-%s%s", teamID[:8], export.ExportExtension) - c.Header("Content-Type", export.ExportContentType) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) - - aw := export.NewArchiveWriter(c.Writer) - defer aw.Close() - - if err := aw.WriteManifest(manifest); err != nil { - slog.Error("export: write manifest", "error", err) - return - } - - writeEntity := func(name string, data interface{}) { - if _, err := aw.WriteEntityJSON(name, data); err != nil { - slog.Error("export: write entity", "name", name, "error", err) - } - } - - writeEntity("team", []interface{}{team}) - writeEntity("channels", sanitizedChannels) - writeEntity("messages", sanitizedMessages) - writeEntity("members", members) - writeEntity("personas", sanitizedPersonas) - writeEntity("persona_knowledge_bases", personaKBs) - writeEntity("knowledge_bases", kbs) - writeEntity("kb_documents", kbDocs) - writeEntity("workflows", sanitizedWorkflows) - writeEntity("workflow_versions", workflowVersions) - writeEntity("workflow_stages", workflowStages) - writeEntity("groups", groups) - writeEntity("group_members", groupMembers) - writeEntity("resource_grants", resourceGrants) - writeEntity("projects", projects) - - h.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: &userID, - Action: "team.export", - ResourceType: "team", - ResourceID: teamID, - Metadata: models.JSONMap{"entity_counts": counts}, - }) -} diff --git a/server/handlers/ext_tools_test.go b/server/handlers/ext_tools_test.go deleted file mode 100644 index b4638af..0000000 --- a/server/handlers/ext_tools_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package handlers - -import ( - "encoding/json" - "testing" - - "go.starlark.net/starlark" -) - -// ── jsonToStarlark ──────────────────────────────────────────────────────────── - -func TestJSONToStarlark_String(t *testing.T) { - v := jsonToStarlark("hello") - s, ok := v.(starlark.String) - if !ok || string(s) != "hello" { - t.Errorf("expected starlark.String(hello), got %v (%T)", v, v) - } -} - -func TestJSONToStarlark_Int(t *testing.T) { - v := jsonToStarlark(float64(42)) - i, ok := v.(starlark.Int) - if !ok { - t.Fatalf("expected starlark.Int, got %T", v) - } - n, _ := i.Int64() - if n != 42 { - t.Errorf("expected 42, got %d", n) - } -} - -func TestJSONToStarlark_Float(t *testing.T) { - v := jsonToStarlark(3.14) - if _, ok := v.(starlark.Float); !ok { - t.Errorf("expected starlark.Float, got %T", v) - } -} - -func TestJSONToStarlark_Bool(t *testing.T) { - if jsonToStarlark(true) != starlark.True { - t.Error("expected starlark.True") - } - if jsonToStarlark(false) != starlark.False { - t.Error("expected starlark.False") - } -} - -func TestJSONToStarlark_Nil(t *testing.T) { - if jsonToStarlark(nil) != starlark.None { - t.Error("expected starlark.None for nil") - } -} - -func TestJSONToStarlark_Dict(t *testing.T) { - v := jsonToStarlark(map[string]interface{}{"key": "val"}) - d, ok := v.(*starlark.Dict) - if !ok { - t.Fatalf("expected *starlark.Dict, got %T", v) - } - got, found, _ := d.Get(starlark.String("key")) - if !found || string(got.(starlark.String)) != "val" { - t.Errorf("expected key=val in dict, got %v", got) - } -} - -func TestJSONToStarlark_List(t *testing.T) { - v := jsonToStarlark([]interface{}{"a", "b"}) - l, ok := v.(*starlark.List) - if !ok { - t.Fatalf("expected *starlark.List, got %T", v) - } - if l.Len() != 2 { - t.Errorf("expected 2 elements, got %d", l.Len()) - } -} - -// ── starlarkValueToGo ───────────────────────────────────────────────────────── - -func TestStarlarkValueToGo_String(t *testing.T) { - v := starlarkValueToGo(starlark.String("hi")) - if v != "hi" { - t.Errorf("expected 'hi', got %v", v) - } -} - -func TestStarlarkValueToGo_Int(t *testing.T) { - v := starlarkValueToGo(starlark.MakeInt(7)) - if v != int64(7) { - t.Errorf("expected int64(7), got %v (%T)", v, v) - } -} - -func TestStarlarkValueToGo_Bool(t *testing.T) { - if starlarkValueToGo(starlark.True) != true { - t.Error("expected true") - } -} - -func TestStarlarkValueToGo_None(t *testing.T) { - if starlarkValueToGo(starlark.None) != nil { - t.Error("expected nil for starlark.None") - } -} - -func TestStarlarkValueToGo_Dict(t *testing.T) { - d := starlark.NewDict(1) - _ = d.SetKey(starlark.String("x"), starlark.MakeInt(1)) - v := starlarkValueToGo(d) - m, ok := v.(map[string]interface{}) - if !ok { - t.Fatalf("expected map, got %T", v) - } - if m["x"] != int64(1) { - t.Errorf("expected x=1, got %v", m["x"]) - } -} - -func TestStarlarkValueToGo_List(t *testing.T) { - l := starlark.NewList([]starlark.Value{starlark.String("a"), starlark.String("b")}) - v := starlarkValueToGo(l) - arr, ok := v.([]interface{}) - if !ok { - t.Fatalf("expected []interface{}, got %T", v) - } - if len(arr) != 2 || arr[0] != "a" { - t.Errorf("unexpected list: %v", arr) - } -} - -// ── RoundTrip: JSON → Starlark → Go → JSON ─────────────────────────────────── - -func TestRoundTrip_JSONToStarlarkToJSON(t *testing.T) { - // Simulate what executeExtensionTool does: parse args, pass as Starlark, convert result back. - argsJSON := `{"query": "hello", "limit": 10, "active": true}` - var raw map[string]interface{} - if err := json.Unmarshal([]byte(argsJSON), &raw); err != nil { - t.Fatalf("unmarshal: %v", err) - } - sv := jsonToStarlark(raw) - got := starlarkValueToGo(sv) - out, err := json.Marshal(got) - if err != nil { - t.Fatalf("marshal: %v", err) - } - // Verify key fields present - var result map[string]interface{} - if err := json.Unmarshal(out, &result); err != nil { - t.Fatalf("unmarshal result: %v", err) - } - if result["query"] != "hello" { - t.Errorf("expected query=hello, got %v", result["query"]) - } - if result["active"] != true { - t.Errorf("expected active=true, got %v", result["active"]) - } -} - -// ── BuildExtToolMap ─────────────────────────────────────────────────────────── - -func TestBuildExtToolMap_Empty(t *testing.T) { - // nil Packages store → returns nil (no panic) - result := BuildExtToolMap(nil, storesWithExtData(newMemExtDataStore()), "user1") - if result != nil && len(result) != 0 { - t.Errorf("expected empty map for stores without Packages, got %v", result) - } -} diff --git a/server/handlers/files.go b/server/handlers/files.go deleted file mode 100644 index 4385b5e..0000000 --- a/server/handlers/files.go +++ /dev/null @@ -1,993 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/extraction" - "switchboard-core/models" - "switchboard-core/storage" - "switchboard-core/store" - "switchboard-core/workspace" -) - -// ── Default Limits ───────────────────────── -// Overridable via global_settings keys. -const ( - defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB - defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file) - defaultMaxFilesPerMsg = 5 // future: multi-file per message - defaultOrphanMaxAge = 24 * time.Hour -) - -// allowedMIMETypes is the default allowlist. Admin can override via global_settings. -var allowedMIMETypes = map[string]bool{ - // Images - "image/jpeg": true, "image/png": true, "image/gif": true, - "image/webp": true, "image/svg+xml": true, - // Documents - "application/pdf": true, - "text/plain": true, "text/markdown": true, "text/csv": true, - // Microsoft Office - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, - "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, - "application/msword": true, "application/vnd.ms-excel": true, - // OpenDocument - "application/vnd.oasis.opendocument.text": true, - "application/vnd.oasis.opendocument.spreadsheet": true, - "application/vnd.oasis.opendocument.presentation": true, - // Other - "application/rtf": true, -} - -// ── Handler ──────────────────────────────── - -type FileHandler struct { - stores store.Stores - objStore storage.ObjectStore - wfs *workspace.FS // nil if workspace FS not configured - extQueue *extraction.Queue // nil if extraction disabled -} - -func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler { - return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue} -} - -// SetWorkspaceFS attaches the workspace filesystem for project file operations. -func (h *FileHandler) SetWorkspaceFS(wfs *workspace.FS) { - h.wfs = wfs -} - -// ── Upload ───────────────────────────────── -// POST /api/v1/channels/:id/files -// Multipart form: file field "file", returns file metadata. -func (h *FileHandler) Upload(c *gin.Context) { - if h.objStore == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - channelID := c.Param("id") - - // Verify channel ownership - if !h.verifyChannelAccess(c, channelID, userID) { - return - } - - // Parse multipart - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) - return - } - defer file.Close() - - // Size check - if header.Size > defaultMaxFileSize { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)), - }) - return - } - - // MIME detection: read first 512 bytes for sniffing, then reset - buf := make([]byte, 512) - n, _ := file.Read(buf) - detectedType := http.DetectContentType(buf[:n]) - - // Reset reader to start - if _, err := file.Seek(0, io.SeekStart); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"}) - return - } - - // Normalize MIME type (strip params like charset) - contentType := detectedType - if idx := strings.Index(contentType, ";"); idx > 0 { - contentType = strings.TrimSpace(contentType[:idx]) - } - - // For types that DetectContentType can't distinguish (returns application/octet-stream), - // fall back to extension-based detection - if contentType == "application/octet-stream" { - ext := strings.ToLower(filepath.Ext(header.Filename)) - if mapped, ok := extToMIME[ext]; ok { - contentType = mapped - } - } - - // Allowlist check - if !allowedMIMETypes[contentType] { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("file type %q not allowed", contentType), - }) - return - } - - // Build storage key: files are stored under files/{channel_id}/{file_id}_{filename} - // We generate the ID first via a temp UUID, then use it in the key. - att := &models.File{ - ChannelID: channelID, - UserID: userID, - Origin: models.FileOriginUserUpload, - Filename: sanitizeFilename(header.Filename), - ContentType: contentType, - SizeBytes: header.Size, - DisplayHint: displayHintFor(contentType), - Metadata: models.JSONMap{ - "extraction_status": "pending", - }, - } - - // Create PG row first to get the UUID - // storage_key is set after we have the ID - att.StorageKey = "placeholder" - if err := h.stores.Files.Create(c.Request.Context(), att); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"}) - return - } - - // Now build the real storage key and update - att.StorageKey = fmt.Sprintf("files/%s/%s_%s", channelID, att.ID, att.Filename) - - // Write to object store - if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil { - // Rollback PG row on storage failure - h.stores.Files.Delete(c.Request.Context(), att.ID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) - return - } - - // Update storage_key - h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey) - - // For images, mark extraction as not needed (complete immediately) - if strings.HasPrefix(contentType, "image/") { - h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{ - "extraction_status": "complete", - }) - att.Metadata["extraction_status"] = "complete" - } - - // For text/plain, extract inline (trivial — just read the file) - if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" { - if _, err := file.Seek(0, io.SeekStart); err == nil { - if textBytes, err := io.ReadAll(file); err == nil { - text := string(textBytes) - h.stores.Files.SetExtractedText(c.Request.Context(), att.ID, text) - h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{ - "extraction_status": "complete", - }) - att.Metadata["extraction_status"] = "complete" - } - } - } - - // For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar - if extraction.IsExtractable(contentType) && h.extQueue != nil { - if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil { - log.Printf("extraction enqueue failed for %s: %v", att.ID, err) - // Non-fatal: file is uploaded, just won't have extracted text - } - } - - c.JSON(http.StatusCreated, att) -} - -// ── Download ─────────────────────────────── -// GET /api/v1/files/:id/download -// Streams file content with auth check via channel membership. -func (h *FileHandler) Download(c *gin.Context) { - if h.objStore == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - attID := c.Param("id") - - att, err := h.stores.Files.GetByID(c.Request.Context(), attID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) - return - } - - // Channel-scoped access check - if !h.verifyChannelAccess(c, att.ChannelID, userID) { - return - } - - reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) - return - } - defer reader.Close() - - c.Header("Content-Type", att.ContentType) - c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename)) - c.Header("Content-Length", fmt.Sprintf("%d", size)) - c.Status(http.StatusOK) - io.Copy(c.Writer, reader) -} - -// ── Get Metadata ─────────────────────────── -// GET /api/v1/files/:id -func (h *FileHandler) GetMetadata(c *gin.Context) { - userID := getUserID(c) - attID := c.Param("id") - - att, err := h.stores.Files.GetByID(c.Request.Context(), attID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) - return - } - - if !h.verifyChannelAccess(c, att.ChannelID, userID) { - return - } - - c.JSON(http.StatusOK, att) -} - -// ── List Channel Files ─────────────── -// GET /api/v1/channels/:id/files -func (h *FileHandler) ListByChannel(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - if !h.verifyChannelAccess(c, channelID, userID) { - return - } - - origin := c.Query("origin") // v0.37.18: filter by origin (user_upload, tool_output, system) - files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, origin) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) - return - } - if files == nil { - files = []models.File{} - } - - c.JSON(http.StatusOK, gin.H{"files": files}) -} - -// ── List by Message ─────────────────────── -// GET /api/v1/messages/:id/files -// Returns files attached to a specific message (inline artifacts, tool output). -func (h *FileHandler) ListByMessage(c *gin.Context) { - messageID := c.Param("id") - - files, err := h.stores.Files.GetByMessage(c.Request.Context(), messageID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) - return - } - if files == nil { - files = []models.File{} - } - - c.JSON(http.StatusOK, gin.H{"files": files}) -} - -// ── List by User (File Manager) ─────────── -// GET /api/v1/files?page=1&per_page=50 -// Paginated list of all files owned by the authenticated user. -func (h *FileHandler) ListByUser(c *gin.Context) { - userID := getUserID(c) - page, perPage, _ := parsePagination(c) - - files, total, err := h.stores.Files.GetByUser(c.Request.Context(), userID, page, perPage) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) - return - } - if files == nil { - files = []models.File{} - } - - c.JSON(http.StatusOK, gin.H{ - "files": files, - "total": total, - "page": page, - "per_page": perPage, - }) -} - -// ── Delete ───────────────────────────────── -// DELETE /api/v1/files/:id -func (h *FileHandler) Delete(c *gin.Context) { - userID := getUserID(c) - attID := c.Param("id") - - att, err := h.stores.Files.GetByID(c.Request.Context(), attID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) - return - } - - if !h.verifyChannelAccess(c, att.ChannelID, userID) { - return - } - - // Delete from PG (returns the row for storage cleanup) - deleted, err := h.stores.Files.Delete(c.Request.Context(), attID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"}) - return - } - - // Clean up storage (async-safe: fire and forget with background context) - if h.objStore != nil && deleted != nil { - storageKey := deleted.StorageKey - go func() { - ctx := context.Background() - if err := h.objStore.Delete(ctx, storageKey); err != nil { - log.Printf("storage cleanup failed for %s: %v", storageKey, err) - } - // Also clean up thumbnail if it exists - h.objStore.Delete(ctx, storageKey+"_thumb.jpg") - }() - } - - c.JSON(http.StatusOK, gin.H{"message": "file deleted"}) -} - -// ── Admin: Orphan Cleanup ────────────────── -// POST /admin/storage/cleanup -func (h *FileHandler) CleanupOrphans(c *gin.Context) { - orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"}) - return - } - - deleted := 0 - var freedBytes int64 - - for _, att := range orphans { - if _, err := h.stores.Files.Delete(c.Request.Context(), att.ID); err != nil { - log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err) - continue - } - if h.objStore != nil { - h.objStore.Delete(c.Request.Context(), att.StorageKey) - h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg") - } - deleted++ - freedBytes += att.SizeBytes - } - - c.JSON(http.StatusOK, gin.H{ - "deleted": deleted, - "freed_bytes": freedBytes, - "scanned": len(orphans), - }) -} - -// ── Admin: Orphan Count ──────────────────── -// GET /admin/storage/orphans (for the admin panel card) -func (h *FileHandler) OrphanCount(c *gin.Context) { - orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"}) - return - } - - var totalBytes int64 - for _, att := range orphans { - totalBytes += att.SizeBytes - } - - c.JSON(http.StatusOK, gin.H{ - "count": len(orphans), - "reclaimable_bytes": totalBytes, - }) -} - -// ── Admin: Extraction Queue Status ───────── -// GET /admin/storage/extraction -func (h *FileHandler) ExtractionStatus(c *gin.Context) { - if h.extQueue == nil { - c.JSON(http.StatusOK, gin.H{ - "enabled": false, - "items": []interface{}{}, - }) - return - } - - items, err := h.extQueue.ListAll() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"}) - return - } - if items == nil { - items = []extraction.QueueItem{} - } - - // Count by status - counts := map[string]int{} - for _, item := range items { - counts[item.Status]++ - } - - c.JSON(http.StatusOK, gin.H{ - "enabled": true, - "total": len(items), - "counts": counts, - "items": items, - }) -} - -// ── Channel Delete Hook ──────────────────── -// Called by ChannelHandler.DeleteChannel to clean up storage. -func (h *FileHandler) CleanupChannelStorage(channelID string) { - if h.objStore == nil { - return - } - // CASCADE already deleted PG rows. Clean up filesystem. - prefix := fmt.Sprintf("files/%s", channelID) - if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil { - log.Printf("storage cleanup for channel %s failed: %v", channelID, err) - } -} - -// ── Helpers ──────────────────────────────── - -// verifyChannelAccess checks that the requesting user owns the channel. -// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission). -func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool { - owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return false - } - if !owns { - // Check if user is admin (admins can access any channel) - role, _ := c.Get("role") - if role != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) - return false - } - } - return true -} - -// sanitizeFilename cleans a filename for safe storage. -func sanitizeFilename(name string) string { - // Take only the base name (strip path separators) - name = filepath.Base(name) - // Replace problematic characters - replacer := strings.NewReplacer( - "/", "_", "\\", "_", "..", "_", "\x00", "", - ) - name = replacer.Replace(name) - if name == "" || name == "." { - name = "unnamed" - } - // Truncate to 200 chars (leave room for UUID prefix in storage key) - if len(name) > 200 { - ext := filepath.Ext(name) - name = name[:200-len(ext)] + ext - } - return name -} - -// ── Project Files (v0.22.4, reworked v0.37.17) ───────────── -// Project files route through the Workspace FS subsystem. -// A workspace is auto-created on first file upload (lazy binding). - -// verifyProjectAccess checks that the user can access the project. -// Returns true if access is granted (admins bypass). -func (h *FileHandler) verifyProjectAccess(c *gin.Context, projectID, userID string) bool { - if c.GetString("role") == "admin" { - return true - } - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) - ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) - if err != nil || !ok { - c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) - return false - } - return true -} - -// ensureProjectWorkspace returns the project's workspace, creating one if needed. -func (h *FileHandler) ensureProjectWorkspace(ctx context.Context, projectID string) (*models.Workspace, error) { - proj, err := h.stores.Projects.GetByID(ctx, projectID) - if err != nil { - return nil, fmt.Errorf("project not found: %w", err) - } - - // Already has a workspace — load and return it. - if proj.WorkspaceID != nil && *proj.WorkspaceID != "" { - ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) - if err != nil { - return nil, fmt.Errorf("workspace %s not found: %w", *proj.WorkspaceID, err) - } - return ws, nil - } - - // Create workspace for this project. - ws := &models.Workspace{ - OwnerType: models.WorkspaceOwnerProject, - OwnerID: projectID, - Name: proj.Name + " Files", - Status: models.WorkspaceStatusActive, - } - ws.ID = store.NewID() - ws.RootPath = "workspaces/" + ws.ID - - if err := h.stores.Workspaces.Create(ctx, ws); err != nil { - // Race guard: another request may have created it. Re-read the project. - proj2, err2 := h.stores.Projects.GetByID(ctx, projectID) - if err2 == nil && proj2.WorkspaceID != nil && *proj2.WorkspaceID != "" { - return h.stores.Workspaces.GetByID(ctx, *proj2.WorkspaceID) - } - return nil, fmt.Errorf("failed to create workspace: %w", err) - } - - // Create directory on disk. - if err := h.wfs.CreateDir(ws); err != nil { - log.Printf("warning: workspace mkdir for project %s: %v", projectID, err) - } - - // Bind workspace to project. - h.stores.Projects.Update(ctx, projectID, models.ProjectPatch{WorkspaceID: &ws.ID}) - - return ws, nil -} - -// UploadToProject handles project-scoped file uploads via workspace FS. -// POST /api/v1/projects/:id/files -func (h *FileHandler) UploadToProject(c *gin.Context) { - if h.wfs == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - ws, err := h.ensureProjectWorkspace(ctx, projectID) - if err != nil { - log.Printf("error: ensure workspace for project %s: %v", projectID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"}) - 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 - } - - // Quota check - stats, _ := h.stores.Workspaces.GetStats(ctx, ws.ID) - quota := workspace.DefaultMaxBytes - if ws.MaxBytes != nil { - quota = *ws.MaxBytes - } - if stats != nil && stats.TotalBytes+header.Size > quota { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{ - "error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota), - }) - 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 - } - - filename := sanitizeFilename(header.Filename) - - // Determine upload path (support optional path prefix via query param) - uploadPath := filename - if prefix := c.Query("path"); prefix != "" { - uploadPath = strings.TrimSuffix(prefix, "/") + "/" + filename - } - - if err := h.wfs.WriteFile(ctx, ws, uploadPath, file, header.Size); err != nil { - log.Printf("error: project file write: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) - return - } - - c.JSON(http.StatusCreated, gin.H{ - "path": uploadPath, - "filename": filename, - "content_type": contentType, - "size_bytes": header.Size, - }) -} - -// ListByProject returns workspace files for a project. -// GET /api/v1/projects/:id/files -func (h *FileHandler) ListByProject(c *gin.Context) { - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - // Load project to get workspace_id - proj, err := h.stores.Projects.GetByID(ctx, projectID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) - return - } - - // No workspace yet → empty list - if proj.WorkspaceID == nil || *proj.WorkspaceID == "" { - c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0}) - return - } - - ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) - if err != nil { - c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0}) - return - } - - dirPath := c.DefaultQuery("path", "") - recursive := c.DefaultQuery("recursive", "true") == "true" - - files, err := h.wfs.ListDir(ctx, ws, dirPath, recursive) - if err != nil { - log.Printf("error: list project files: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) - return - } - if files == nil { - files = []models.WorkspaceFile{} - } - - c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)}) -} - -// DownloadProjectFile streams a single file from the project workspace. -// GET /api/v1/projects/:id/files/download?path=... -func (h *FileHandler) DownloadProjectFile(c *gin.Context) { - if h.wfs == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - filePath := c.Query("path") - if filePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - proj, err := h.stores.Projects.GetByID(ctx, projectID) - if err != nil || proj.WorkspaceID == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no files"}) - return - } - - ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - - rc, size, err := h.wfs.ReadFile(ctx, ws, filePath) - if err != nil { - if strings.Contains(err.Error(), "not found") { - c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return - } - defer rc.Close() - - // Detect content type - f, _ := h.wfs.Stat(ctx, ws, filePath) - contentType := "application/octet-stream" - if f != nil && f.ContentType != "" { - contentType = f.ContentType - } - - filename := filepath.Base(filePath) - c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) - c.Header("Content-Length", fmt.Sprintf("%d", size)) - c.DataFromReader(http.StatusOK, size, contentType, rc, nil) -} - -// DeleteProjectFile deletes a file or directory from the project workspace. -// DELETE /api/v1/projects/:id/files?path=... -func (h *FileHandler) DeleteProjectFile(c *gin.Context) { - if h.wfs == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - filePath := c.Query("path") - if filePath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - proj, err := h.stores.Projects.GetByID(ctx, projectID) - if err != nil || proj.WorkspaceID == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no files"}) - return - } - - ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - - recursive := c.Query("recursive") == "true" - if err := h.wfs.DeleteFile(ctx, ws, filePath, recursive); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// MkdirProject creates a directory in the project workspace. -// POST /api/v1/projects/:id/files/mkdir -func (h *FileHandler) MkdirProject(c *gin.Context) { - if h.wfs == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - ws, err := h.ensureProjectWorkspace(ctx, projectID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"}) - return - } - - // Accept path from query param or JSON body - dirPath := c.Query("path") - if dirPath == "" { - var body struct { - Path string `json:"path"` - } - if c.ShouldBindJSON(&body) == nil && body.Path != "" { - dirPath = body.Path - } - } - if dirPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - if err := h.wfs.Mkdir(ctx, ws, dirPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{"ok": true, "path": dirPath}) -} - -// UploadProjectArchive extracts a zip/tar.gz archive into the project workspace. -// POST /api/v1/projects/:id/archive/upload -func (h *FileHandler) UploadProjectArchive(c *gin.Context) { - if h.wfs == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - ws, err := h.ensureProjectWorkspace(ctx, projectID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"}) - return - } - - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"}) - return - } - defer file.Close() - - format := detectArchiveFormat(header.Filename) - if format == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"}) - return - } - - // Save to temp file (zip extraction needs seekable file) - tmp, err := os.CreateTemp("", "project-archive-*") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) - return - } - tmpName := tmp.Name() - defer os.Remove(tmpName) - - if _, err := io.Copy(tmp, file); err != nil { - tmp.Close() - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) - return - } - tmp.Close() - - count, err := h.wfs.ExtractArchive(ctx, ws, tmpName, format) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true, "files_extracted": count}) -} - -// DownloadProjectArchive creates and streams a zip/tar.gz of all project files. -// GET /api/v1/projects/:id/archive/download?format=zip -func (h *FileHandler) DownloadProjectArchive(c *gin.Context) { - if h.wfs == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - userID := getUserID(c) - projectID := c.Param("id") - ctx := c.Request.Context() - - if !h.verifyProjectAccess(c, projectID, userID) { - return - } - - proj, err := h.stores.Projects.GetByID(ctx, projectID) - if err != nil || proj.WorkspaceID == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no files"}) - return - } - - ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - - format := c.DefaultQuery("format", "zip") - if format != "zip" && format != "tar.gz" && format != "tgz" { - c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"}) - return - } - - archivePath, err := h.wfs.CreateArchive(ctx, ws, format) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - defer os.Remove(archivePath) - - ext := "zip" - if format == "tar.gz" || format == "tgz" { - ext = "tar.gz" - } - - filename := fmt.Sprintf("%s.%s", proj.Name, ext) - c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) - c.File(archivePath) -} - -// extToMIME maps file extensions to MIME types for cases where -// http.DetectContentType returns application/octet-stream. -var extToMIME = map[string]string{ - ".pdf": "application/pdf", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".doc": "application/msword", - ".xls": "application/vnd.ms-excel", - ".odt": "application/vnd.oasis.opendocument.text", - ".ods": "application/vnd.oasis.opendocument.spreadsheet", - ".odp": "application/vnd.oasis.opendocument.presentation", - ".rtf": "application/rtf", - ".md": "text/markdown", - ".csv": "text/csv", - ".svg": "image/svg+xml", -} - -// displayHintFor returns the appropriate display hint for a content type. -func displayHintFor(contentType string) string { - switch { - case strings.HasPrefix(contentType, "image/"): - return models.FileHintInline - case strings.HasPrefix(contentType, "video/"): - return models.FileHintInline - case strings.HasPrefix(contentType, "audio/"): - return models.FileHintInline - case contentType == "application/pdf": - return models.FileHintThumbnail - case strings.HasPrefix(contentType, "text/"): - return models.FileHintInline - case contentType == "application/json": - return models.FileHintInline - default: - return models.FileHintDownload - } -} diff --git a/server/handlers/folders.go b/server/handlers/folders.go deleted file mode 100644 index 4d12412..0000000 --- a/server/handlers/folders.go +++ /dev/null @@ -1,127 +0,0 @@ -package handlers - -// folders.go — Chat folder CRUD (v0.23.1) -// -// v0.29.0: Raw SQL replaced with FolderStore methods. - -import ( - "encoding/json" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -type FolderHandler struct { - stores store.Stores -} - -func NewFolderHandler(s store.Stores) *FolderHandler { - return &FolderHandler{stores: s} -} - -func (h *FolderHandler) List(c *gin.Context) { - userID := getUserID(c) - - folders, err := h.stores.Folders.List(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": folders}) -} - -func (h *FolderHandler) Create(c *gin.Context) { - userID := getUserID(c) - var req struct { - Name string `json:"name" binding:"required"` - ParentID *string `json:"parent_id"` - SortOrder int `json:"sort_order"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) - return - } - - f := &models.Folder{ - UserID: userID, - Name: req.Name, - ParentID: req.ParentID, - SortOrder: req.SortOrder, - } - if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"}) - return - } - c.JSON(http.StatusCreated, gin.H{"folder": f}) -} - -func (h *FolderHandler) Update(c *gin.Context) { - userID := getUserID(c) - folderID := c.Param("id") - - // Read raw body to detect which fields were explicitly sent - data, err := c.GetRawData() - if err != nil || len(data) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) - return - } - var req struct { - Name string `json:"name"` - SortOrder *int `json:"sort_order"` - ParentID *string `json:"parent_id"` - } - if err := json.Unmarshal(data, &req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if req.Name != "" { - req.Name = strings.TrimSpace(req.Name) - } - - // Only update parent_id if it was explicitly present in the JSON - var parentID **string - var raw map[string]json.RawMessage - _ = json.Unmarshal(data, &raw) - if _, ok := raw["parent_id"]; ok { - parentID = &req.ParentID - } - - n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder, parentID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"}) - return - } - if n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (h *FolderHandler) Delete(c *gin.Context) { - userID := getUserID(c) - folderID := c.Param("id") - - // Unassign chats before deleting folder - _ = h.stores.Folders.UnassignChannels(c.Request.Context(), folderID, userID) - - n, err := h.stores.Folders.Delete(c.Request.Context(), folderID, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"}) - return - } - if n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) -} diff --git a/server/handlers/gdpr.go b/server/handlers/gdpr.go deleted file mode 100644 index f3ec302..0000000 --- a/server/handlers/gdpr.go +++ /dev/null @@ -1,130 +0,0 @@ -package handlers - -// gdpr.go — v0.34.0 CS2 -// -// GDPR delete endpoint: DELETE /api/v1/me -// Allows a user to delete their own account and all associated data. - -import ( - "crypto/sha256" - "fmt" - "log/slog" - "net/http" - - "github.com/gin-gonic/gin" - "golang.org/x/crypto/bcrypt" - - "switchboard-core/models" - "switchboard-core/store" -) - -// GDPRHandler serves account deletion endpoints. -type GDPRHandler struct { - stores store.Stores -} - -// NewGDPRHandler creates a new handler for GDPR operations. -func NewGDPRHandler(s store.Stores) *GDPRHandler { - return &GDPRHandler{stores: s} -} - -// deleteAccountRequest is the body for DELETE /api/v1/me. -type deleteAccountRequest struct { - Confirm string `json:"confirm"` - Password string `json:"password"` -} - -// DeleteMyAccount permanently deletes the requesting user's account. -// DELETE /api/v1/me -func (h *GDPRHandler) DeleteMyAccount(c *gin.Context) { - ctx := c.Request.Context() - userID := c.GetString("user_id") - - var req deleteAccountRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) - return - } - - // Require explicit confirmation - if req.Confirm != "DELETE" { - c.JSON(http.StatusBadRequest, gin.H{"error": "confirm field must be \"DELETE\""}) - return - } - - // Fetch user - user, err := h.stores.Users.GetByID(ctx, userID) - if err != nil || user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) - return - } - - // Verify password - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"}) - return - } - - // Prevent deleting last admin - if user.Role == "admin" { - adminCount, err := h.stores.Export.CountActiveAdmins(ctx) - if err != nil { - slog.Error("gdpr: count admins", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin count"}) - return - } - if adminCount <= 1 { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete the last admin account"}) - return - } - } - - // Generate anonymized hash from user ID - hash := sha256.Sum256([]byte(userID)) - anonHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars - - // Step 1: Soft-delete/hard-delete user data - counts, err := h.stores.Export.SoftDeleteUserData(ctx, userID) - if err != nil { - slog.Error("gdpr: delete user data", "error", err, "user_id", userID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user data"}) - return - } - - // Step 2: Revoke tokens - if err := h.stores.Export.DeleteUserTokens(ctx, userID); err != nil { - slog.Error("gdpr: delete tokens", "error", err, "user_id", userID) - // Continue — tokens will expire naturally - } - - // Step 3: Delete personal provider configs - if deleted, err := h.stores.Providers.DeletePersonalByOwner(ctx, userID); err != nil { - slog.Error("gdpr: delete provider configs", "error", err) - } else { - counts["provider_configs"] = deleted - } - - // Step 4: Anonymize user record - if err := h.stores.Export.AnonymizeUser(ctx, userID, anonHash); err != nil { - slog.Error("gdpr: anonymize user", "error", err, "user_id", userID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to anonymize user"}) - return - } - - // Audit log (before the user record is fully anonymized) - anonUsername := "deleted-user-" + anonHash - h.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: &userID, - Action: "user.gdpr_delete", - ResourceType: "user", - ResourceID: userID, - Metadata: models.JSONMap{"anonymized_as": anonUsername, "deleted_counts": counts}, - }) - - slog.Info("gdpr: account deleted", "user_id", userID, "anonymized_as", anonUsername) - - c.JSON(http.StatusOK, gin.H{ - "message": "account deleted", - "anonymized_as": anonUsername, - }) -} diff --git a/server/handlers/git.go b/server/handlers/git.go deleted file mode 100644 index 3822d09..0000000 --- a/server/handlers/git.go +++ /dev/null @@ -1,436 +0,0 @@ -package handlers - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/json" - "encoding/pem" - "fmt" - "net/http" - "strconv" - - "golang.org/x/crypto/ssh" - - "switchboard-core/crypto" - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/workspace" - - "github.com/gin-gonic/gin" -) - -// ========================================= -// GIT HANDLER — Git operations on workspaces -// ========================================= - -type GitHandler struct { - stores store.Stores - gitOps *workspace.GitOps -} - -func NewGitHandler(s store.Stores, gitOps *workspace.GitOps) *GitHandler { - return &GitHandler{stores: s, gitOps: gitOps} -} - -// ── Clone ──────────────────────────────────── - -func (h *GitHandler) Clone(c *gin.Context) { - wsID := c.Param("id") - var req struct { - URL string `json:"url" binding:"required"` - Branch string `json:"branch"` - CredentialID string `json:"credential_id"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - - if err := h.gitOps.Clone(c.Request.Context(), w, req.URL, req.Branch, req.CredentialID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "cloned"}) -} - -// ── Pull ───────────────────────────────────── - -func (h *GitHandler) Pull(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - if err := h.gitOps.Pull(c.Request.Context(), w); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "pulled"}) -} - -// ── Push ───────────────────────────────────── - -func (h *GitHandler) Push(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - if err := h.gitOps.Push(c.Request.Context(), w); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "pushed"}) -} - -// ── Status ─────────────────────────────────── - -func (h *GitHandler) Status(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - status, err := h.gitOps.Status(c.Request.Context(), w) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, status) -} - -// ── Diff ───────────────────────────────────── - -func (h *GitHandler) Diff(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - path := c.Query("path") - diff, err := h.gitOps.Diff(c.Request.Context(), w, path) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"diff": diff}) -} - -// ── Commit ─────────────────────────────────── - -func (h *GitHandler) Commit(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - var req struct { - Message string `json:"message" binding:"required"` - Paths []string `json:"paths"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.gitOps.Commit(c.Request.Context(), w, req.Message, req.Paths); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "committed"}) -} - -// ── Log ────────────────────────────────────── - -func (h *GitHandler) Log(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - n := 20 - if v := c.Query("n"); v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - n = parsed - if n > 100 { - n = 100 - } - } - } - entries, err := h.gitOps.Log(c.Request.Context(), w, n) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if entries == nil { - entries = []models.GitLogEntry{} - } - c.JSON(http.StatusOK, gin.H{"data": entries}) -} - -// ── Branches ───────────────────────────────── - -func (h *GitHandler) Branches(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - branches, current, err := h.gitOps.BranchList(c.Request.Context(), w) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "current": current, - "branches": branches, - }) -} - -// ── Checkout ───────────────────────────────── - -func (h *GitHandler) Checkout(c *gin.Context) { - w, err := h.loadWorkspace(c) - if err != nil { - return - } - var req struct { - Branch string `json:"branch" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.gitOps.Checkout(c.Request.Context(), w, req.Branch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "checked out", "branch": req.Branch}) -} - -// loadWorkspace loads the workspace and validates git is configured. -func (h *GitHandler) loadWorkspace(c *gin.Context) (*models.Workspace, error) { - wsID := c.Param("id") - w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return nil, err - } - if w.GitRemoteURL == nil || *w.GitRemoteURL == "" { - err := fmt.Errorf("workspace has no git remote configured") - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return nil, err - } - return w, nil -} - -// ========================================= -// GIT CREDENTIAL HANDLER — CRUD for encrypted git credentials -// ========================================= - -type GitCredentialHandler struct { - stores store.Stores - vault *crypto.KeyResolver -} - -func NewGitCredentialHandler(s store.Stores, vault *crypto.KeyResolver) *GitCredentialHandler { - return &GitCredentialHandler{stores: s, vault: vault} -} - -// Create stores a new encrypted git credential. -func (h *GitCredentialHandler) Create(c *gin.Context) { - userID := getUserID(c) - var req struct { - Name string `json:"name" binding:"required"` - AuthType string `json:"auth_type" binding:"required"` - // Raw credential data — encrypted before storage - Token string `json:"token,omitempty"` // for https_pat - Username string `json:"username,omitempty"` // for https_basic - Password string `json:"password,omitempty"` // for https_basic - PrivateKey string `json:"private_key,omitempty"` // for ssh_key - Passphrase string `json:"passphrase,omitempty"` // for ssh_key - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate auth type - switch req.AuthType { - case "https_pat": - if req.Token == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "token is required for https_pat"}) - return - } - case "https_basic": - if req.Username == "" || req.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required for https_basic"}) - return - } - case "ssh_key": - if req.PrivateKey == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "private_key is required for ssh_key"}) - return - } - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "auth_type must be https_pat, https_basic, or ssh_key"}) - return - } - - // Build plaintext JSON - var credData map[string]string - switch req.AuthType { - case "https_pat": - credData = map[string]string{"token": req.Token} - case "https_basic": - credData = map[string]string{"username": req.Username, "password": req.Password} - case "ssh_key": - credData = map[string]string{"private_key": req.PrivateKey, "passphrase": req.Passphrase} - } - plaintext, _ := json.Marshal(credData) - - // Encrypt using global scope (same vault pattern as BYOK) - ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"}) - return - } - - cred := &models.GitCredential{ - UserID: userID, - Name: req.Name, - AuthType: req.AuthType, - EncryptedData: ciphertext, - Nonce: nonce, - } - - if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, cred.Summary()) -} - -// List returns all git credentials for the current user (summaries only). -func (h *GitCredentialHandler) List(c *gin.Context) { - userID := getUserID(c) - creds, err := h.stores.GitCredentials.ListByUser(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - summaries := make([]models.GitCredentialSummary, 0, len(creds)) - for _, cred := range creds { - summaries = append(summaries, cred.Summary()) - } - c.JSON(http.StatusOK, gin.H{"data": summaries}) -} - -// Delete removes a git credential. -func (h *GitCredentialHandler) Delete(c *gin.Context) { - userID := getUserID(c) - credID := c.Param("id") - - if err := h.stores.GitCredentials.Delete(c.Request.Context(), credID, userID); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// Generate creates a new ED25519 SSH keypair server-side. -// The private key is vault-encrypted before storage. Only the public key -// and fingerprint are returned — the private key never leaves the server. -// -// POST /git-credentials/generate -func (h *GitCredentialHandler) Generate(c *gin.Context) { - userID := getUserID(c) - var req struct { - Name string `json:"name" binding:"required"` - PersonaID *string `json:"persona_id,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Generate ED25519 keypair - pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"}) - return - } - - // Marshal public key to authorized_keys format - sshPub, err := ssh.NewPublicKey(pubKey) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"}) - return - } - authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub)) - - // Fingerprint (SHA256) - fingerprint := ssh.FingerprintSHA256(sshPub) - - // Marshal private key to OpenSSH PEM format - privPEM, err := ssh.MarshalPrivateKey(privKey, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"}) - return - } - privPEMBytes := pem.EncodeToMemory(privPEM) - - // Encrypt private key via vault - credData := map[string]string{"private_key": string(privPEMBytes)} - plaintext, _ := json.Marshal(credData) - - ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"}) - return - } - - cred := &models.GitCredential{ - UserID: userID, - Name: req.Name, - AuthType: "ssh_key", - EncryptedData: ciphertext, - Nonce: nonce, - PublicKey: authorizedKey, - Fingerprint: fingerprint, - PersonaID: req.PersonaID, - } - - if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, cred.Summary()) -} - -// GetPublicKey returns the public key for a credential (for copy-to-clipboard). -// -// GET /git-credentials/:id/public-key -func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) { - userID := getUserID(c) - credID := c.Param("id") - - cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID) - if err != nil || cred.UserID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"}) - return - } - - if cred.PublicKey == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "public_key": cred.PublicKey, - "fingerprint": cred.Fingerprint, - }) -} diff --git a/server/handlers/git_credentials_test.go b/server/handlers/git_credentials_test.go deleted file mode 100644 index f7062b4..0000000 --- a/server/handlers/git_credentials_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - - "switchboard-core/config" - "switchboard-core/crypto" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// ── Git Credentials Test Harness ────────── - -type gitCredHarness struct { - *testHarness - userToken string - userID string - user2Token string - user2ID string -} - -func setupGitCredHarness(t *testing.T) *gitCredHarness { - t.Helper() - database.RequireTestDB(t) - database.TruncateAll(t) - - cfg := &config.Config{ - JWTSecret: testJWTSecret, - BasePath: "", - } - - var stores store.Stores - if database.IsSQLite() { - stores = sqlite.NewStores(database.TestDB) - } else { - stores = postgres.NewStores(database.TestDB) - } - userCache := middleware.NewUserStatusCache() - - // Create a test vault with a static env key (32 bytes for AES-256) - envKey := []byte("test-encryption-key-32-bytes!!") - for len(envKey) < 32 { - envKey = append(envKey, '0') - } - envKey = envKey[:32] - vault := crypto.NewKeyResolver(envKey, nil) - - r := gin.New() - api := r.Group("/api/v1") - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - gitCredH := NewGitCredentialHandler(stores, vault) - protected.POST("/git-credentials", gitCredH.Create) - protected.POST("/git-credentials/generate", gitCredH.Generate) - protected.GET("/git-credentials", gitCredH.List) - protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey) - protected.DELETE("/git-credentials/:id", gitCredH.Delete) - - // Seed users - userID := database.SeedTestUser(t, "gituser", "gituser@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) - userToken := makeToken(userID, "gituser@test.com", "user") - - user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID) - user2Token := makeToken(user2ID, "gituser2@test.com", "user") - - return &gitCredHarness{ - testHarness: &testHarness{router: r, t: t}, - userToken: userToken, - userID: userID, - user2Token: user2Token, - user2ID: user2ID, - } -} - -// ── GET /git-credentials — empty state ─── - -func TestGitCreds_List_Empty(t *testing.T) { - h := setupGitCredHarness(t) - - resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - data, ok := body["data"] - if !ok { - t.Fatal("response must have 'data' key") - } - arr := data.([]interface{}) - if len(arr) != 0 { - t.Fatalf("expected empty array, got %d items", len(arr)) - } -} - -// ── POST /git-credentials/generate ─────── - -func TestGitCreds_Generate(t *testing.T) { - h := setupGitCredHarness(t) - - resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ - "name": "Test Key", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String()) - } - - var cred map[string]interface{} - json.NewDecoder(resp.Body).Decode(&cred) - - // Must have public_key and fingerprint - pubKey, _ := cred["public_key"].(string) - fp, _ := cred["fingerprint"].(string) - if pubKey == "" { - t.Error("public_key should be non-empty") - } - if fp == "" { - t.Error("fingerprint should be non-empty") - } - if cred["auth_type"] != "ssh_key" { - t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"]) - } - if cred["name"] != "Test Key" { - t.Errorf("name: got %v", cred["name"]) - } - - // Must NOT have encrypted data - if _, has := cred["encrypted_data"]; has { - t.Error("encrypted_data must not appear in response") - } - if _, has := cred["nonce"]; has { - t.Error("nonce must not appear in response") - } -} - -// ── GET /git-credentials/:id/public-key ── - -func TestGitCreds_GetPublicKey(t *testing.T) { - h := setupGitCredHarness(t) - - // Generate a key first - resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ - "name": "PK Test", - }) - var cred map[string]interface{} - json.NewDecoder(resp.Body).Decode(&cred) - id := cred["id"].(string) - origPK := cred["public_key"].(string) - - // Retrieve public key - resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String()) - } - - var pkBody map[string]interface{} - json.NewDecoder(resp.Body).Decode(&pkBody) - if pkBody["public_key"] != origPK { - t.Errorf("public key mismatch") - } - if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" { - t.Error("fingerprint should be present") - } -} - -// ── List after generate ────────────────── - -func TestGitCreds_ListAfterGenerate(t *testing.T) { - h := setupGitCredHarness(t) - - h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ - "name": "Key A", - }) - h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ - "name": "Key B", - }) - - resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) != 2 { - t.Fatalf("expected 2 keys, got %d", len(arr)) - } - - // Verify summaries have public_key + fingerprint - for _, raw := range arr { - item := raw.(map[string]interface{}) - if item["public_key"] == nil || item["public_key"] == "" { - t.Error("list item should have public_key") - } - if item["fingerprint"] == nil || item["fingerprint"] == "" { - t.Error("list item should have fingerprint") - } - } -} - -// ── Delete ─────────────────────────────── - -func TestGitCreds_Delete(t *testing.T) { - h := setupGitCredHarness(t) - - resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ - "name": "Delete Me", - }) - var cred map[string]interface{} - json.NewDecoder(resp.Body).Decode(&cred) - id := cred["id"].(string) - - // Delete - resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("delete: got %d", resp.Code) - } - - // Verify gone - resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) != 0 { - t.Fatalf("expected 0 keys after delete, got %d", len(arr)) - } -} - -// ── User isolation ─────────────────────── - -func TestGitCreds_UserIsolation(t *testing.T) { - h := setupGitCredHarness(t) - - // User 1 generates a key - resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{ - "name": "User1 Key", - }) - var cred map[string]interface{} - json.NewDecoder(resp.Body).Decode(&cred) - id := cred["id"].(string) - - // User 2 should see empty list - resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) != 0 { - t.Fatalf("user2 should see 0 keys, got %d", len(arr)) - } - - // User 2 should not be able to get user1's public key - resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil) - if resp.Code != http.StatusNotFound { - t.Fatalf("cross-user public-key: want 404, got %d", resp.Code) - } - - // User 2 should not be able to delete user1's key - resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil) - if resp.Code != http.StatusNotFound { - t.Fatalf("cross-user delete: want 404, got %d", resp.Code) - } -} - -// ── Validation ─────────────────────────── - -func TestGitCreds_Generate_MissingName(t *testing.T) { - h := setupGitCredHarness(t) - - resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{}) - if resp.Code != http.StatusBadRequest { - t.Fatalf("missing name: want 400, got %d", resp.Code) - } -} diff --git a/server/handlers/import_data.go b/server/handlers/import_data.go deleted file mode 100644 index aa7391c..0000000 --- a/server/handlers/import_data.go +++ /dev/null @@ -1,733 +0,0 @@ -package handlers - -// import_data.go — v0.34.0 CS1 -// -// Data import endpoint: user data import from .switchboard archives. -// Reads the archive, validates manifest, and imports entities in -// FK-dependency order with UUID dedup (skip if ID exists). - -import ( - "fmt" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/export" - "switchboard-core/models" - "switchboard-core/storage" - "switchboard-core/store" -) - -// DataImportHandler serves data import endpoints. -type DataImportHandler struct { - stores store.Stores - objStore storage.ObjectStore -} - -// NewDataImportHandler creates a new handler for data import operations. -func NewDataImportHandler(s store.Stores, obj storage.ObjectStore) *DataImportHandler { - return &DataImportHandler{stores: s, objStore: obj} -} - -// ImportMyData imports user data from a .switchboard zip archive. -// POST /api/v1/import/me -func (h *DataImportHandler) ImportMyData(c *gin.Context) { - ctx := c.Request.Context() - userID := c.GetString("user_id") - - // ── Receive upload ── - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) - return - } - defer file.Close() - - // Validate extension - if !strings.HasSuffix(header.Filename, export.ExportExtension) && !strings.HasSuffix(header.Filename, ".zip") { - c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .switchboard or .zip archive"}) - return - } - - // Size check: use MaxExportArchiveSize - if header.Size > export.MaxExportArchiveSize { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)), - }) - return - } - - // Save to temp file (zip.OpenReader needs a file path) - tmp, err := os.CreateTemp("", "switchboard-import-*.zip") - if err != nil { - slog.Error("import: create temp file", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) - return - } - defer os.Remove(tmp.Name()) - defer tmp.Close() - - if _, err := io.Copy(tmp, file); err != nil { - slog.Error("import: save temp file", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) - return - } - tmp.Close() - - // ── Open archive ── - ar, err := export.OpenArchive(tmp.Name()) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()}) - return - } - defer ar.Close() - - manifest, err := ar.ReadManifest() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()}) - return - } - - if manifest.FormatVersion > export.FormatVersion { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("unsupported format version %d (max %d)", manifest.FormatVersion, export.FormatVersion), - }) - return - } - - // ── Read entities from archive ── - imported := make(map[string]int) - skipped := make(map[string]int) - var errors []string - - importEntity := func(name string, fn func() (int, int, error)) { - imp, skip, err := fn() - if err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", name, err)) - slog.Error("import: entity error", "entity", name, "error", err) - } - if imp > 0 { - imported[name] = imp - } - if skip > 0 { - skipped[name] = skip - } - } - - // Helper to remap user_id on entities for cross-instance import - remapUserID := manifest.Scope != nil && manifest.Scope.UserID != "" && manifest.Scope.UserID != userID - sourceUserID := "" - if manifest.Scope != nil { - sourceUserID = manifest.Scope.UserID - } - - // 1. Folders - var folders []models.Folder - if err := ar.ReadEntityJSON("folders", &folders); err == nil && len(folders) > 0 { - if remapUserID { - for i := range folders { - if folders[i].UserID == sourceUserID { - folders[i].UserID = userID - } - } - } - importEntity("folders", func() (int, int, error) { - return h.stores.Export.ImportFolders(ctx, folders) - }) - } - - // 2. Projects - var projects []models.Project - if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 { - if remapUserID { - for i := range projects { - if projects[i].OwnerID == sourceUserID { - projects[i].OwnerID = userID - } - } - } - importEntity("projects", func() (int, int, error) { - return h.stores.Export.ImportProjects(ctx, projects) - }) - } - - // 3. Workspaces - var workspaces []models.Workspace - if err := ar.ReadEntityJSON("workspaces", &workspaces); err == nil && len(workspaces) > 0 { - if remapUserID { - for i := range workspaces { - if workspaces[i].OwnerID == sourceUserID { - workspaces[i].OwnerID = userID - } - } - } - importEntity("workspaces", func() (int, int, error) { - return h.stores.Export.ImportWorkspaces(ctx, workspaces) - }) - } - - // 4. Channels - var channels []models.Channel - if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 { - if remapUserID { - for i := range channels { - if channels[i].UserID == sourceUserID { - channels[i].UserID = userID - } - } - } - importEntity("channels", func() (int, int, error) { - return h.stores.Export.ImportChannels(ctx, channels) - }) - } - - // 5. Channel participants - var participants []models.ChannelParticipant - if err := ar.ReadEntityJSON("channel_participants", &participants); err == nil && len(participants) > 0 { - if remapUserID { - for i := range participants { - if participants[i].ParticipantType == "user" && participants[i].ParticipantID == sourceUserID { - participants[i].ParticipantID = userID - } - } - } - importEntity("channel_participants", func() (int, int, error) { - return h.stores.Export.ImportChannelParticipants(ctx, participants) - }) - } - - // 5b. Channel models - var channelModels []models.ChannelModel - if err := ar.ReadEntityJSON("channel_models", &channelModels); err == nil && len(channelModels) > 0 { - importEntity("channel_models", func() (int, int, error) { - return h.stores.Export.ImportChannelModels(ctx, channelModels) - }) - } - - // 5c. Channel cursors - var cursors []models.ChannelCursor - if err := ar.ReadEntityJSON("channel_cursors", &cursors); err == nil && len(cursors) > 0 { - if remapUserID { - for i := range cursors { - if cursors[i].UserID == sourceUserID { - cursors[i].UserID = userID - } - } - } - importEntity("channel_cursors", func() (int, int, error) { - return h.stores.Export.ImportChannelCursors(ctx, cursors) - }) - } - - // 6. Messages (ordered by created_at ASC for parent_id integrity) - var messages []models.Message - if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 { - if remapUserID { - for i := range messages { - if messages[i].ParticipantType == "user" && messages[i].ParticipantID == sourceUserID { - messages[i].ParticipantID = userID - } - } - } - importEntity("messages", func() (int, int, error) { - return h.stores.Export.ImportMessages(ctx, messages) - }) - } - - // 7. Notes - var notes []models.Note - if err := ar.ReadEntityJSON("notes", ¬es); err == nil && len(notes) > 0 { - if remapUserID { - for i := range notes { - if notes[i].UserID == sourceUserID { - notes[i].UserID = userID - } - } - } - importEntity("notes", func() (int, int, error) { - return h.stores.Export.ImportNotes(ctx, notes) - }) - } - - // 8. Note links - var noteLinks []models.ExportNoteLink - if err := ar.ReadEntityJSON("note_links", ¬eLinks); err == nil && len(noteLinks) > 0 { - importEntity("note_links", func() (int, int, error) { - return h.stores.Export.ImportNoteLinks(ctx, noteLinks) - }) - } - - // 9. Memories - var memories []models.Memory - if err := ar.ReadEntityJSON("memories", &memories); err == nil && len(memories) > 0 { - if remapUserID { - for i := range memories { - if memories[i].OwnerID == sourceUserID { - memories[i].OwnerID = userID - } - } - } - importEntity("memories", func() (int, int, error) { - return h.stores.Export.ImportMemories(ctx, memories) - }) - } - - // 10. Project channels - var projectChannels []models.ProjectChannel - if err := ar.ReadEntityJSON("project_channels", &projectChannels); err == nil && len(projectChannels) > 0 { - importEntity("project_channels", func() (int, int, error) { - return h.stores.Export.ImportProjectChannels(ctx, projectChannels) - }) - } - - // 10b. Project KBs - var projectKBs []models.ProjectKB - if err := ar.ReadEntityJSON("project_knowledge_bases", &projectKBs); err == nil && len(projectKBs) > 0 { - importEntity("project_knowledge_bases", func() (int, int, error) { - return h.stores.Export.ImportProjectKBs(ctx, projectKBs) - }) - } - - // 10c. Project notes - var projectNotes []models.ProjectNote - if err := ar.ReadEntityJSON("project_notes", &projectNotes); err == nil && len(projectNotes) > 0 { - importEntity("project_notes", func() (int, int, error) { - return h.stores.Export.ImportProjectNotes(ctx, projectNotes) - }) - } - - // 11. Workspace files - var workspaceFiles []models.WorkspaceFile - if err := ar.ReadEntityJSON("workspace_files", &workspaceFiles); err == nil && len(workspaceFiles) > 0 { - importEntity("workspace_files", func() (int, int, error) { - return h.stores.Export.ImportWorkspaceFiles(ctx, workspaceFiles) - }) - } - - // 12. Files (metadata) - var files []models.File - if err := ar.ReadEntityJSON("files", &files); err == nil && len(files) > 0 { - if remapUserID { - for i := range files { - if files[i].UserID == sourceUserID { - files[i].UserID = userID - } - } - } - // Generate storage keys for imported files - for i := range files { - if files[i].StorageKey == "" && files[i].ChannelID != "" { - files[i].StorageKey = fmt.Sprintf("files/%s/%s_%s", files[i].ChannelID, files[i].ID, files[i].Filename) - } - } - importEntity("files", func() (int, int, error) { - return h.stores.Export.ImportFiles(ctx, files) - }) - } - - // 12b. File blobs from archive - if h.objStore != nil { - fileBlobs := ar.FileEntries() - blobCount := 0 - for _, entry := range fileBlobs { - if blobCount >= export.MaxExportFiles { - errors = append(errors, "file blob limit reached, some files skipped") - break - } - // Extract file ID from path: files/{fileID}/{filename} - parts := strings.SplitN(strings.TrimPrefix(entry.Name, "files/"), "/", 2) - if len(parts) != 2 { - continue - } - fileID := parts[0] - filename := parts[1] - - // Find matching file record for storage key - storageKey := "" - var contentType string - for _, f := range files { - if f.ID == fileID { - storageKey = f.StorageKey - contentType = f.ContentType - break - } - } - if storageKey == "" { - storageKey = fmt.Sprintf("files/imported/%s_%s", fileID, filename) - } - if contentType == "" { - contentType = "application/octet-stream" - } - - rc, err := entry.Open() - if err != nil { - errors = append(errors, fmt.Sprintf("file %s: open error", filepath.Base(entry.Name))) - continue - } - if err := h.objStore.Put(ctx, storageKey, rc, int64(entry.UncompressedSize64), contentType); err != nil { - rc.Close() - errors = append(errors, fmt.Sprintf("file %s: upload error", filepath.Base(entry.Name))) - continue - } - rc.Close() - blobCount++ - } - if blobCount > 0 { - imported["file_blobs"] = blobCount - } - } - - // 13. Settings/Prefs - var userModelSettings []models.UserModelSetting - if err := ar.ReadEntityJSON("user_settings", &userModelSettings); err == nil && len(userModelSettings) > 0 { - if remapUserID { - for i := range userModelSettings { - if userModelSettings[i].UserID == sourceUserID { - userModelSettings[i].UserID = userID - } - } - } - importEntity("user_settings", func() (int, int, error) { - return h.stores.Export.ImportUserModelSettings(ctx, userModelSettings) - }) - } - - var notifPrefs []models.NotificationPreference - if err := ar.ReadEntityJSON("notification_preferences", ¬ifPrefs); err == nil && len(notifPrefs) > 0 { - if remapUserID { - for i := range notifPrefs { - if notifPrefs[i].UserID == sourceUserID { - notifPrefs[i].UserID = userID - } - } - } - importEntity("notification_preferences", func() (int, int, error) { - return h.stores.Export.ImportNotifPrefs(ctx, notifPrefs) - }) - } - - // 14. Persona groups - var personaGroups []models.PersonaGroup - if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 { - if remapUserID { - for i := range personaGroups { - if personaGroups[i].OwnerID == sourceUserID { - personaGroups[i].OwnerID = userID - } - } - } - importEntity("persona_groups", func() (int, int, error) { - return h.stores.Export.ImportPersonaGroups(ctx, personaGroups) - }) - } - - var personaGroupMembers []models.PersonaGroupMember - if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 { - importEntity("persona_group_members", func() (int, int, error) { - return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers) - }) - } - - // ── Audit log ── - h.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: &userID, - Action: "user.import", - ResourceType: "user", - ResourceID: userID, - Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors}, - }) - - c.JSON(http.StatusOK, gin.H{ - "imported": imported, - "skipped": skipped, - "errors": errors, - }) -} - -// ImportTeam imports team data from a .switchboard archive. -// POST /api/v1/admin/teams/:id/import -func (h *DataImportHandler) ImportTeam(c *gin.Context) { - ctx := c.Request.Context() - userID := c.GetString("user_id") - teamID := c.Param("id") - - // Verify team exists - team, err := h.stores.Teams.GetByID(ctx, teamID) - if err != nil || team == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) - return - } - - // Receive upload - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) - return - } - defer file.Close() - - if header.Size > export.MaxExportArchiveSize { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)), - }) - return - } - - tmp, err := os.CreateTemp("", "switchboard-team-import-*.zip") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) - return - } - defer os.Remove(tmp.Name()) - defer tmp.Close() - - if _, err := io.Copy(tmp, file); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) - return - } - tmp.Close() - - ar, err := export.OpenArchive(tmp.Name()) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()}) - return - } - defer ar.Close() - - manifest, err := ar.ReadManifest() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()}) - return - } - - if manifest.FormatVersion > export.FormatVersion { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("unsupported format version %d", manifest.FormatVersion), - }) - return - } - - imported := make(map[string]int) - skipped := make(map[string]int) - var errors []string - - importEntity := func(name string, fn func() (int, int, error)) { - imp, skip, err := fn() - if err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", name, err)) - slog.Error("import: team entity error", "entity", name, "error", err) - } - if imp > 0 { - imported[name] = imp - } - if skip > 0 { - skipped[name] = skip - } - } - - // Remap team_id on entities to target team - sourceTeamID := "" - if manifest.Scope != nil { - sourceTeamID = manifest.Scope.TeamID - } - remapTeam := sourceTeamID != "" && sourceTeamID != teamID - - // Channels (remap team_id) - var channels []models.Channel - if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 { - if remapTeam { - for i := range channels { - if channels[i].TeamID != nil && *channels[i].TeamID == sourceTeamID { - channels[i].TeamID = &teamID - } - } - } - importEntity("channels", func() (int, int, error) { - return h.stores.Export.ImportChannels(ctx, channels) - }) - } - - // Messages - var messages []models.Message - if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 { - importEntity("messages", func() (int, int, error) { - return h.stores.Export.ImportMessages(ctx, messages) - }) - } - - // Projects (remap team_id) - var projects []models.Project - if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 { - if remapTeam { - for i := range projects { - if projects[i].TeamID != nil && *projects[i].TeamID == sourceTeamID { - projects[i].TeamID = &teamID - } - } - } - importEntity("projects", func() (int, int, error) { - return h.stores.Export.ImportProjects(ctx, projects) - }) - } - - // Persona groups (remap team_id) - var personaGroups []models.PersonaGroup - if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 { - if remapTeam { - for i := range personaGroups { - if personaGroups[i].TeamID != nil && *personaGroups[i].TeamID == sourceTeamID { - personaGroups[i].TeamID = &teamID - } - } - } - importEntity("persona_groups", func() (int, int, error) { - return h.stores.Export.ImportPersonaGroups(ctx, personaGroups) - }) - } - - var personaGroupMembers []models.PersonaGroupMember - if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 { - importEntity("persona_group_members", func() (int, int, error) { - return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers) - }) - } - - // Audit log - h.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: &userID, - Action: "team.import", - ResourceType: "team", - ResourceID: teamID, - Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors}, - }) - - c.JSON(http.StatusOK, gin.H{ - "imported": imported, - "skipped": skipped, - "errors": errors, - }) -} - -// ImportChatGPT imports conversations from a ChatGPT export. -// POST /api/v1/import/chatgpt -func (h *DataImportHandler) ImportChatGPT(c *gin.Context) { - ctx := c.Request.Context() - userID := c.GetString("user_id") - - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) - return - } - defer file.Close() - - if header.Size > export.MaxExportArchiveSize { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("file too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)), - }) - return - } - - // Detect if it's a zip or raw JSON - var convs export.ChatGPTExport - - if strings.HasSuffix(header.Filename, ".zip") { - // Save to temp and extract conversations.json - tmp, err := os.CreateTemp("", "chatgpt-import-*.zip") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) - return - } - defer os.Remove(tmp.Name()) - defer tmp.Close() - - if _, err := io.Copy(tmp, file); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) - return - } - tmp.Close() - - ar, err := export.OpenArchive(tmp.Name()) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"}) - return - } - defer ar.Close() - - // Try to find conversations.json in the zip - if err := ar.ReadEntityJSON("conversations", &convs); err != nil || len(convs) == 0 { - // Try searching all files for conversations.json - for _, f := range ar.FileEntries() { - if strings.HasSuffix(f.Name, "conversations.json") { - rc, err := f.Open() - if err != nil { - continue - } - convs, err = export.ParseChatGPTExport(rc) - rc.Close() - if err == nil { - break - } - } - } - } - } else { - // Raw JSON file - convs, err = export.ParseChatGPTExport(file) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse conversations.json: " + err.Error()}) - return - } - } - - if len(convs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no conversations found in upload"}) - return - } - - // Convert to Switchboard models - result := export.ConvertChatGPTExport(convs, userID) - - // Import channels and messages - chImported, chSkipped, err := h.stores.Export.ImportChannels(ctx, result.Channels) - if err != nil { - slog.Error("chatgpt import: channels", "error", err) - } - - msgImported, msgSkipped, err := h.stores.Export.ImportMessages(ctx, result.Messages) - if err != nil { - slog.Error("chatgpt import: messages", "error", err) - } - - imported := map[string]int{ - "channels": chImported, - "messages": msgImported, - } - skipped := map[string]int{ - "channels": chSkipped, - "messages": msgSkipped, - } - - h.stores.Audit.Log(ctx, &models.AuditEntry{ - ActorID: &userID, - Action: "user.import_chatgpt", - ResourceType: "user", - ResourceID: userID, - Metadata: models.JSONMap{ - "conversations": len(convs), - "imported": imported, - "skipped": skipped, - }, - }) - - c.JSON(http.StatusOK, gin.H{ - "conversations": len(convs), - "imported": imported, - "skipped": skipped, - }) -} diff --git a/server/handlers/knowledge_bases.go b/server/handlers/knowledge_bases.go deleted file mode 100644 index 33e0adf..0000000 --- a/server/handlers/knowledge_bases.go +++ /dev/null @@ -1,1044 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "io" - "log" - "net/http" - "path/filepath" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/storage" - "switchboard-core/store" -) - -// ── Limits ─────────────────────────────────── - -const ( - kbMaxDocSize = 10 * 1024 * 1024 // 10 MB per document - kbMaxNameLen = 200 - kbMaxDescLen = 2000 -) - -// allowedKBMIMETypes lists content types accepted for KB documents. -var allowedKBMIMETypes = map[string]bool{ - "text/plain": true, - "text/markdown": true, - "text/csv": true, - "text/html": true, - // Future: PDF, docx once extraction sidecar is wired in -} - -// ── Request / Response Types ───────────────── - -type createKBRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Scope string `json:"scope"` // personal (default), team, global - TeamID string `json:"team_id"` -} - -type updateKBRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` -} - -type kbResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Scope string `json:"scope"` - OwnerID *string `json:"owner_id,omitempty"` - TeamID *string `json:"team_id,omitempty"` - EmbeddingConfig models.JSONMap `json:"embedding_config"` - DocumentCount int `json:"document_count"` - ChunkCount int `json:"chunk_count"` - TotalBytes int64 `json:"total_bytes"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type docResponse struct { - ID string `json:"id"` - KBID string `json:"kb_id"` - Filename string `json:"filename"` - ContentType string `json:"content_type"` - SizeBytes int64 `json:"size_bytes"` - ChunkCount int `json:"chunk_count"` - Status string `json:"status"` - Error *string `json:"error,omitempty"` - UploadedBy string `json:"uploaded_by"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type searchKBRequest struct { - Query string `json:"query" binding:"required"` - Limit int `json:"limit"` - Threshold float64 `json:"threshold"` -} - -// ── Handler ────────────────────────────────── - -// KnowledgeBaseHandler handles KB CRUD and document management. -type KnowledgeBaseHandler struct { - stores store.Stores - objStore storage.ObjectStore - ingester *knowledge.Ingester - embedder *knowledge.Embedder -} - -// NewKnowledgeBaseHandler creates a KB handler. -func NewKnowledgeBaseHandler( - stores store.Stores, - objStore storage.ObjectStore, - ingester *knowledge.Ingester, - embedder *knowledge.Embedder, -) *KnowledgeBaseHandler { - return &KnowledgeBaseHandler{ - stores: stores, - objStore: objStore, - ingester: ingester, - embedder: embedder, - } -} - -// ── KB CRUD ────────────────────────────────── - -// CreateKB creates a new knowledge base. -// POST /api/v1/knowledge-bases -func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) { - var req createKBRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if len(req.Name) > kbMaxNameLen { - c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"}) - return - } - if len(req.Description) > kbMaxDescLen { - c.JSON(http.StatusBadRequest, gin.H{"error": "description too long"}) - return - } - - userID := getUserID(c) - - // Default scope - scope := "personal" - if req.Scope != "" { - scope = req.Scope - } - - // Validate scope - switch scope { - case "personal": - // OK — owned by user - case "team": - if req.TeamID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"}) - return - } - // Verify user is team admin (or system admin) - role, _ := c.Get("role") - if role != "admin" { - isTA, err := h.stores.Teams.IsTeamAdmin(c.Request.Context(), req.TeamID, userID) - if err != nil || !isTA { - c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"}) - return - } - } - case "global": - // Verify user is system admin - role, _ := c.Get("role") - if role != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "admin access required to create global knowledge bases"}) - return - } - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"}) - return - } - - kb := &models.KnowledgeBase{ - Name: req.Name, - Description: req.Description, - Scope: scope, - EmbeddingConfig: models.JSONMap{}, - Status: "active", - } - - // Set ownership based on scope - switch scope { - case "personal": - kb.OwnerID = &userID - case "team": - kb.TeamID = &req.TeamID - } - - if err := h.stores.KnowledgeBases.Create(c.Request.Context(), kb); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create knowledge base"}) - return - } - - h.auditLog(c, "kb.create", kb.ID, map[string]interface{}{ - "name": kb.Name, "scope": kb.Scope, - }) - - c.JSON(http.StatusCreated, toKBResponse(kb)) -} - -// ListKBs lists knowledge bases accessible to the current user. -// GET /api/v1/knowledge-bases -func (h *KnowledgeBaseHandler) ListKBs(c *gin.Context) { - userID := getUserID(c) - - // Get user's team IDs for scoped listing - teamIDs := h.getUserTeamIDs(c, userID) - - kbs, err := h.stores.KnowledgeBases.ListForUser(c.Request.Context(), userID, teamIDs) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"}) - return - } - - items := make([]kbResponse, len(kbs)) - for i := range kbs { - items[i] = toKBResponse(&kbs[i]) - } - - c.JSON(http.StatusOK, gin.H{"data": items, "total": len(items)}) -} - -// GetKB returns a single knowledge base. -// GET /api/v1/knowledge-bases/:id -func (h *KnowledgeBaseHandler) GetKB(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - c.JSON(http.StatusOK, toKBResponse(kb)) -} - -// UpdateKB updates a knowledge base's name/description. -// PUT /api/v1/knowledge-bases/:id -func (h *KnowledgeBaseHandler) UpdateKB(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req updateKBRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - fields := make(map[string]interface{}) - if req.Name != nil { - if len(*req.Name) > kbMaxNameLen { - c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"}) - return - } - fields["name"] = *req.Name - } - if req.Description != nil { - if len(*req.Description) > kbMaxDescLen { - c.JSON(http.StatusBadRequest, gin.H{"error": "description too long"}) - return - } - fields["description"] = *req.Description - } - - if len(fields) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return - } - - if err := h.stores.KnowledgeBases.Update(c.Request.Context(), kb.ID, fields); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update knowledge base"}) - return - } - - // Reload for response - updated, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kb.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload knowledge base"}) - return - } - c.JSON(http.StatusOK, toKBResponse(updated)) -} - -// DeleteKB deletes a knowledge base and all its documents/chunks. -// DELETE /api/v1/knowledge-bases/:id -func (h *KnowledgeBaseHandler) DeleteKB(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Delete stored files - if h.objStore != nil { - prefix := fmt.Sprintf("kb/%s/", kb.ID) - _ = h.objStore.DeletePrefix(c.Request.Context(), prefix) - } - - // Delete from DB (cascades to documents and chunks) - if err := h.stores.KnowledgeBases.Delete(c.Request.Context(), kb.ID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete knowledge base"}) - return - } - - h.auditLog(c, "kb.delete", kb.ID, map[string]interface{}{ - "name": kb.Name, "scope": kb.Scope, - }) - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// ── Document Management ────────────────────── - -// UploadDocument uploads a document to a KB and starts async ingestion. -// POST /api/v1/knowledge-bases/:id/documents -func (h *KnowledgeBaseHandler) UploadDocument(c *gin.Context) { - if h.objStore == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) - return - } - - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Verify embedding role is configured - if !h.embedder.IsConfigured(c.Request.Context()) { - c.JSON(http.StatusPreconditionFailed, gin.H{ - "error": "embedding model role not configured — set up an embedding provider in Settings → Model Roles", - }) - return - } - - // Parse multipart - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"}) - return - } - defer file.Close() - - if header.Size > kbMaxDocSize { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("file too large (max %d MB)", kbMaxDocSize/(1024*1024)), - }) - return - } - - // MIME detection - buf := make([]byte, 512) - n, _ := file.Read(buf) - contentType := http.DetectContentType(buf[:n]) - if idx := strings.Index(contentType, ";"); idx > 0 { - contentType = strings.TrimSpace(contentType[:idx]) - } - - // Fall back to extension for octet-stream - if contentType == "application/octet-stream" { - ext := strings.ToLower(filepath.Ext(header.Filename)) - switch ext { - case ".md", ".markdown": - contentType = "text/markdown" - case ".csv": - contentType = "text/csv" - case ".html", ".htm": - contentType = "text/html" - case ".txt": - contentType = "text/plain" - } - } - - // Allowlist check - if !allowedKBMIMETypes[contentType] { - c.JSON(http.StatusBadRequest, gin.H{ - "error": fmt.Sprintf("file type %q not supported for knowledge bases — only text, markdown, CSV, and HTML are currently supported", contentType), - }) - return - } - - // Reset reader - if _, err := file.Seek(0, io.SeekStart); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"}) - return - } - - userID := getUserID(c) - filename := sanitizeFilename(header.Filename) - - // Create document record (status=pending) - doc := &models.KBDocument{ - KBID: kb.ID, - Filename: filename, - ContentType: contentType, - SizeBytes: header.Size, - StorageKey: "placeholder", - Status: "pending", - UploadedBy: userID, - } - - if err := h.stores.KnowledgeBases.CreateDocument(c.Request.Context(), doc); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create document record"}) - return - } - - // Build storage key and save file - doc.StorageKey = fmt.Sprintf("kb/%s/%s_%s", kb.ID, doc.ID, filename) - if err := h.objStore.Put(c.Request.Context(), doc.StorageKey, file, header.Size, contentType); err != nil { - // Rollback DB record - h.stores.KnowledgeBases.DeleteDocument(c.Request.Context(), doc.ID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) - return - } - - // Update storage key in DB - h.updateDocStorageKey(c, doc.ID, doc.StorageKey) - - // Resolve team ID for embedding role - var teamID *string - if kb.TeamID != nil { - teamID = kb.TeamID - } - - // Fire async ingestion - h.ingester.IngestDocument(kb, doc, userID, teamID) - - h.auditLog(c, "kb.document.upload", kb.ID, map[string]interface{}{ - "kb_name": kb.Name, "doc_id": doc.ID, "filename": doc.Filename, - "size_bytes": doc.SizeBytes, - }) - - c.JSON(http.StatusAccepted, toDocResponse(doc)) -} - -// ListDocuments lists all documents in a KB. -// GET /api/v1/knowledge-bases/:id/documents -func (h *KnowledgeBaseHandler) ListDocuments(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - docs, err := h.stores.KnowledgeBases.ListDocuments(c.Request.Context(), kb.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list documents"}) - return - } - - items := make([]docResponse, len(docs)) - for i := range docs { - items[i] = toDocResponse(&docs[i]) - } - - c.JSON(http.StatusOK, gin.H{"data": items, "total": len(items)}) -} - -// GetDocumentStatus returns the current ingestion status of a document. -// GET /api/v1/knowledge-bases/:id/documents/:docId/status -func (h *KnowledgeBaseHandler) GetDocumentStatus(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - docID := c.Param("docId") - doc, err := h.stores.KnowledgeBases.GetDocument(c.Request.Context(), docID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) - return - } - - if doc.KBID != kb.ID { - c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "id": doc.ID, - "status": doc.Status, - "chunk_count": doc.ChunkCount, - "error": doc.Error, - "filename": doc.Filename, - }) -} - -// DeleteDocument removes a document and its chunks. -// DELETE /api/v1/knowledge-bases/:id/documents/:docId -func (h *KnowledgeBaseHandler) DeleteDocument(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - docID := c.Param("docId") - doc, err := h.stores.KnowledgeBases.GetDocument(c.Request.Context(), docID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) - return - } - - if doc.KBID != kb.ID { - c.JSON(http.StatusNotFound, gin.H{"error": "document not found"}) - return - } - - // Delete chunks first - h.stores.KnowledgeBases.DeleteChunksForDocument(c.Request.Context(), docID) - - // Delete the document record (returns doc for storage cleanup) - deleted, err := h.stores.KnowledgeBases.DeleteDocument(c.Request.Context(), docID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete document"}) - return - } - - // Clean up stored file - if h.objStore != nil && deleted != nil && deleted.StorageKey != "" && deleted.StorageKey != "placeholder" { - _ = h.objStore.Delete(c.Request.Context(), deleted.StorageKey) - } - - // Update KB stats - h.stores.KnowledgeBases.UpdateStats(c.Request.Context(), kb.ID) - - h.auditLog(c, "kb.document.delete", kb.ID, map[string]interface{}{ - "kb_name": kb.Name, "doc_id": docID, - "filename": safeFilename(deleted), - }) - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// ── Search ─────────────────────────────────── - -// SearchKB performs a direct similarity search against a KB. -// POST /api/v1/knowledge-bases/:id/search -func (h *KnowledgeBaseHandler) SearchKB(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req searchKBRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Limit <= 0 || req.Limit > 20 { - req.Limit = 5 - } - if req.Threshold <= 0 { - req.Threshold = 0.3 // default cosine similarity threshold - } - - userID := getUserID(c) - var teamID *string - if kb.TeamID != nil { - teamID = kb.TeamID - } - - // Embed the query - embedResult, err := h.embedder.EmbedChunks(c.Request.Context(), userID, teamID, []string{req.Query}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to embed query"}) - return - } - - if len(embedResult.Vectors) == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "embedding produced no vectors"}) - return - } - - // Search - results, err := h.stores.KnowledgeBases.SimilaritySearch( - c.Request.Context(), - []string{kb.ID}, - embedResult.Vectors[0], - req.Threshold, - req.Limit, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "data": results, - "query": req.Query, - "total": len(results), - }) -} - -// ── Helpers ────────────────────────────────── - -// loadAndAuthorize loads a KB by ID from the URL and checks the user -// has access to it (owner, team member, or admin for global). -func (h *KnowledgeBaseHandler) loadAndAuthorize(c *gin.Context) (*models.KnowledgeBase, bool) { - kbID := c.Param("id") - if kbID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing knowledge base ID"}) - return nil, false - } - - kb, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kbID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"}) - return nil, false - } - - userID := getUserID(c) - - switch kb.Scope { - case "personal": - if kb.OwnerID == nil || *kb.OwnerID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"}) - return nil, false - } - case "team": - // Verify user is a member of the team - teamIDs := h.getUserTeamIDs(c, userID) - found := false - for _, tid := range teamIDs { - if kb.TeamID != nil && tid == *kb.TeamID { - found = true - break - } - } - if !found { - c.JSON(http.StatusNotFound, gin.H{"error": "knowledge base not found"}) - return nil, false - } - case "global": - // Global KBs are readable by everyone, writable by admins - // For now, allow all authenticated users to read - } - - return kb, true -} - -// getUserTeamIDs returns the team IDs for the current user. -func (h *KnowledgeBaseHandler) getUserTeamIDs(c *gin.Context, userID string) []string { - ids, err := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) - if err != nil { - return nil - } - return ids -} - -// updateDocStorageKey sets the storage_key on a document after upload. -func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) { - h.stores.KnowledgeBases.UpdateDocumentStorageKey(c.Request.Context(), docID, storageKey) -} - -// toKBResponse converts a model to the API response type. -func toKBResponse(kb *models.KnowledgeBase) kbResponse { - return kbResponse{ - ID: kb.ID, - Name: kb.Name, - Description: kb.Description, - Scope: kb.Scope, - OwnerID: kb.OwnerID, - TeamID: kb.TeamID, - EmbeddingConfig: kb.EmbeddingConfig, - DocumentCount: kb.DocumentCount, - ChunkCount: kb.ChunkCount, - TotalBytes: kb.TotalBytes, - Status: kb.Status, - CreatedAt: kb.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: kb.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } -} - -// toDocResponse converts a document model to the API response type. -func toDocResponse(doc *models.KBDocument) docResponse { - return docResponse{ - ID: doc.ID, - KBID: doc.KBID, - Filename: doc.Filename, - ContentType: doc.ContentType, - SizeBytes: doc.SizeBytes, - ChunkCount: doc.ChunkCount, - Status: doc.Status, - Error: doc.Error, - UploadedBy: doc.UploadedBy, - CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: doc.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } -} - -// ── Channel KB Toggle ──────────────────────── - -// GetChannelKBs returns knowledge bases linked to a channel. -// GET /api/v1/channels/:id/knowledge-bases -func (h *KnowledgeBaseHandler) GetChannelKBs(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - if !userOwnsChannel(c, channelID, userID) { - return - } - - kbs, err := h.stores.KnowledgeBases.GetChannelKBs(c.Request.Context(), channelID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel knowledge bases"}) - return - } - - if kbs == nil { - kbs = []models.ChannelKB{} - } - - c.JSON(http.StatusOK, gin.H{"data": kbs}) -} - -// SetChannelKBs links knowledge bases to a channel (replaces existing links). -// PUT /api/v1/channels/:id/knowledge-bases -// Body: { "kb_ids": ["uuid1", "uuid2"] } -func (h *KnowledgeBaseHandler) SetChannelKBs(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - if !userOwnsChannel(c, channelID, userID) { - return - } - - var req struct { - KBIDs []string `json:"kb_ids" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate that user has access to all requested KBs - teamIDs := h.getUserTeamIDs(c, userID) - for _, kbID := range req.KBIDs { - kb, err := h.stores.KnowledgeBases.GetByID(c.Request.Context(), kbID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("knowledge base %s not found", kbID)}) - return - } - if !h.userCanAccess(kb, userID, teamIDs) { - c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("no access to knowledge base %s", kbID)}) - return - } - } - - if err := h.stores.KnowledgeBases.SetChannelKBs(c.Request.Context(), channelID, req.KBIDs); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel knowledge bases"}) - return - } - - // Return the updated list - kbs, _ := h.stores.KnowledgeBases.GetChannelKBs(c.Request.Context(), channelID) - if kbs == nil { - kbs = []models.ChannelKB{} - } - - h.auditLog(c, "kb.channel.link", channelID, map[string]interface{}{ - "channel_id": channelID, "kb_ids": req.KBIDs, - }) - - c.JSON(http.StatusOK, gin.H{"data": kbs}) -} - -// ── Discoverable Management (v0.17.0) ──────────── - -// SetDiscoverable toggles KB visibility to users. -// Only the KB owner or a system admin may change this flag. -func (h *KnowledgeBaseHandler) SetDiscoverable(c *gin.Context) { - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Require ownership or admin role to toggle discoverability - userID := getUserID(c) - role, _ := c.Get("role") - isAdmin := role == "admin" - isOwner := kb.OwnerID != nil && *kb.OwnerID == userID - - if !isAdmin && !isOwner { - c.JSON(http.StatusForbidden, gin.H{"error": "only the KB owner or a system admin can change discoverability"}) - return - } - - var req struct { - Discoverable bool `json:"discoverable"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.KnowledgeBases.SetDiscoverable(c.Request.Context(), kb.ID, req.Discoverable); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update discoverability"}) - return - } - - h.auditLog(c, "kb.discoverable", kb.ID, map[string]interface{}{ - "kb_name": kb.Name, "discoverable": req.Discoverable, - }) - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// ListDiscoverableKBs returns KBs the user can see that are marked discoverable. -// Used by the channel KB toggle popup when kb_direct_access is enabled. -func (h *KnowledgeBaseHandler) ListDiscoverableKBs(c *gin.Context) { - userID := getUserID(c) - teamIDs := h.getUserTeamIDs(c, userID) - - kbs, err := h.stores.KnowledgeBases.ListDiscoverable(c.Request.Context(), userID, teamIDs) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"}) - return - } - if kbs == nil { - kbs = []models.KnowledgeBase{} - } - - items := make([]kbResponse, len(kbs)) - for i := range kbs { - items[i] = toKBResponse(&kbs[i]) - } - - c.JSON(http.StatusOK, gin.H{"data": items}) -} - -// userCanAccess checks if a user can access a KB based on scope. -func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool { - switch kb.Scope { - case "global": - return true - case "personal": - return kb.OwnerID != nil && *kb.OwnerID == userID - case "team": - if kb.TeamID == nil { - return false - } - for _, tid := range teamIDs { - if tid == *kb.TeamID { - return true - } - } - return false - } - return false -} - -// ── KB Hint for System Prompt ──────────────── - -// BuildKBHint returns a system prompt fragment listing active KBs for a -// channel, or empty string if none are active. Called by the completion -// handler to nudge the LLM to use kb_search. -// -// Deprecated: v0.29.0 — replaced by filters.KBInjectFilter in the -// pre-completion filter chain. Kept for backward compatibility with -// any callers outside the main completion path. Will be removed in v0.30.0. -func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string { - teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID) - - // Collect KB details for the hint - type kbInfo struct { - Name string - DocCount int - } - var kbs []kbInfo - seen := make(map[string]bool) - - // Persona-bound KBs (v0.17.0) - if personaID != "" { - personaKBs, err := stores.Personas.GetKBs(ctx, personaID) - if err == nil { - for _, pkb := range personaKBs { - if pkb.ChunkCount > 0 { - kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount}) - seen[pkb.KBID] = true - } - } - } - } - - // Project-bound KBs (v0.19.0) - if stores.Projects != nil { - projID, _ := stores.Projects.GetProjectIDForChannel(ctx, channelID) - if projID != "" { - projKBIDs, projErr := stores.Projects.GetKBIDs(ctx, projID) - if projErr == nil { - for _, kbID := range projKBIDs { - if !seen[kbID] { - kb, kbErr := stores.KnowledgeBases.GetByID(ctx, kbID) - if kbErr == nil && kb.ChunkCount > 0 { - kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) - seen[kbID] = true - } - } - } - } - } - } - - // Channel-linked KBs - channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID) - if err == nil { - for _, ckb := range channelKBs { - if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] { - kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount}) - seen[ckb.KBID] = true - } - } - } - - // Personal KBs (not already counted) - personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID) - if err == nil { - for _, kb := range personalKBs { - if !seen[kb.ID] && kb.ChunkCount > 0 { - kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount}) - seen[kb.ID] = true - } - } - } - - // Group-granted KBs are resolved via GetActiveKBIDs — only add names - // for KBs found through channel link or persona. The kb_search tool - // handles the full resolution at search time. - _ = teamIDs // used by GetActiveKBIDsWithPersona at search time - - if len(kbs) == 0 { - return "" - } - - hint := "\nYou have access to the following knowledge bases:\n" - for _, kb := range kbs { - hint += fmt.Sprintf("- \"%s\" (%d documents)\n", kb.Name, kb.DocCount) - } - hint += "Use the kb_search tool to find relevant information from these sources when the user asks questions that might be answered by their documents." - - return hint -} - -// ── Audit Logging ────────────────────────────── - -// safeFilename safely extracts the filename from a deleted document. -func safeFilename(doc *models.KBDocument) string { - if doc == nil { - return "" - } - return doc.Filename -} - -// auditLog records a KB operation in the audit log. -func (h *KnowledgeBaseHandler) auditLog(c *gin.Context, action, resourceID string, metadata map[string]interface{}) { - if h.stores.Audit == nil { - return - } - - userID := getUserID(c) - var meta models.JSONMap - if metadata != nil { - meta = models.JSONMap(metadata) - } - - entry := &models.AuditEntry{ - ActorID: &userID, - Action: action, - ResourceType: "knowledge_base", - ResourceID: resourceID, - Metadata: meta, - IPAddress: c.ClientIP(), - UserAgent: c.GetHeader("User-Agent"), - } - - if err := h.stores.Audit.Log(context.Background(), entry); err != nil { - log.Printf("audit log error (KB): %v", err) - } -} - -// ── KB Rebuild ───────────────────────────────── - -// RebuildKB re-chunks and re-embeds all documents in a knowledge base. -// POST /api/v1/knowledge-bases/:id/rebuild -func (h *KnowledgeBaseHandler) RebuildKB(c *gin.Context) { - if h.ingester == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "ingestion pipeline not configured"}) - return - } - - kb, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - ctx := c.Request.Context() - userID := getUserID(c) - - // List all documents in the KB - docs, err := h.stores.KnowledgeBases.ListDocuments(ctx, kb.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list documents"}) - return - } - - if len(docs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "knowledge base has no documents to rebuild"}) - return - } - - // Resolve team for embedding role - teamIDs := h.getUserTeamIDs(c, userID) - var teamID *string - if len(teamIDs) > 0 { - teamID = &teamIDs[0] - } - - // Delete all existing chunks (embeddings will be regenerated) - rebuiltCount := 0 - for _, doc := range docs { - if doc.ExtractedText == nil || *doc.ExtractedText == "" { - // Skip docs without extracted text — they'd need re-extraction - // which requires the original file in object store - continue - } - - // Clear existing chunks for this document - if err := h.stores.KnowledgeBases.DeleteChunksForDocument(ctx, doc.ID); err != nil { - log.Printf("⚠ rebuild: failed to delete chunks for doc %s: %v", doc.ID, err) - continue - } - - // Reset document status and re-ingest - h.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "pending", nil) - h.ingester.IngestDocument(kb, &doc, userID, teamID) - rebuiltCount++ - } - - h.auditLog(c, "kb.rebuild", kb.ID, map[string]interface{}{ - "kb_name": kb.Name, - "total_docs": len(docs), - "rebuilt_docs": rebuiltCount, - }) - - c.JSON(http.StatusAccepted, gin.H{ - "message": "rebuild started", - "total_docs": len(docs), - "rebuilding": rebuiltCount, - "skipped": len(docs) - rebuiltCount, - }) -} diff --git a/server/handlers/live_compaction_test.go b/server/handlers/live_compaction_test.go deleted file mode 100644 index a1b7de5..0000000 --- a/server/handlers/live_compaction_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "strings" - "testing" - - "switchboard-core/compaction" - "switchboard-core/database" - "switchboard-core/models" - "switchboard-core/roles" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" - "switchboard-core/treepath" -) - -// ═══════════════════════════════════════════ -// Live Compaction Tests -// ═══════════════════════════════════════════ -// Requires: TEST_DATABASE_URL + PROVIDER_KEY (or VENICE_API_KEY) -// -// Tests the full Compact() pipeline end-to-end: -// seed messages → call utility model → verify summary tree node -// ═══════════════════════════════════════════ - -// dialectStores returns the correct store bundle for the active dialect. -func dialectStores() store.Stores { - if database.IsSQLite() { - return sqlite.NewStores(database.TestDB) - } - return postgres.NewStores(database.TestDB) -} - -func TestLive_CompactionFullPipeline(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - userID, adminToken := h.createAdminUser("compactadmin", "compact@test.com") - - // Set up provider + enable first model - configID, modelID := setupProviderWithModel(t, h, adminToken, pc) - - // Configure utility role - w := h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{ - "primary": map[string]interface{}{ - "provider_config_id": configID, - "model_id": modelID, - }, - }) - if w.Code != http.StatusOK { - t.Fatalf("configure utility role: %d: %s", w.Code, w.Body.String()) - } - t.Log(" ✓ Utility role configured with", modelID) - - // Create a channel with enough messages to summarize - w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{ - "title": "Compaction Test", "type": "direct", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create channel: %d: %s", w.Code, w.Body.String()) - } - var ch map[string]interface{} - decode(w, &ch) - channelID := ch["id"].(string) - - // Seed messages manually (not via completion — faster and cheaper) - msgs := []struct{ role, content string }{ - {"user", "I'm planning a trip to Japan next month. What cities should I visit?"}, - {"assistant", "For a Japan trip, I'd recommend Tokyo for modern culture, Kyoto for temples and traditional culture, Osaka for food, and Hiroshima for history. How long is your trip?"}, - {"user", "About 10 days. I'm most interested in food and temples."}, - {"assistant", "With 10 days focusing on food and temples, I'd suggest: 3 days in Kyoto for Fushimi Inari, Kinkaku-ji, and Arashiyama. 2 days in Osaka for Dotonbori street food, Kuromon Market, and Namba. 2 days in Tokyo for Tsukiji Outer Market, Senso-ji, and Meiji Shrine. Then day trips to Nara (deer park and Todai-ji) and Kamakura (Great Buddha)."}, - {"user", "That sounds great! What about budget? I'm trying to keep it under $3000 for the whole trip."}, - {"assistant", "A $3000 budget for 10 days in Japan is doable. Rough breakdown: Flights ~$800-1000, Accommodation ~$50-80/night in hostels or budget hotels ($500-800), JR Pass 14-day ~$380, Food ~$30-50/day ($300-500), Activities and temples ~$200. Total: $2180-2880. Tips: eat at konbini (convenience stores) for cheap meals, visit free shrines, and book accommodation early."}, - {"user", "Should I get a JR Pass or just buy individual tickets?"}, - {"assistant", "For your itinerary covering Tokyo, Kyoto, Osaka, Nara, and Kamakura, a 7-day JR Pass ($200) would cover most of your intercity travel. The Tokyo-Kyoto shinkansen alone costs $120 one-way. I'd recommend the 7-day pass starting when you leave Tokyo for Kyoto, then use IC cards (Suica/Pasmo) for local transit."}, - {"user", "Perfect. One more question — what's the best time to visit temples to avoid crowds?"}, - {"assistant", "Early morning is best for temples. Fushimi Inari at sunrise (5-6am) is magical and nearly empty. Kinkaku-ji opens at 9am — arrive right at opening. Arashiyama bamboo grove is best before 8am. For Senso-ji in Tokyo, go at dawn for beautiful photos. Weekdays are always less crowded than weekends."}, - } - - ph := database.PH - var lastMsgID string - for _, m := range msgs { - var parentPtr *string - if lastMsgID != "" { - parentPtr = &lastMsgID - } - err := database.TestDB.QueryRow( - "INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id", - channelID, parentPtr, m.role, m.content).Scan(&lastMsgID) - if err != nil { - t.Fatalf("seed message: %v", err) - } - } - - // Set cursor to last message - database.TestDB.Exec( - "INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id", - channelID, userID, lastMsgID) - - t.Logf(" ✓ Seeded %d messages in channel %s", len(msgs), channelID) - - // ── Run compaction ── - stores := dialectStores() - resolver := roles.NewResolver(stores, nil) - svc := compaction.NewService(stores, resolver) - - result, err := svc.Compact(context.Background(), compaction.CompactRequest{ - ChannelID: channelID, - UserID: userID, - TeamID: nil, - Trigger: "manual", - }) - if err != nil { - t.Fatalf("Compact() failed: %v", err) - } - - t.Logf(" ✓ Compact result: %d messages summarized, model=%s, %d chars", - result.SummarizedCount, result.Model, len(result.Content)) - - // Verify summary was created - if result.SummarizedCount != len(msgs) { - t.Errorf("summarized_count = %d, want %d", result.SummarizedCount, len(msgs)) - } - if result.Content == "" { - t.Fatal("summary content should not be empty") - } - if result.Model != modelID { - t.Errorf("model = %q, want %q", result.Model, modelID) - } - - // Verify summary message exists in the tree - var summaryContent, summaryRole string - var metadataRaw []byte - err = database.TestDB.QueryRow( - "SELECT role, content, metadata FROM messages WHERE id = "+ph(1), - result.SummaryID).Scan(&summaryRole, &summaryContent, &metadataRaw) - if err != nil { - t.Fatalf("query summary message: %v", err) - } - if summaryRole != "assistant" { - t.Errorf("summary role = %q, want assistant", summaryRole) - } - - var metadata models.JSONMap - json.Unmarshal(metadataRaw, &metadata) - if metadata["type"] != "summary" { - t.Errorf("metadata.type = %q, want summary", metadata["type"]) - } - if metadata["trigger"] != "manual" { - t.Errorf("metadata.trigger = %q, want manual", metadata["trigger"]) - } - t.Logf(" ✓ Summary message verified: id=%s, metadata=%v", result.SummaryID, metadata) - - // Verify cursor was updated to point to summary - path, err := treepath.GetActivePath(channelID, userID) - if err != nil { - t.Fatalf("GetActivePath after compaction: %v", err) - } - - if len(path) == 0 { - t.Fatal("path should not be empty after compaction") - } - lastPath := path[len(path)-1] - if lastPath.ID != result.SummaryID { - t.Errorf("active path leaf = %s, want summary %s", lastPath.ID, result.SummaryID) - } - t.Logf(" ✓ Cursor updated: path has %d messages, leaf is summary", len(path)) - - // Verify usage was logged - var usageCount int - database.TestDB.QueryRow( - "SELECT COUNT(*) FROM usage_log WHERE channel_id = "+ph(1)+" AND role = 'utility'", - channelID).Scan(&usageCount) - if usageCount == 0 { - t.Error("expected usage_log entry for utility role") - } - t.Logf(" ✓ Usage logged: %d entries", usageCount) -} - -// TestLive_CompactionContextBudgetGuardRail verifies that Compact() -// rejects input that exceeds the utility model's context window. -func TestLive_CompactionContextBudgetGuardRail(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - _, adminToken := h.createAdminUser("guardrail_admin", "guardrail@test.com") - userID := database.SeedTestUser(t, "guardrail_user", "gruser@test.com") - - // Set up provider + enable first model - configID, modelID := setupProviderWithModel(t, h, adminToken, pc) - - ph := database.PH - - // Set max_context to 32000 in catalog (in case sync didn't populate it) - if database.IsSQLite() { - database.TestDB.Exec( - "UPDATE model_catalog SET capabilities = json_set(COALESCE(capabilities,'{}'), '$.max_context', 32000) WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2), - configID, modelID) - } else { - database.TestDB.Exec( - "UPDATE model_catalog SET capabilities = capabilities || '{\"max_context\": 32000}'::jsonb WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2), - configID, modelID) - } - - // Configure utility role - h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{ - "primary": map[string]interface{}{ - "provider_config_id": configID, - "model_id": modelID, - }, - }) - - // Create channel with huge messages (>32K context worth) - w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{ - "title": "Guard Rail Test", "type": "direct", - }) - var ch map[string]interface{} - decode(w, &ch) - channelID := ch["id"].(string) - - // Seed 20 messages × 8000 chars each = 160K chars ≈ 40K tokens - bigContent := make([]byte, 8000) - for i := range bigContent { - bigContent[i] = 'a' - } - var lastMsgID string - for i := 0; i < 20; i++ { - var parentPtr *string - if lastMsgID != "" { - parentPtr = &lastMsgID - } - role := "user" - if i%2 == 1 { - role = "assistant" - } - database.TestDB.QueryRow( - "INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id", - channelID, parentPtr, role, string(bigContent)).Scan(&lastMsgID) - } - database.TestDB.Exec( - "INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id", - channelID, userID, lastMsgID) - - // Run compaction — should fail with context budget error - stores := dialectStores() - resolver := roles.NewResolver(stores, nil) - svc := compaction.NewService(stores, resolver) - - _, err := svc.Compact(context.Background(), compaction.CompactRequest{ - ChannelID: channelID, - UserID: userID, - TeamID: nil, - Trigger: "auto", - }) - if err == nil { - t.Fatal("Compact() should fail when content exceeds utility model context window") - } - - t.Logf(" ✓ Guard rail triggered: %v", err) - - if !strings.Contains(err.Error(), "context window") { - t.Errorf("expected context window error, got: %v", err) - } -} diff --git a/server/handlers/live_provider_test.go b/server/handlers/live_provider_test.go deleted file mode 100644 index 00ed869..0000000 --- a/server/handlers/live_provider_test.go +++ /dev/null @@ -1,812 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "switchboard-core/database" - "switchboard-core/providers" -) - -// ═══════════════════════════════════════════ -// Live Provider Integration Tests -// ═══════════════════════════════════════════ -// These tests require: -// - TEST_DATABASE_URL or PGHOST+PGUSER -// - PROVIDER + PROVIDER_KEY (or legacy VENICE_API_KEY) -// -// Provider config env vars: -// PROVIDER — provider type: "venice", "openai", "anthropic" (default: "venice") -// PROVIDER_KEY — API key (falls back to VENICE_API_KEY for compat) -// PROVIDER_URL — endpoint override (optional, uses default for known providers) -// -// They exercise the full flow: create provider → -// fetch models → enable model → resolve → complete. -// ═══════════════════════════════════════════ - -// liveProviderConfig holds resolved provider settings for live tests. -type liveProviderConfig struct { - Provider string // "venice", "openai", "anthropic" - Key string - Endpoint string -} - -// defaultEndpoints maps provider names to their default API endpoints. -var defaultEndpoints = map[string]string{ - "venice": "https://api.venice.ai/api/v1", - "openai": "https://api.openai.com/v1", - "anthropic": "https://api.anthropic.com/v1", - "openrouter": "https://openrouter.ai/api/v1", -} - -// providerKeyEnvs maps provider names to their API key env var names. -var providerKeyEnvs = map[string]string{ - "venice": "VENICE_API_KEY", - "openai": "OPENAI_API_KEY", - "anthropic": "ANTHROPIC_API_KEY", - "openrouter": "OPENROUTER_API_KEY", -} - -// requireLiveProvider resolves the primary provider config from env vars. -// Kept for backward compat — tests that only need one provider use this. -func requireLiveProvider(t *testing.T) liveProviderConfig { - t.Helper() - - key := os.Getenv("PROVIDER_KEY") - if key == "" { - // Legacy fallback - key = os.Getenv("VENICE_API_KEY") - } - if key == "" { - t.Skip("PROVIDER_KEY (or VENICE_API_KEY) not set — skipping live provider test") - } - - provider := os.Getenv("PROVIDER") - if provider == "" { - provider = "venice" // default - } - - endpoint := os.Getenv("PROVIDER_URL") - if endpoint == "" { - var ok bool - endpoint, ok = defaultEndpoints[provider] - if !ok { - t.Fatalf("PROVIDER=%q has no default endpoint — set PROVIDER_URL", provider) - } - } - - t.Logf(" Live provider: %s @ %s", provider, endpoint) - return liveProviderConfig{Provider: provider, Key: key, Endpoint: endpoint} -} - -// requireLiveProviders resolves all configured providers for failover tests. -// Reads LIVE_PROVIDERS (comma-separated, e.g. "venice,openai") and resolves -// API keys from {PROVIDER}_API_KEY env vars. Falls back to requireLiveProvider -// if LIVE_PROVIDERS is not set. -func requireLiveProviders(t *testing.T) []liveProviderConfig { - t.Helper() - - list := os.Getenv("LIVE_PROVIDERS") - if list == "" { - // Fallback: just the primary provider - return []liveProviderConfig{requireLiveProvider(t)} - } - - var configs []liveProviderConfig - for _, name := range splitTrim(list, ",") { - keyEnv := providerKeyEnvs[name] - if keyEnv == "" { - keyEnv = strings.ToUpper(name) + "_API_KEY" - } - key := os.Getenv(keyEnv) - if key == "" { - t.Logf(" Skipping provider %s: %s not set", name, keyEnv) - continue - } - endpoint := os.Getenv(strings.ToUpper(name) + "_API_URL") - if endpoint == "" { - endpoint = defaultEndpoints[name] - } - if endpoint == "" { - t.Logf(" Skipping provider %s: no endpoint", name) - continue - } - configs = append(configs, liveProviderConfig{Provider: name, Key: key, Endpoint: endpoint}) - } - - if len(configs) == 0 { - t.Skip("no live providers configured — set LIVE_PROVIDERS + API keys") - } - t.Logf(" Live providers: %d configured", len(configs)) - return configs -} - -// splitTrim splits s by sep and trims whitespace from each element. -func splitTrim(s, sep string) []string { - parts := strings.Split(s, sep) - var out []string - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} - -// providerModel holds a resolved (configID, modelID) pair from a provider. -type providerModel struct { - ConfigID string - ModelID string - Provider string -} - -// setupAllProviders creates configs and enables a model for each provider. -// Tolerant: if a provider fails setup, it's skipped. Fails only if zero succeed. -func setupAllProviders(t *testing.T, h *testHarness, adminToken string, providerList []liveProviderConfig) []providerModel { - t.Helper() - var models []providerModel - for _, pc := range providerList { - configID, modelID, err := trySetupProvider(h, adminToken, pc) - if err != nil { - t.Logf(" ⚠ %s setup failed: %v — skipping", pc.Provider, err) - continue - } - t.Logf(" Provider %s ready, model %s enabled", pc.Provider, modelID) - models = append(models, providerModel{ConfigID: configID, ModelID: modelID, Provider: pc.Provider}) - } - if len(models) == 0 { - t.Fatal("no providers available after setup — all failed") - } - return models -} - -// trySetupProvider is like setupProviderWithModel but returns error instead of t.Fatal. -func trySetupProvider(h *testHarness, adminToken string, pc liveProviderConfig) (string, string, error) { - // Create provider - w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ - "name": pc.Provider + " Test", "provider": pc.Provider, - "endpoint": pc.Endpoint, "api_key": pc.Key, - }) - if w.Code != http.StatusCreated { - return "", "", fmt.Errorf("create config: %d: %s", w.Code, w.Body.String()) - } - var cfg map[string]interface{} - decode(w, &cfg) - configID := cfg["id"].(string) - - // Fetch models - w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, - map[string]interface{}{"provider_config_id": configID}) - if w.Code != http.StatusOK { - return "", "", fmt.Errorf("fetch models: %d: %s", w.Code, w.Body.String()) - } - - // Find and enable a non-reasoning model - w = h.request("GET", "/api/v1/admin/models", adminToken, nil) - var modelsResp map[string]interface{} - decode(w, &modelsResp) - - var catalogID, modelID string - var fallbackCatalogID, fallbackModelID string - var toolCapableCatalogID, toolCapableModelID string - - for _, raw := range modelsResp["data"].([]interface{}) { - m := raw.(map[string]interface{}) - if m["visibility"].(string) != "disabled" { - continue - } - // Only pick models from this provider - if m["provider_config_id"] != nil && m["provider_config_id"].(string) != configID { - continue - } - mid := m["model_id"].(string) - cid := m["id"].(string) - if fallbackCatalogID == "" { - fallbackCatalogID = cid - fallbackModelID = mid - } - isReasoning := false - hasToolCalling := false - if caps, ok := m["capabilities"].(map[string]interface{}); ok { - if r, exists := caps["reasoning"]; exists && r == true { - isReasoning = true - } - if tc, exists := caps["tool_calling"]; exists && tc == true { - hasToolCalling = true - } - } - // Prefer non-reasoning models with tool_calling support - if !isReasoning && hasToolCalling && toolCapableCatalogID == "" { - toolCapableCatalogID = cid - toolCapableModelID = mid - } - if !isReasoning && catalogID == "" { - catalogID = cid - modelID = mid - } - } - // Prefer tool-capable model (server may inject tools into completion) - if toolCapableCatalogID != "" { - catalogID = toolCapableCatalogID - modelID = toolCapableModelID - } - if catalogID == "" { - catalogID = fallbackCatalogID - modelID = fallbackModelID - } - if catalogID == "" { - return "", "", fmt.Errorf("no disabled model found") - } - - w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken, - map[string]interface{}{"visibility": "enabled"}) - if w.Code != http.StatusOK { - return "", "", fmt.Errorf("enable model: %d: %s", w.Code, w.Body.String()) - } - return configID, modelID, nil -} - -// tryCompletion attempts a completion against each provider/model in order. -// Returns the first successful response and the provider that worked. -// Fails only if ALL providers fail. -func tryCompletion(t *testing.T, h *testHarness, token, channelID string, models []providerModel, stream bool) (*httptest.ResponseRecorder, providerModel) { - t.Helper() - var lastW *httptest.ResponseRecorder - for _, pm := range models { - w := h.request("POST", "/api/v1/chat/completions", token, map[string]interface{}{ - "channel_id": channelID, - "content": "Say ok", - "model": pm.ModelID, - "provider_config_id": pm.ConfigID, - "stream": &stream, - "max_tokens": 1200, - }) - if w.Code == http.StatusOK { - t.Logf(" ✓ Completion via %s/%s (status %d)", pm.Provider, pm.ModelID, w.Code) - return w, pm - } - t.Logf(" ✗ %s/%s returned %d — trying next", pm.Provider, pm.ModelID, w.Code) - lastW = w - } - // All failed - body := "" - if lastW != nil { - body = lastW.Body.String() - } - t.Fatalf("all %d providers failed; last: %s", len(models), body) - return nil, providerModel{} // unreachable -} - -// setupProviderWithModel creates a provider config, fetches models, and enables -// the first available model. Returns (configID, enabledModelID). -func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc liveProviderConfig) (string, string) { - t.Helper() - - // Create provider - w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ - "name": pc.Provider + " Test", "provider": pc.Provider, - "endpoint": pc.Endpoint, "api_key": pc.Key, - }) - if w.Code != http.StatusCreated { - t.Fatalf("create %s config: want 201, got %d: %s", pc.Provider, w.Code, w.Body.String()) - } - var cfg map[string]interface{} - decode(w, &cfg) - configID := cfg["id"].(string) - - // Fetch models - w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, - map[string]interface{}{"provider_config_id": configID}) - if w.Code != http.StatusOK { - t.Fatalf("fetch models: %d: %s", w.Code, w.Body.String()) - } - - // Find and enable a model. - // Prefer non-reasoning models: they're cheaper and don't require - // minimum thinking budget tokens (which causes 400s with low max_tokens). - w = h.request("GET", "/api/v1/admin/models", adminToken, nil) - var modelsResp map[string]interface{} - decode(w, &modelsResp) - - var catalogID, modelID string - var fallbackCatalogID, fallbackModelID string - - for _, raw := range modelsResp["data"].([]interface{}) { - m := raw.(map[string]interface{}) - if m["visibility"].(string) != "disabled" { - continue - } - - mid := m["model_id"].(string) - cid := m["id"].(string) - - // Save first disabled model as fallback - if fallbackCatalogID == "" { - fallbackCatalogID = cid - fallbackModelID = mid - } - - // Check if this is a reasoning model — skip it if possible - isReasoning := false - if caps, ok := m["capabilities"].(map[string]interface{}); ok { - if r, exists := caps["reasoning"]; exists && r == true { - isReasoning = true - } - } - if !isReasoning { - catalogID = cid - modelID = mid - break - } - } - - // Fall back to any disabled model if all are reasoning models - if catalogID == "" { - catalogID = fallbackCatalogID - modelID = fallbackModelID - } - if catalogID == "" { - t.Fatal("no disabled model found to enable after fetch") - } - - w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken, - map[string]interface{}{"visibility": "enabled"}) - if w.Code != http.StatusOK { - t.Fatalf("enable model: %d: %s", w.Code, w.Body.String()) - } - - t.Logf(" Provider %s ready, model %s enabled", configID, modelID) - return configID, modelID -} - -// TestLive_ProviderFullFlow exercises the complete admin workflow: -// create provider → fetch models → enable a model → user sees it → chat completion -func TestLive_ProviderFullFlow(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - // ── 1. Create provider config ──────────── - t.Log("Step 1: Creating provider config") - w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ - "name": pc.Provider + " Live Test", - "provider": pc.Provider, - "endpoint": pc.Endpoint, - "api_key": pc.Key, - }) - if w.Code != http.StatusCreated { - t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String()) - } - var configResp map[string]interface{} - decode(w, &configResp) - configID := configResp["id"].(string) - t.Logf(" Created config: %s", configID) - - // ── 2. Fetch models ───────────────────── - t.Log("Step 2: Fetching models from provider API") - w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{ - "provider_config_id": configID, - }) - if w.Code != http.StatusOK { - t.Fatalf("fetch models: want 200, got %d: %s", w.Code, w.Body.String()) - } - var fetchResp map[string]interface{} - decode(w, &fetchResp) - totalFetched := fetchResp["total"].(float64) - if totalFetched < 1 { - t.Fatalf("provider should return at least 1 model, got %.0f", totalFetched) - } - t.Logf(" Fetched %.0f models", totalFetched) - - // ── 3. List + enable first model ──────── - t.Log("Step 3: Enabling first available model") - w = h.request("GET", "/api/v1/admin/models", adminToken, nil) - var modelsResp map[string]interface{} - decode(w, &modelsResp) - catalogModels := modelsResp["data"].([]interface{}) - - var enableID, enableModelID string - var fallbackID, fallbackModelID string - for _, raw := range catalogModels { - m := raw.(map[string]interface{}) - if m["visibility"].(string) != "disabled" { - continue - } - mid := m["model_id"].(string) - cid := m["id"].(string) - if fallbackID == "" { - fallbackID = cid - fallbackModelID = mid - } - // Prefer non-reasoning models (cheaper, no thinking budget requirement) - isReasoning := false - if caps, ok := m["capabilities"].(map[string]interface{}); ok { - if r, exists := caps["reasoning"]; exists && r == true { - isReasoning = true - } - } - if !isReasoning { - enableID = cid - enableModelID = mid - break - } - } - if enableID == "" { - enableID = fallbackID - enableModelID = fallbackModelID - } - if enableID == "" { - t.Fatal("no disabled model found to enable") - } - t.Logf(" Enabling: %s (catalog ID: %s)", enableModelID, enableID) - - w = h.request("PUT", "/api/v1/admin/models/"+enableID, adminToken, - map[string]interface{}{"visibility": "enabled"}) - if w.Code != http.StatusOK { - t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // ── 4. Admin sees enabled model ───────── - t.Log("Step 4: Verifying models/enabled (admin)") - w = h.request("GET", "/api/v1/models/enabled", adminToken, nil) - var enabledResp map[string]interface{} - decode(w, &enabledResp) - enabledModels := enabledResp["data"].([]interface{}) - if len(enabledModels) < 1 { - t.Fatal("models/enabled should return at least 1 model") - } - - found := false - for _, raw := range enabledModels { - m := raw.(map[string]interface{}) - if m["model_id"] == enableModelID { - found = true - if m["config_id"] == nil || m["config_id"] == "" { - t.Error("enabled model must have config_id") - } - if m["provider_name"] == nil || m["provider_name"] == "" { - t.Error("enabled model must have provider_name") - } - break - } - } - if !found { - t.Errorf("model %s not found in models/enabled", enableModelID) - } - - // ── 5. Regular user also sees model ───── - t.Log("Step 5: Verifying models/enabled (regular user)") - userID := database.SeedTestUser(t, "liveuser", "liveuser@test.com") - database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(1), userID) - userToken := makeToken(userID, "liveuser@test.com", "user") - - w = h.request("GET", "/api/v1/models/enabled", userToken, nil) - var userResp map[string]interface{} - decode(w, &userResp) - userModels := userResp["data"].([]interface{}) - if len(userModels) < 1 { - t.Fatal("regular user should see at least 1 enabled model") - } -} - -// TestLive_FetchModelsCapabilities verifies that model capabilities -// are correctly parsed into the catalog. -func TestLive_FetchModelsCapabilities(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ - "name": pc.Provider + " Caps Test", "provider": pc.Provider, - "endpoint": pc.Endpoint, "api_key": pc.Key, - }) - var cfg map[string]interface{} - decode(w, &cfg) - configID := cfg["id"].(string) - - h.request("POST", "/api/v1/admin/models/fetch", adminToken, - map[string]interface{}{"provider_config_id": configID}) - - w = h.request("GET", "/api/v1/admin/models", adminToken, nil) - var resp map[string]interface{} - decode(w, &resp) - - for _, raw := range resp["data"].([]interface{}) { - m := raw.(map[string]interface{}) - caps, ok := m["capabilities"].(map[string]interface{}) - if !ok { - t.Errorf("model %s: capabilities must be an object", m["model_id"]) - continue - } - - // streaming should be true for most providers - if caps["streaming"] != true { - t.Logf(" note: model %s streaming=%v", m["model_id"], caps["streaming"]) - } - - // Verify capabilities are actual booleans - for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} { - if v, exists := caps[key]; exists { - if _, ok := v.(bool); !ok { - t.Errorf("model %s: capability %s should be bool, got %T", m["model_id"], key, v) - } - } - } - } -} - -// TestLive_ChatCompletion sends an actual non-streaming chat completion. -func TestLive_ChatCompletion(t *testing.T) { - h := setupHarness(t) - liveProvs := requireLiveProviders(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - models := setupAllProviders(t, h, adminToken, liveProvs) - - w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{ - "title": "Chat Test", "type": "direct", - }) - if w.Code != http.StatusCreated { - t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String()) - } - var ch map[string]interface{} - decode(w, &ch) - channelID := ch["id"].(string) - - w, _ = tryCompletion(t, h, adminToken, channelID, models, false) - t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())]) -} - -// TestLive_UsageLogging verifies that a non-streaming completion -// creates a usage_log row with token counts. -func TestLive_UsageLogging(t *testing.T) { - h := setupHarness(t) - liveProvs := requireLiveProviders(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - models := setupAllProviders(t, h, adminToken, liveProvs) - - w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{ - "title": "Usage Test", "type": "direct", - }) - if w.Code != http.StatusCreated { - t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String()) - } - var ch map[string]interface{} - decode(w, &ch) - - _, used := tryCompletion(t, h, adminToken, ch["id"].(string), models, false) - - var rowCount, promptTokens, completionTokens int - err := database.TestDB.QueryRow( - "SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1), - used.ConfigID).Scan(&rowCount, &promptTokens, &completionTokens) - if err != nil { - t.Fatalf("query usage_log: %v", err) - } - if rowCount == 0 { - t.Fatal("usage_log should have a row after completion") - } - if promptTokens == 0 { - t.Fatal("completion should report prompt tokens") - } - t.Logf(" ✓ Usage: %d row(s), prompt=%d completion=%d (via %s)", rowCount, promptTokens, completionTokens, used.Provider) -} - -// TestLive_StreamingUsageLogging verifies streaming completions log usage. -func TestLive_StreamingUsageLogging(t *testing.T) { - h := setupHarness(t) - liveProvs := requireLiveProviders(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - models := setupAllProviders(t, h, adminToken, liveProvs) - - w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{ - "title": "Stream Usage Test", "type": "direct", - }) - if w.Code != http.StatusCreated { - t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String()) - } - var ch map[string]interface{} - decode(w, &ch) - - _, used := tryCompletion(t, h, adminToken, ch["id"].(string), models, true) - - var rowCount, promptTokens, completionTokens int - err := database.TestDB.QueryRow( - "SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1), - used.ConfigID).Scan(&rowCount, &promptTokens, &completionTokens) - if err != nil { - t.Fatalf("query usage_log: %v", err) - } - if rowCount == 0 { - t.Fatal("usage_log should have a row after streaming completion") - } - if promptTokens == 0 { - // Some providers (e.g. Venice) don't report token usage in streaming responses - t.Skipf("streaming completion did not report prompt tokens (provider: %s) — some providers omit streaming usage", used.Provider) - } - t.Logf(" ✓ Streaming usage: %d row(s), prompt=%d completion=%d (via %s)", rowCount, promptTokens, completionTokens, used.Provider) -} - -// TestLive_PricingFromCatalog verifies model sync populates pricing. -func TestLive_PricingFromCatalog(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - configID, _ := setupProviderWithModel(t, h, adminToken, pc) - - var pricingCount int - database.TestDB.QueryRow( - "SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = "+database.PH(1), - configID).Scan(&pricingCount) - - if pricingCount == 0 { - t.Skip("model sync did not populate pricing — provider may not support pricing data") - } - t.Logf(" ✓ Catalog sync populated %d pricing entries", pricingCount) - - w := h.request("GET", "/api/v1/admin/pricing", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin pricing: %d: %s", w.Code, w.Body.String()) - } - var entries []interface{} - decode(w, &entries) - if len(entries) == 0 { - t.Error("admin pricing API should return catalog-synced entries") - } - t.Logf(" ✓ Admin pricing API returns %d entries", len(entries)) -} - -// TestLive_Embeddings tests the embeddings endpoint directly. -// Only runs for providers that support embeddings (currently Venice). -func TestLive_Embeddings(t *testing.T) { - pc := requireLiveProvider(t) - - // Only Venice has a known embedding model for now - if pc.Provider != "venice" { - t.Skipf("embedding test only implemented for Venice (got %s)", pc.Provider) - } - - provider := &providers.VeniceProvider{} - cfg := providers.ProviderConfig{ - Endpoint: pc.Endpoint, - APIKey: pc.Key, - } - - // Retry up to 3 times — Venice embedding endpoint can return transient 500s - var resp *providers.EmbeddingResponse - var err error - for attempt := 1; attempt <= 3; attempt++ { - resp, err = provider.Embed( - context.Background(), cfg, - providers.EmbeddingRequest{ - Model: "text-embedding-bge-m3", - Input: []string{"test embedding"}, - }, - ) - if err == nil { - break - } - t.Logf(" Embed attempt %d: %v", attempt, err) - if attempt < 3 { - time.Sleep(time.Duration(attempt) * 2 * time.Second) - } - } - if err != nil { - t.Skipf("Embed failed after 3 attempts (transient): %v", err) - } - if len(resp.Embeddings) == 0 || len(resp.Embeddings[0]) == 0 { - t.Fatal("expected non-empty embedding vector") - } - t.Logf(" ✓ Embedding: %d dimensions, input_tokens=%d", - len(resp.Embeddings[0]), resp.InputTokens) -} - -// TestLive_ModelDeletion tests cleanup: delete provider removes catalog entries. -func TestLive_ModelDeletion(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{ - "name": pc.Provider + " Delete Test", "provider": pc.Provider, - "endpoint": pc.Endpoint, "api_key": pc.Key, - }) - var cfg map[string]interface{} - decode(w, &cfg) - configID := cfg["id"].(string) - - h.request("POST", "/api/v1/admin/models/fetch", adminToken, - map[string]interface{}{"provider_config_id": configID}) - - var count int - database.TestDB.QueryRow( - "SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1), - configID).Scan(&count) - if count == 0 { - t.Fatal("catalog should have models after fetch") - } - t.Logf(" %d models before delete", count) - - w = h.request("DELETE", "/api/v1/admin/configs/"+configID, adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String()) - } - - database.TestDB.QueryRow( - "SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1), - configID).Scan(&count) - if count != 0 { - t.Errorf("catalog should be empty after delete, got %d", count) - } - t.Log(" ✓ Cascade delete cleaned up catalog entries") -} - -// TestLive_BYOK_AutoFetch exercises the user experience: -// user creates a BYOK provider → auto-fetch triggers → models appear. -func TestLive_BYOK_AutoFetch(t *testing.T) { - h := setupHarness(t) - pc := requireLiveProvider(t) - _, adminToken := h.createAdminUser("admin", "admin@test.com") - - // Enable BYOK policy - h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken, - map[string]interface{}{"value": "true"}) - - userID := database.SeedTestUser(t, "byokuser", "byokuser@test.com") - database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(1), userID) - userToken := makeToken(userID, "byokuser@test.com", "user") - - // User creates BYOK provider - w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{ - "name": "My " + pc.Provider, - "provider": pc.Provider, - "endpoint": pc.Endpoint, - "api_key": pc.Key, - }) - if w.Code != http.StatusCreated { - t.Fatalf("create BYOK: want 201, got %d: %s", w.Code, w.Body.String()) - } - var created map[string]interface{} - decode(w, &created) - cfgID := created["id"].(string) - - if created["warning"] != nil { - t.Fatalf("auto-fetch should succeed, got warning: %v", created["warning"]) - } - modelsFetched := created["models_fetched"] - if modelsFetched == nil || modelsFetched.(float64) < 1 { - t.Fatalf("auto-fetch should return models_fetched > 0, got: %v", modelsFetched) - } - t.Logf(" Auto-fetched %.0f models", modelsFetched.(float64)) - - // Verify user sees personal models - w = h.request("GET", "/api/v1/models/enabled", userToken, nil) - var resp map[string]interface{} - decode(w, &resp) - userModels := resp["data"].([]interface{}) - - personalCount := 0 - for _, raw := range userModels { - m := raw.(map[string]interface{}) - if m["scope"] == "personal" { - personalCount++ - } - } - if personalCount < 1 { - t.Fatalf("user should see personal BYOK models, got %d", personalCount) - } - t.Logf(" ✓ User sees %d personal BYOK models", personalCount) - - // Cleanup - h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil) -} diff --git a/server/handlers/memory.go b/server/handlers/memory.go deleted file mode 100644 index e7dea82..0000000 --- a/server/handlers/memory.go +++ /dev/null @@ -1,297 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/memory" - "switchboard-core/models" - "switchboard-core/store" -) - -// MemoryHandler provides REST endpoints for memory management. -type MemoryHandler struct { - stores store.Stores - compactor *memory.Compactor -} - -// NewMemoryHandler creates a new memory handler. -func NewMemoryHandler(stores store.Stores) *MemoryHandler { - return &MemoryHandler{stores: stores} -} - -// SetCompactor wires the compactor for the /compact endpoint. -func (h *MemoryHandler) SetCompactor(c *memory.Compactor) { - h.compactor = c -} - -// ListMyMemories returns the current user's memories with optional filters. -// GET /api/v1/memories?status=active&query=... -func (h *MemoryHandler) ListMyMemories(c *gin.Context) { - userID := c.GetString("user_id") - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - status := c.DefaultQuery("status", "active") - query := c.Query("query") - - memories, err := h.stores.Memories.List(c.Request.Context(), models.MemoryFilter{ - Scope: models.MemoryScopeUser, - OwnerID: userID, - Status: status, - Query: query, - Limit: 100, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - if memories == nil { - memories = []models.Memory{} - } - c.JSON(http.StatusOK, gin.H{"data": memories}) -} - -// UpdateMemory updates a memory's key/value/confidence. -// PUT /api/v1/memories/:id -func (h *MemoryHandler) UpdateMemory(c *gin.Context) { - userID := c.GetString("user_id") - memoryID := c.Param("id") - - existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"}) - return - } - - // Ownership check: user can only edit their own user-scope memories - if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"}) - return - } - - var body struct { - Key *string `json:"key"` - Value *string `json:"value"` - Confidence *float64 `json:"confidence"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) - return - } - - if body.Key != nil { - existing.Key = *body.Key - } - if body.Value != nil { - existing.Value = *body.Value - } - if body.Confidence != nil { - existing.Confidence = *body.Confidence - } - - if err := h.stores.Memories.Update(c.Request.Context(), existing); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"memory": existing}) -} - -// DeleteMemory hard-deletes a memory. -// DELETE /api/v1/memories/:id -func (h *MemoryHandler) DeleteMemory(c *gin.Context) { - userID := c.GetString("user_id") - memoryID := c.Param("id") - - existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"}) - return - } - - if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"}) - return - } - - if err := h.stores.Memories.Delete(c.Request.Context(), memoryID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// ApproveMemory transitions a pending_review memory to active. -// POST /api/v1/memories/:id/approve -func (h *MemoryHandler) ApproveMemory(c *gin.Context) { - memoryID := c.Param("id") - - existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"}) - return - } - - if existing.Status != models.MemoryStatusPendingReview { - c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be approved"}) - return - } - - existing.Status = models.MemoryStatusActive - if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"memory": existing}) -} - -// RejectMemory archives a pending_review memory. -// POST /api/v1/memories/:id/reject -func (h *MemoryHandler) RejectMemory(c *gin.Context) { - userID := c.GetString("user_id") - memoryID := c.Param("id") - - existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"}) - return - } - - // Ownership check: user can only reject their own user-scope memories - if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"}) - return - } - - if existing.Status != models.MemoryStatusPendingReview { - c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be rejected"}) - return - } - - if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"rejected": true}) -} - -// MemoryCount returns active + pending counts for the current user. -// GET /api/v1/memories/count -func (h *MemoryHandler) MemoryCount(c *gin.Context) { - userID := c.GetString("user_id") - ctx := c.Request.Context() - - active, err := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Count pending by listing - pending, err := h.stores.Memories.List(ctx, models.MemoryFilter{ - Scope: models.MemoryScopeUser, - OwnerID: userID, - Status: models.MemoryStatusPendingReview, - Limit: 1000, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "active": active, - "pending": len(pending), - }) -} - -// ── Admin Endpoints ────────────────────────── - -// ListPendingReview returns all pending_review memories (admin only). -// GET /api/v1/admin/memories/pending -func (h *MemoryHandler) ListPendingReview(c *gin.Context) { - ctx := c.Request.Context() - - memories, err := h.stores.Memories.ListByStatus(ctx, models.MemoryStatusPendingReview, 200) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - if memories == nil { - memories = []models.Memory{} - } - c.JSON(http.StatusOK, gin.H{"data": memories}) -} - -// BulkApprove approves multiple memories at once. -// POST /api/v1/admin/memories/bulk-approve -func (h *MemoryHandler) BulkApprove(c *gin.Context) { - var body struct { - IDs []string `json:"ids"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) - return - } - - ctx := c.Request.Context() - approved := 0 - for _, id := range body.IDs { - existing, err := h.stores.Memories.GetByID(ctx, id) - if err != nil { - continue - } - if existing.Status != models.MemoryStatusPendingReview { - continue - } - existing.Status = models.MemoryStatusActive - if err := h.stores.Memories.Upsert(ctx, existing); err == nil { - approved++ - } - } - - c.JSON(http.StatusOK, gin.H{"approved": approved}) -} - -// CompactMemories triggers memory compaction for the current user. -// POST /api/v1/memories/compact -func (h *MemoryHandler) CompactMemories(c *gin.Context) { - if h.compactor == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compaction not available"}) - return - } - - userID := c.GetString("user_id") - if userID == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - - teamID := c.GetString("team_id") - var tID *string - if teamID != "" { - tID = &teamID - } - - result, err := h.compactor.Compact(c.Request.Context(), userID, tID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "before": result.Before, - "after": result.After, - "merged": result.Merged, - "archived": result.Archived, - }) -} diff --git a/server/handlers/memory_inject.go b/server/handlers/memory_inject.go deleted file mode 100644 index fec8a62..0000000 --- a/server/handlers/memory_inject.go +++ /dev/null @@ -1,100 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "log" - "strings" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/store" -) - -// maxMemoryChars is the approximate character budget for injected memories. -const maxMemoryChars = 6000 - -// BuildMemoryHint loads active memories for the user (and persona if active) -// and formats them as a system prompt injection. -// -// Phase 2: if an embedder is available and the user's latest message -// is provided, it generates a query vector for semantic recall. Otherwise -// falls back to keyword/confidence ranking. -func BuildMemoryHint(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID, personaID, lastUserMessage string) string { - if stores.Memories == nil { - return "" - } - - var pID *string - if personaID != "" { - pID = &personaID - } - - var memories []models.Memory - var err error - - // Try hybrid recall with embedding if available - if embedder != nil && embedder.IsConfigured(ctx) && lastUserMessage != "" { - memories, err = hybridRecallForInjection(ctx, stores, embedder, userID, pID, lastUserMessage) - } - - // Fall back to keyword recall - if err != nil || len(memories) == 0 { - memories, err = stores.Memories.Recall(ctx, userID, pID, "", 30) - } - - if err != nil { - log.Printf("⚠ memory injection failed: %v", err) - return "" - } - if len(memories) == 0 { - return "" - } - - // Format memories as bullet points, respecting token budget - var b strings.Builder - b.WriteString("Known facts about this user (from previous conversations):\n") - - totalChars := b.Len() - included := 0 - for _, m := range memories { - line := fmt.Sprintf("• %s: %s\n", m.Key, m.Value) - if totalChars+len(line) > maxMemoryChars { - break - } - b.WriteString(line) - totalChars += len(line) - included++ - } - - if included == 0 { - return "" - } - - log.Printf("🧠 Injected %d memories for user %s (persona: %s)", included, userID, personaID) - return b.String() -} - -// hybridRecallForInjection generates a query vector from the user's message -// and performs hybrid semantic + keyword recall. -func hybridRecallForInjection(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID string, personaID *string, message string) ([]models.Memory, error) { - text := message - if len(text) > 2000 { - text = text[:2000] - } - - var teamID *string - if stores.Teams != nil { - ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID) - if len(ids) > 0 { - teamID = &ids[0] - } - } - - result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text}) - if err != nil || len(result.Vectors) == 0 { - return nil, err - } - - return stores.Memories.RecallHybrid(ctx, userID, personaID, "", result.Vectors[0], 30) -} diff --git a/server/handlers/memory_test.go b/server/handlers/memory_test.go deleted file mode 100644 index 45e82f0..0000000 --- a/server/handlers/memory_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package handlers - -import ( - "net/http" - "testing" - - "switchboard-core/database" - "switchboard-core/models" -) - -// seedMemory inserts a memory directly into the DB for testing. -func seedMemory(t *testing.T, ownerID, key, value, status string) string { - t.Helper() - id := seedID() - q := dialectSQL(` - INSERT INTO memories (id, scope, owner_id, key, value, confidence, status) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `) - _, err := database.TestDB.Exec(q, id, models.MemoryScopeUser, ownerID, key, value, 0.9, status) - if err != nil { - t.Fatalf("seedMemory: %v", err) - } - return id -} - -// ═══════════════════════════════════════════════ -// List -// ═══════════════════════════════════════════════ - -func TestMemory_ListEmpty(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("memuser1", "memuser1@test.com") - - w := h.request("GET", "/api/v1/memories", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - - // Must use "data" key (envelope convention) - data, ok := resp["data"].([]interface{}) - if !ok { - t.Fatalf("response must have 'data' array key, got: %s", w.Body.String()) - } - if len(data) != 0 { - t.Fatalf("expected 0 memories, got %d", len(data)) - } -} - -func TestMemory_ListWithData(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memuser2", "memuser2@test.com") - - seedMemory(t, userID, "lang", "Go", models.MemoryStatusActive) - seedMemory(t, userID, "editor", "Vim", models.MemoryStatusActive) - seedMemory(t, userID, "pending-fact", "something", models.MemoryStatusPendingReview) - - // Default status=active - w := h.request("GET", "/api/v1/memories", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - if len(data) != 2 { - t.Fatalf("expected 2 active memories, got %d", len(data)) - } - - // Explicit status=pending_review - w = h.request("GET", "/api/v1/memories?status=pending_review", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("list pending: want 200, got %d", w.Code) - } - decode(w, &resp) - data = resp["data"].([]interface{}) - if len(data) != 1 { - t.Fatalf("expected 1 pending memory, got %d", len(data)) - } -} - -func TestMemory_ListIsolation(t *testing.T) { - h := setupHarness(t) - userA, tokenA := h.createAdminUser("memA", "memA@test.com") - userB, _ := h.createAdminUser("memB", "memB@test.com") - - seedMemory(t, userA, "a-fact", "user A", models.MemoryStatusActive) - seedMemory(t, userB, "b-fact", "user B", models.MemoryStatusActive) - - w := h.request("GET", "/api/v1/memories", tokenA, nil) - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - if len(data) != 1 { - t.Fatalf("user A should only see 1 memory, got %d", len(data)) - } - mem := data[0].(map[string]interface{}) - if mem["key"].(string) != "a-fact" { - t.Fatalf("wrong memory: got key %q", mem["key"]) - } -} - -// ═══════════════════════════════════════════════ -// Update -// ═══════════════════════════════════════════════ - -func TestMemory_Update(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memupdater", "memupdater@test.com") - - memID := seedMemory(t, userID, "old-key", "old-value", models.MemoryStatusActive) - - w := h.request("PUT", "/api/v1/memories/"+memID, token, map[string]interface{}{ - "key": "new-key", - "value": "new-value", - }) - if w.Code != http.StatusOK { - t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - mem := resp["memory"].(map[string]interface{}) - if mem["key"].(string) != "new-key" { - t.Fatalf("key not updated: got %q", mem["key"]) - } - if mem["value"].(string) != "new-value" { - t.Fatalf("value not updated: got %q", mem["value"]) - } -} - -func TestMemory_UpdateOwnershipDenied(t *testing.T) { - h := setupHarness(t) - ownerID, _ := h.createAdminUser("memowner", "memowner@test.com") - _, otherToken := h.createAdminUser("memother", "memother@test.com") - - memID := seedMemory(t, ownerID, "secret", "mine", models.MemoryStatusActive) - - w := h.request("PUT", "/api/v1/memories/"+memID, otherToken, map[string]interface{}{ - "value": "hacked", - }) - if w.Code != http.StatusForbidden { - t.Fatalf("update other's memory: want 403, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestMemory_UpdateNotFound(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("memghost", "memghost@test.com") - - w := h.request("PUT", "/api/v1/memories/"+seedID(), token, map[string]interface{}{ - "value": "x", - }) - if w.Code != http.StatusNotFound { - t.Fatalf("update missing: want 404, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// Delete -// ═══════════════════════════════════════════════ - -func TestMemory_Delete(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memdeleter", "memdeleter@test.com") - - memID := seedMemory(t, userID, "to-delete", "gone", models.MemoryStatusActive) - - w := h.request("DELETE", "/api/v1/memories/"+memID, token, nil) - if w.Code != http.StatusOK { - t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify gone — list should be empty - w = h.request("GET", "/api/v1/memories", token, nil) - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - if len(data) != 0 { - t.Fatalf("memory should be deleted, got %d", len(data)) - } -} - -func TestMemory_DeleteOwnershipDenied(t *testing.T) { - h := setupHarness(t) - ownerID, _ := h.createAdminUser("delowner", "delowner@test.com") - _, otherToken := h.createAdminUser("delother", "delother@test.com") - - memID := seedMemory(t, ownerID, "no-touch", "x", models.MemoryStatusActive) - - w := h.request("DELETE", "/api/v1/memories/"+memID, otherToken, nil) - if w.Code != http.StatusForbidden { - t.Fatalf("delete other's memory: want 403, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// Approve -// ═══════════════════════════════════════════════ - -func TestMemory_Approve(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memapprover", "memapprover@test.com") - - memID := seedMemory(t, userID, "pending", "val", models.MemoryStatusPendingReview) - - w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("approve: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - mem := resp["memory"].(map[string]interface{}) - if mem["status"].(string) != models.MemoryStatusActive { - t.Fatalf("status should be active, got %q", mem["status"]) - } -} - -func TestMemory_ApproveStatusGuard(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memguard1", "memguard1@test.com") - - // Try to approve an already-active memory - memID := seedMemory(t, userID, "already-active", "val", models.MemoryStatusActive) - - w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil) - if w.Code != http.StatusConflict { - t.Fatalf("approve active memory: want 409, got %d: %s", w.Code, w.Body.String()) - } - - // Try to approve an archived memory - archivedID := seedMemory(t, userID, "archived", "val", models.MemoryStatusArchived) - w = h.request("POST", "/api/v1/memories/"+archivedID+"/approve", token, nil) - if w.Code != http.StatusConflict { - t.Fatalf("approve archived memory: want 409, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// Reject -// ═══════════════════════════════════════════════ - -func TestMemory_Reject(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memrejecter", "memrejecter@test.com") - - memID := seedMemory(t, userID, "to-reject", "val", models.MemoryStatusPendingReview) - - w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("reject: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - if resp["rejected"] != true { - t.Fatal("response should have rejected: true") - } -} - -func TestMemory_RejectStatusGuard(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memguard2", "memguard2@test.com") - - // Can't reject an already-active memory - memID := seedMemory(t, userID, "active-reject", "val", models.MemoryStatusActive) - - w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil) - if w.Code != http.StatusConflict { - t.Fatalf("reject active memory: want 409, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestMemory_RejectOwnershipDenied(t *testing.T) { - h := setupHarness(t) - ownerID, _ := h.createAdminUser("rejowner", "rejowner@test.com") - _, otherToken := h.createAdminUser("rejother", "rejother@test.com") - - memID := seedMemory(t, ownerID, "not-yours", "val", models.MemoryStatusPendingReview) - - w := h.request("POST", "/api/v1/memories/"+memID+"/reject", otherToken, nil) - if w.Code != http.StatusForbidden { - t.Fatalf("reject other's memory: want 403, got %d: %s", w.Code, w.Body.String()) - } -} - -// ═══════════════════════════════════════════════ -// Count -// ═══════════════════════════════════════════════ - -func TestMemory_Count(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memcounter", "memcounter@test.com") - - seedMemory(t, userID, "fact1", "a", models.MemoryStatusActive) - seedMemory(t, userID, "fact2", "b", models.MemoryStatusActive) - seedMemory(t, userID, "pend1", "c", models.MemoryStatusPendingReview) - - w := h.request("GET", "/api/v1/memories/count", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("count: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - - // Response shape: {"active": N, "pending": N} - active := int(resp["active"].(float64)) - pending := int(resp["pending"].(float64)) - if active != 2 { - t.Fatalf("expected 2 active, got %d", active) - } - if pending != 1 { - t.Fatalf("expected 1 pending, got %d", pending) - } -} - -// ═══════════════════════════════════════════════ -// Admin — List Pending -// ═══════════════════════════════════════════════ - -func TestMemory_AdminListPending(t *testing.T) { - h := setupHarness(t) - userA, _ := h.createAdminUser("adminmemA", "adminmemA@test.com") - userB, _ := h.createAdminUser("adminmemB", "adminmemB@test.com") - _, adminToken := h.createAdminUser("memadmin", "memadmin@test.com") - - // Seed pending memories for both users - seedMemory(t, userA, "factA", "from A", models.MemoryStatusPendingReview) - seedMemory(t, userB, "factB", "from B", models.MemoryStatusPendingReview) - seedMemory(t, userA, "active-A", "active", models.MemoryStatusActive) // should NOT appear - - w := h.request("GET", "/api/v1/admin/memories/pending", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin pending: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - if len(data) != 2 { - t.Fatalf("expected 2 pending memories across users, got %d", len(data)) - } -} - -func TestMemory_AdminListPendingRequiresAdmin(t *testing.T) { - h := setupHarness(t) - database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") - database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'") - _, userToken := h.registerUser("normalmem", "normalmem@test.com", "password123") - - w := h.request("GET", "/api/v1/admin/memories/pending", userToken, nil) - if w.Code != http.StatusForbidden { - t.Fatalf("non-admin should be forbidden: want 403, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// Admin — Bulk Approve -// ═══════════════════════════════════════════════ - -func TestMemory_AdminBulkApprove(t *testing.T) { - h := setupHarness(t) - userID, _ := h.createAdminUser("bulkowner", "bulkowner@test.com") - _, adminToken := h.createAdminUser("bulkadmin", "bulkadmin@test.com") - - id1 := seedMemory(t, userID, "bulk1", "a", models.MemoryStatusPendingReview) - id2 := seedMemory(t, userID, "bulk2", "b", models.MemoryStatusPendingReview) - id3 := seedMemory(t, userID, "already-active", "c", models.MemoryStatusActive) - - w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, map[string]interface{}{ - "ids": []string{id1, id2, id3}, - }) - if w.Code != http.StatusOK { - t.Fatalf("bulk approve: want 200, got %d: %s", w.Code, w.Body.String()) - } - var resp map[string]interface{} - decode(w, &resp) - approved := int(resp["approved"].(float64)) - if approved != 2 { - t.Fatalf("expected 2 approved (skipping already-active), got %d", approved) - } -} - -func TestMemory_AdminBulkApproveInvalidBody(t *testing.T) { - h := setupHarness(t) - _, adminToken := h.createAdminUser("bulkbad", "bulkbad@test.com") - - w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, "not-json") - if w.Code != http.StatusBadRequest { - t.Fatalf("bad body: want 400, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// Field Shape Verification -// ═══════════════════════════════════════════════ - -func TestMemory_FieldShape(t *testing.T) { - h := setupHarness(t) - userID, token := h.createAdminUser("memshape", "memshape@test.com") - - seedMemory(t, userID, "test-key", "test-value", models.MemoryStatusActive) - - w := h.request("GET", "/api/v1/memories", token, nil) - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - mem := data[0].(map[string]interface{}) - - // Verify all expected fields exist - requiredFields := []string{"id", "scope", "owner_id", "key", "value", "confidence", "status", "created_at", "updated_at"} - for _, f := range requiredFields { - if _, ok := mem[f]; !ok { - t.Errorf("missing field %q in memory object", f) - } - } - - // Verify no legacy "content" field - if _, ok := mem["content"]; ok { - t.Error("memory should not have 'content' field (legacy ICD)") - } - // Verify no legacy "persona_id" field - if _, ok := mem["persona_id"]; ok { - t.Error("memory should not have 'persona_id' field (legacy ICD)") - } - - // Verify scope is correct - if mem["scope"].(string) != models.MemoryScopeUser { - t.Errorf("scope should be %q, got %q", models.MemoryScopeUser, mem["scope"]) - } - - // Confidence should be a number - confidence, ok := mem["confidence"].(float64) - if !ok || confidence < 0 || confidence > 1 { - t.Errorf("confidence should be 0-1 float, got %v", mem["confidence"]) - } -} diff --git a/server/handlers/messages.go b/server/handlers/messages.go deleted file mode 100644 index 099040d..0000000 --- a/server/handlers/messages.go +++ /dev/null @@ -1,802 +0,0 @@ -package handlers - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "log" - "math" - "net/http" - "time" - - "github.com/gin-gonic/gin" - - capspkg "switchboard-core/capabilities" - "switchboard-core/crypto" - "switchboard-core/database" - "switchboard-core/events" - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/storage" - "switchboard-core/store" - "switchboard-core/tools" - "switchboard-core/treepath" -) - -// ── Request / Response types ──────────────── - -type createMessageRequest struct { - Role string `json:"role" binding:"required,oneof=user assistant system"` - Content string `json:"content" binding:"required"` - Model string `json:"model,omitempty"` -} - -type messageResponse struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - Role string `json:"role"` - Content string `json:"content"` - Model *string `json:"model"` - TokensUsed *int `json:"tokens_used"` - ParentID *string `json:"parent_id,omitempty"` - SiblingCount int `json:"sibling_count"` - SiblingIndex int `json:"sibling_index"` - ParticipantType *string `json:"participant_type,omitempty"` - ParticipantID *string `json:"participant_id,omitempty"` - SenderName *string `json:"sender_name,omitempty"` - SenderAvatar *string `json:"sender_avatar,omitempty"` - CreatedAt string `json:"created_at"` -} - -type editRequest struct { - Content string `json:"content" binding:"required"` -} - -type regenerateRequest struct { - Model string `json:"model,omitempty"` - PersonaID string `json:"persona_id,omitempty"` - ProviderConfigID string `json:"provider_config_id,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - DisabledTools []string `json:"disabled_tools,omitempty"` -} - -type cursorRequest struct { - ActiveLeafID string `json:"active_leaf_id" binding:"required"` -} - -// MessageHandler holds dependencies for message endpoints. -type MessageHandler struct { - vault *crypto.KeyResolver - stores store.Stores - hub *events.Hub - objStore storage.ObjectStore -} - -// NewMessageHandler creates a new message handler. -func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler { - return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore} -} - -// ── List Messages (flat, all branches) ────── -// GET /channels/:id/messages - -func (h *MessageHandler) ListMessages(c *gin.Context) { - page, perPage, offset := parsePagination(c) - - userID := getUserID(c) - channelID := c.Param("id") - - // v0.24.3: Session participants are pre-validated by AuthOrSession middleware - if isSessionAuth(c) { - if !sessionCanAccessChannel(c, channelID) { - c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"}) - return - } - } else if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - ctx := c.Request.Context() - msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) - return - } - - messages := make([]messageResponse, 0, len(msgs)) - for _, m := range msgs { - messages = append(messages, messageResponse{ - ID: m.ID, - ChannelID: m.ChannelID, - Role: m.Role, - Content: m.Content, - Model: m.Model, - TokensUsed: m.TokensUsed, - ParentID: m.ParentID, - SiblingIndex: m.SiblingIndex, - ParticipantType: m.ParticipantType, - ParticipantID: m.ParticipantID, - SenderName: m.SenderName, - SenderAvatar: m.SenderAvatar, - CreatedAt: m.CreatedAt, - }) - } - - // Enrich with sibling counts - for i := range messages { - messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID) - } - - c.JSON(http.StatusOK, paginatedResponse{ - Data: messages, - Page: page, - PerPage: perPage, - Total: total, - TotalPages: int(math.Ceil(float64(total) / float64(perPage))), - }) -} - -// ── Active Path ───────────────────────────── -// GET /channels/:id/path - -func (h *MessageHandler) GetActivePath(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - - if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - path, err := getActivePath(channelID, userID) - if err != nil { - log.Printf("GetActivePath error: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"}) - return - } - - c.JSON(http.StatusOK, gin.H{"data": path}) -} - -// ── Create Message (manual) ───────────────── -// POST /channels/:id/messages - -func (h *MessageHandler) CreateMessage(c *gin.Context) { - var req createMessageRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := getUserID(c) - channelID := c.Param("id") - - // v0.24.3: Session participants are pre-validated by AuthOrSession middleware - if isSessionAuth(c) { - if !sessionCanAccessChannel(c, channelID) { - c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"}) - return - } - } else if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - // Use cursor for parent, compute sibling_index - effectiveUserID := userID - if isSessionAuth(c) { - effectiveUserID = c.GetString("session_id") - } - parentID, _ := getActiveLeaf(channelID, effectiveUserID) - siblingIdx := nextSiblingIndex(channelID, parentID) - - participantType := "user" - participantID := userID - if isSessionAuth(c) { - participantType = "session" - participantID = c.GetString("session_id") - } - if req.Role == "assistant" { - participantType = "model" - if req.Model != "" { - participantID = req.Model - } - } - - msg := &models.Message{ - ChannelID: channelID, - Role: req.Role, - Content: req.Content, - Model: req.Model, - ParentID: parentID, - SiblingIndex: siblingIdx, - ParticipantType: participantType, - ParticipantID: participantID, - ToolCalls: models.JSONMap{}, - Metadata: models.JSONMap{}, - } - - if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"}) - return - } - - // Broadcast via WS to channel participants - if h.hub != nil && msg.Role == "user" { - broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID) - } - - resp := messageResponse{ - ID: msg.ID, - ChannelID: msg.ChannelID, - Role: msg.Role, - Content: msg.Content, - SiblingIndex: msg.SiblingIndex, - CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"), - } - if msg.Model != "" { - resp.Model = &msg.Model - } - resp.ParentID = msg.ParentID - resp.SiblingCount = getSiblingCount(channelID, msg.ParentID) - - c.JSON(http.StatusCreated, resp) -} - -// ── Edit Message (create sibling) ─────────── -// POST /channels/:id/messages/:msgId/edit - -func (h *MessageHandler) EditMessage(c *gin.Context) { - var req editRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := getUserID(c) - channelID := c.Param("id") - messageID := c.Param("msgId") - - if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - ctx := c.Request.Context() - - // Load target — must exist, belong to channel, be a user message - targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"}) - return - } - if targetRole != "user" { - c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"}) - return - } - - // Create sibling: same parent_id as the target - siblingIdx := nextSiblingIndex(channelID, targetParentID) - - msg := &models.Message{ - ChannelID: channelID, - Role: "user", - Content: req.Content, - ParentID: targetParentID, - SiblingIndex: siblingIdx, - ParticipantType: "user", - ParticipantID: userID, - ToolCalls: models.JSONMap{}, - Metadata: models.JSONMap{}, - } - - if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"}) - return - } - - resp := messageResponse{ - ID: msg.ID, - ChannelID: msg.ChannelID, - Role: msg.Role, - Content: msg.Content, - ParentID: msg.ParentID, - SiblingIndex: msg.SiblingIndex, - SiblingCount: getSiblingCount(channelID, msg.ParentID), - CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"), - } - - c.JSON(http.StatusCreated, resp) -} - -// ── Regenerate / Complete ────────────────────── -// POST /channels/:id/messages/:msgId/regenerate - -func (h *MessageHandler) Regenerate(c *gin.Context) { - var req regenerateRequest - _ = c.ShouldBindJSON(&req) // body is optional - - userID := getUserID(c) - channelID := c.Param("id") - messageID := c.Param("msgId") - - if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - ctx := c.Request.Context() - - // Load target message - targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"}) - return - } - if targetRole != "assistant" && targetRole != "user" { - c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"}) - return - } - - // Determine context path and new message's parent based on target role - var contextPath []PathMessage - var newParentID *string - - if targetRole == "assistant" { - if targetParentID == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"}) - return - } - contextPath, err = getPathToLeaf(channelID, *targetParentID) - newParentID = targetParentID - } else { - // user message — respond to it - contextPath, err = getPathToLeaf(channelID, messageID) - newParentID = &messageID - } - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"}) - return - } - - // ── Resolve model + provider ── - - comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil) - - var personaSystemPrompt string - var personaID string - model := req.Model - providerConfigID := req.ProviderConfigID - temperature := req.Temperature - maxTokens := req.MaxTokens - - if req.PersonaID != "" { - persona := ResolvePersona(h.stores, req.PersonaID, userID) - if persona == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"}) - return - } - personaID = persona.ID - if model == "" { - model = persona.BaseModelID - } - if providerConfigID == "" && persona.ProviderConfigID != nil { - providerConfigID = *persona.ProviderConfigID - } - if temperature == nil && persona.Temperature != nil { - temperature = persona.Temperature - } - if maxTokens == 0 && persona.MaxTokens != nil { - maxTokens = *persona.MaxTokens - } - if persona.SystemPrompt != "" { - personaSystemPrompt = persona.SystemPrompt - } - } - - // Fallback: channel's stored model - if model == "" { - channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID) - if channelModel != nil { - model = *channelModel - } - } - - providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{ - Model: model, - ProviderConfigID: providerConfigID, - }) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - provider, err := providers.Get(providerID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Build LLM message array - llmMessages := make([]providers.Message, 0, len(contextPath)+1) - - if personaSystemPrompt != "" { - llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt}) - } else { - systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID) - if systemPrompt != nil && *systemPrompt != "" { - llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt}) - } - } - - for _, m := range contextPath { - if m.Role == "system" { - continue - } - llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content}) - } - - provReq := providers.CompletionRequest{ - Model: model, - Messages: llmMessages, - } - - caps := comp.getModelCapabilities(c, model, configID) - if maxTokens > 0 { - provReq.MaxTokens = maxTokens - } else { - provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps) - } - if temperature != nil { - provReq.Temperature = temperature - } - - // Attach tool definitions (same as normal completion) - workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID) - - // Query channel metadata for tool context - chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID) - msgTeamID := "" - if chTeamID != nil { - msgTeamID = *chTeamID - } - - hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) - if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { - tctx := tools.ToolContext{ - ChannelType: chType, - WorkspaceID: workspaceID, - TeamID: msgTeamID, - PersonaID: personaID, - IsVisitor: isSessionAuth(c), - } - provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID) - } - - // ── Stream the response ── - - if hooks := providers.GetHooks(providerID); hooks != nil { - hooks.PreRequest(providerCfg, &provReq) - } - - result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health, comp.runner, comp.buildExtToolMap(c.Request.Context(), userID)) - - // Persist as sibling (regen) with tool activity - if result.Content != "" { - siblingIdx := nextSiblingIndex(channelID, newParentID) - - var toolCalls models.JSONMap - if len(result.ToolActivity) > 0 { - b, _ := json.Marshal(result.ToolActivity) - _ = json.Unmarshal(b, &toolCalls) - } - if toolCalls == nil { - toolCalls = models.JSONMap{} - } - - msg := &models.Message{ - ChannelID: channelID, - Role: "assistant", - Content: result.Content, - Model: model, - ToolCalls: toolCalls, - Metadata: models.JSONMap{}, - ParentID: newParentID, - SiblingIndex: siblingIdx, - ParticipantType: "model", - ParticipantID: model, - } - - if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil { - log.Printf("Failed to persist regenerated message: %v", err) - } else { - flusher, _ := c.Writer.(http.Flusher) - msgJSON, _ := json.Marshal(gin.H{ - "id": msg.ID, - "parent_id": newParentID, - "sibling_index": siblingIdx, - "sibling_count": getSiblingCount(channelID, newParentID), - }) - fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON) - if flusher != nil { - flusher.Flush() - } - } - } - - // Log usage for regeneration - comp.logUsage(c, channelID, userID, configID, providerScope, model, - result.InputTokens, result.OutputTokens, - result.CacheCreationTokens, result.CacheReadTokens) -} - -// ── Switch Branch (update cursor) ─────────── -// PUT /channels/:id/cursor - -func (h *MessageHandler) UpdateCursor(c *gin.Context) { - var req cursorRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := getUserID(c) - channelID := c.Param("id") - - if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - ctx := c.Request.Context() - - // Verify the target message belongs to this channel and is not deleted. - // GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL - _, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"}) - return - } - - // Walk down to the leaf from the target - leafID, err := findLeafFromMessage(req.ActiveLeafID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"}) - return - } - - if err := updateCursor(channelID, userID, leafID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"}) - return - } - - // Return the new active path - path, err := getPathToLeaf(channelID, leafID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"}) - return - } - - c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID}) -} - -// ── List Siblings ─────────────────────────── -// GET /channels/:id/messages/:msgId/siblings - -func (h *MessageHandler) ListSiblings(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - messageID := c.Param("msgId") - - if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - siblings, currentIdx, err := getSiblings(messageID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "siblings": siblings, - "current_index": currentIdx, - "total": len(siblings), - }) -} - -// ── Ownership / Access Check ───────────────── - -// userCanAccessChannel verifies channel ownership or participation, -// writing an error response if denied. Returns true if access is allowed. -func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool { - ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"}) - return false - } - if !ok { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return false - } - return true -} - -// userOwnsChannel is the legacy wrapper used by other handlers (completion.go). -// Delegates to userCanAccessChannel using the treepath global stores. -func userOwnsChannel(c *gin.Context, channelID, userID string) bool { - ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"}) - return false - } - if !ok { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return false - } - return true -} - -// broadcastUserMessage publishes a message.created event to all user -// participants in the channel via WebSocket. -func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) { - // Look up sender display name - var displayName, username string - _ = database.DB.QueryRowContext(ctx, database.Q(` - SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1 - `), senderID).Scan(&displayName, &username) - - payload, _ := json.Marshal(map[string]any{ - "id": msgID, - "channel_id": channelID, - "role": "user", - "content": content, - "user_id": senderID, - "display_name": displayName, - "username": username, - "created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"), - }) - evt := events.Event{ - Label: "message.created", - Payload: payload, - Ts: time.Now().UnixMilli(), - } - // Send to all user participants in the channel - rows, err := database.DB.QueryContext(ctx, database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' - `), channelID) - if err != nil { - // Fallback: at least send to the sender - hub.PublishToUser(senderID, evt) - return - } - defer rows.Close() - for rows.Next() { - var pid string - if rows.Scan(&pid) == nil { - hub.PublishToUser(pid, evt) - } - } -} - -// broadcastAssistantMessage publishes a message.created event for an assistant -// message to all user participants in the channel (except the requesting user, -// who already received the response via SSE). -func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) { - // Look up persona display name if available - var displayName, avatar string - if personaID != "" { - _ = database.DB.QueryRowContext(ctx, database.Q(` - SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1 - `), personaID).Scan(&displayName, &avatar) - } - if displayName == "" { - displayName = model - } - - payload, _ := json.Marshal(map[string]any{ - "id": msgID, - "channel_id": channelID, - "role": "assistant", - "content": content, - "model": model, - "participant_type": "persona", - "participant_id": personaID, - "display_name": displayName, - "avatar": avatar, - "created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"), - }) - evt := events.Event{ - Label: "message.created", - Payload: payload, - Ts: time.Now().UnixMilli(), - } - // Send to all user participants except the sender (who got SSE) - rows, err := database.DB.QueryContext(ctx, database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' - `), channelID) - if err != nil { - return - } - defer rows.Close() - for rows.Next() { - var pid string - if rows.Scan(&pid) == nil && pid != senderUserID { - hub.PublishToUser(pid, evt) - } - } -} - -// ── Delete Message ────────────────────────────────────────── - -// DeleteMessage soft-deletes a message. -// DELETE /channels/:id/messages/:msgId -func (h *MessageHandler) DeleteMessage(c *gin.Context) { - userID := getUserID(c) - channelID := c.Param("id") - msgID := c.Param("msgId") - - if !userCanAccessChannel(c, h.stores, channelID, userID) { - return - } - - // Verify message belongs to this channel and is not already deleted - var exists bool - _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` - SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL) - `), msgID, channelID).Scan(&exists) - if !exists { - c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) - return - } - - if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"}) - return - } - - // Broadcast to channel participants (exclude sender — they already removed it) - broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID) - - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// broadcastMessageDeleted publishes a message.deleted event to all user -// participants in the channel except the sender. -func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) { - payload, _ := json.Marshal(map[string]any{ - "id": msgID, - "channel_id": channelID, - }) - evt := events.Event{ - Label: "message.deleted", - Payload: payload, - Ts: time.Now().UnixMilli(), - } - rows, err := database.DB.QueryContext(ctx, database.Q(` - SELECT participant_id FROM channel_participants - WHERE channel_id = $1 AND participant_type = 'user' - `), channelID) - if err != nil { - return - } - defer rows.Close() - for rows.Next() { - var pid string - if rows.Scan(&pid) == nil && pid != senderID { - hub.PublishToUser(pid, evt) - } - } -} diff --git a/server/handlers/model_prefs.go b/server/handlers/model_prefs.go deleted file mode 100644 index 6872747..0000000 --- a/server/handlers/model_prefs.go +++ /dev/null @@ -1,91 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ModelPrefsHandler handles user model preference endpoints. -type ModelPrefsHandler struct { - stores store.Stores -} - -func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler { - return &ModelPrefsHandler{stores: s} -} - -// GetPreferences returns the user's model preferences. -func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) { - userID := getUserID(c) - - prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"}) - return - } - - if prefs == nil { - prefs = []models.UserModelSetting{} - } - - c.JSON(http.StatusOK, gin.H{"data": prefs}) -} - -// SetPreference upserts a single model preference. -func (h *ModelPrefsHandler) SetPreference(c *gin.Context) { - userID := getUserID(c) - - var req struct { - ModelID string `json:"model_id" binding:"required"` - ProviderConfigID string `json:"provider_config_id" binding:"required"` - Hidden *bool `json:"hidden,omitempty"` - PreferredTemperature *float64 `json:"preferred_temperature,omitempty"` - PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"` - SortOrder *int `json:"sort_order,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - pcid := req.ProviderConfigID - patch := models.UserModelSettingPatch{ - ProviderConfigID: &pcid, - Hidden: req.Hidden, - PreferredTemperature: req.PreferredTemperature, - PreferredMaxTokens: req.PreferredMaxTokens, - SortOrder: req.SortOrder, - } - - if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, &pcid, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "preference updated"}) -} - -// BulkSetPreferences sets hidden state for multiple model+provider pairs at once. -func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) { - userID := getUserID(c) - - var req struct { - Entries []models.HiddenEntry `json:"entries" binding:"required"` - Hidden bool `json:"hidden"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)}) -} diff --git a/server/handlers/model_prefs_test.go b/server/handlers/model_prefs_test.go deleted file mode 100644 index 632ccb0..0000000 --- a/server/handlers/model_prefs_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - - "switchboard-core/config" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// ── Model Prefs Test Harness ────────────── - -type modelPrefsHarness struct { - *testHarness - userToken string - userID string - user2Token string - user2ID string - configID string // seeded provider_config for preference tests -} - -func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness { - t.Helper() - database.RequireTestDB(t) - database.TruncateAll(t) - - cfg := &config.Config{ - JWTSecret: testJWTSecret, - BasePath: "", - } - - var stores store.Stores - if database.IsSQLite() { - stores = sqlite.NewStores(database.TestDB) - } else { - stores = postgres.NewStores(database.TestDB) - } - userCache := middleware.NewUserStatusCache() - - r := gin.New() - api := r.Group("/api/v1") - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - modelPrefs := NewModelPrefsHandler(stores) - protected.GET("/models/preferences", modelPrefs.GetPreferences) - protected.PUT("/models/preferences", modelPrefs.SetPreference) - protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) - - // Seed two users - userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) - userToken := makeToken(userID, "prefuser@test.com", "user") - - user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID) - user2Token := makeToken(user2ID, "prefuser2@test.com", "user") - - // Seed a provider config for preference targets - configID := uuid.New().String() - if database.IsSQLite() { - database.TestDB.Exec(` - INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) - VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`, - configID) - } else { - database.TestDB.Exec(` - INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) - VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`, - configID) - } - - return &modelPrefsHarness{ - testHarness: &testHarness{router: r, t: t}, - userToken: userToken, - userID: userID, - user2Token: user2Token, - user2ID: user2ID, - configID: configID, - } -} - -// ── GET /models/preferences — empty state ─ - -func TestModelPrefs_Get_Empty(t *testing.T) { - h := setupModelPrefsHarness(t) - - resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - - data, ok := body["data"] - if !ok { - t.Fatal("response must have 'data' key") - } - arr, ok := data.([]interface{}) - if !ok { - t.Fatal("data must be an array") - } - if len(arr) != 0 { - t.Fatalf("expected empty array, got %d items", len(arr)) - } -} - -// ── PUT + GET round-trip ────────────────── - -func TestModelPrefs_Set_RoundTrip(t *testing.T) { - h := setupModelPrefsHarness(t) - - // Set preference - resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "test-model-1", - "provider_config_id": h.configID, - "hidden": true, - "sort_order": 5, - }) - if resp.Code != http.StatusOK { - t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String()) - } - - // Read back - resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("GET: got %d", resp.Code) - } - - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) != 1 { - t.Fatalf("expected 1 preference, got %d", len(arr)) - } - - pref := arr[0].(map[string]interface{}) - if pref["model_id"] != "test-model-1" { - t.Errorf("model_id: got %v", pref["model_id"]) - } - if pref["provider_config_id"] != h.configID { - t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID) - } - if pref["hidden"] != true { - t.Errorf("hidden: got %v, want true", pref["hidden"]) - } - if pref["sort_order"] != float64(5) { - t.Errorf("sort_order: got %v, want 5", pref["sort_order"]) - } -} - -// ── Upsert — no duplicate rows ──────────── - -func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) { - h := setupModelPrefsHarness(t) - - for _, hidden := range []bool{true, false, true} { - resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "test-model-1", - "provider_config_id": h.configID, - "hidden": hidden, - }) - if resp.Code != http.StatusOK { - t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code) - } - } - - resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) != 1 { - t.Fatalf("upsert produced %d rows, want 1", len(arr)) - } - - pref := arr[0].(map[string]interface{}) - if pref["hidden"] != true { - t.Errorf("final hidden state should be true, got %v", pref["hidden"]) - } -} - -// ── Validation: missing fields ──────────── - -func TestModelPrefs_Set_MissingModelID(t *testing.T) { - h := setupModelPrefsHarness(t) - - resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "provider_config_id": h.configID, - "hidden": true, - }) - if resp.Code != http.StatusBadRequest { - t.Fatalf("missing model_id: want 400, got %d", resp.Code) - } -} - -func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) { - h := setupModelPrefsHarness(t) - - resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "test-model-1", - "hidden": true, - }) - if resp.Code != http.StatusBadRequest { - t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code) - } -} - -// ── Bulk set hidden ─────────────────────── - -func TestModelPrefs_BulkSetHidden(t *testing.T) { - h := setupModelPrefsHarness(t) - - resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{ - "entries": []map[string]string{ - {"model_id": "bulk-model-a", "provider_config_id": h.configID}, - {"model_id": "bulk-model-b", "provider_config_id": h.configID}, - }, - "hidden": true, - }) - if resp.Code != http.StatusOK { - t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String()) - } - - var bulkResp map[string]interface{} - json.NewDecoder(resp.Body).Decode(&bulkResp) - if bulkResp["count"] != float64(2) { - t.Errorf("bulk count: got %v, want 2", bulkResp["count"]) - } - - // Verify both are hidden - resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - - hiddenCount := 0 - for _, raw := range arr { - p := raw.(map[string]interface{}) - if p["hidden"] == true { - hiddenCount++ - } - } - if hiddenCount < 2 { - t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount) - } -} - -// ── User isolation ──────────────────────── - -func TestModelPrefs_UserIsolation(t *testing.T) { - h := setupModelPrefsHarness(t) - - // User 1 sets a preference - resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "isolated-model", - "provider_config_id": h.configID, - "hidden": true, - }) - if resp.Code != http.StatusOK { - t.Fatalf("user1 PUT: got %d", resp.Code) - } - - // User 2 should see empty preferences - resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil) - if resp.Code != http.StatusOK { - t.Fatalf("user2 GET: got %d", resp.Code) - } - - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) != 0 { - t.Fatalf("user2 should have 0 prefs, got %d", len(arr)) - } -} - -// ── Shape validation ────────────────────── - -func TestModelPrefs_ResponseShape(t *testing.T) { - h := setupModelPrefsHarness(t) - - // Create a preference so we have something to inspect - h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "shape-model", - "provider_config_id": h.configID, - "hidden": false, - "sort_order": 0, - }) - - resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - if len(arr) == 0 { - t.Fatal("expected at least 1 preference for shape check") - } - - pref := arr[0].(map[string]interface{}) - requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"} - for _, f := range requiredFields { - if _, ok := pref[f]; !ok { - t.Errorf("missing required field %q in preference response", f) - } - } - - // id, user_id, model_id, provider_config_id should be non-empty strings - for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} { - v, _ := pref[f].(string) - if v == "" { - t.Errorf("field %q should be a non-empty string, got %v", f, pref[f]) - } - } -} - -// ── Different provider_config_id = different entry ── - -func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) { - h := setupModelPrefsHarness(t) - - // Seed a second provider config - config2ID := uuid.New().String() - database.TestDB.Exec(dialectSQL( - "INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"), - config2ID) - - // Set preference for same model under two providers - h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "dual-provider-model", - "provider_config_id": h.configID, - "hidden": true, - }) - h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{ - "model_id": "dual-provider-model", - "provider_config_id": config2ID, - "hidden": false, - }) - - // Should have two distinct entries - resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil) - var body map[string]interface{} - json.NewDecoder(resp.Body).Decode(&body) - arr := body["data"].([]interface{}) - - matches := 0 - for _, raw := range arr { - p := raw.(map[string]interface{}) - if p["model_id"] == "dual-provider-model" { - matches++ - } - } - if matches != 2 { - t.Fatalf("same model, different providers: expected 2 entries, got %d", matches) - } -} diff --git a/server/handlers/model_sync.go b/server/handlers/model_sync.go deleted file mode 100644 index bcb6aae..0000000 --- a/server/handlers/model_sync.go +++ /dev/null @@ -1,146 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log" - "os" - "strconv" - "time" - - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/store" -) - -// providerSyncTimeout is the maximum time allowed for an outbound provider -// HTTP call during model sync. Configurable via PROVIDER_SYNC_TIMEOUT env -// var (seconds). Default 30s. -var providerSyncTimeout = func() time.Duration { - if v := os.Getenv("PROVIDER_SYNC_TIMEOUT"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - return time.Duration(n) * time.Second - } - } - return 30 * time.Second -}() - -// ErrUpstreamTimeout is returned when a provider API call exceeds the -// configured timeout. Handlers use this to distinguish upstream timeouts -// from other errors and return 504 instead of 502. -var ErrUpstreamTimeout = errors.New("upstream timeout") - -// syncResult holds the outcome of a model sync operation. -type syncResult struct { - Added int `json:"added"` - Updated int `json:"updated"` - Total int `json:"total"` -} - -// syncProviderModels fetches models from a provider's API and syncs them into the catalog. -// New models default to 'disabled' visibility (admin must explicitly enable for global providers). -// apiKeyPlain is the decrypted API key — callers are responsible for decryption. -func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) { - prov, err := providers.Get(cfg.Provider) - if err != nil { - return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider) - } - - proxyURL := "" - if cfg.ProxyURL != nil { - proxyURL = *cfg.ProxyURL - } - provCfg := providers.ProviderConfig{ - Endpoint: cfg.Endpoint, - APIKey: apiKeyPlain, - ProxyMode: cfg.ProxyMode, - ProxyURL: proxyURL, - } - - if cfg.Headers != nil { - customHeaders := make(map[string]string) - for k, v := range cfg.Headers { - if s, ok := v.(string); ok { - customHeaders[k] = s - } - } - provCfg.CustomHeaders = customHeaders - } - - // Parse config for any extra settings the provider needs - if cfg.Config != nil { - raw, _ := json.Marshal(cfg.Config) - var extra map[string]string - if json.Unmarshal(raw, &extra) == nil { - if provCfg.CustomHeaders == nil { - provCfg.CustomHeaders = make(map[string]string) - } - for k, v := range extra { - provCfg.CustomHeaders[k] = v - } - } - } - - // Wrap context with timeout for the outbound provider call. - // All provider ListModels implementations use http.NewRequestWithContext, - // so the deadline propagates to the HTTP transport automatically. - syncCtx, cancel := context.WithTimeout(ctx, providerSyncTimeout) - defer cancel() - - provModels, err := prov.ListModels(syncCtx, provCfg) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - return syncResult{}, fmt.Errorf("%w: %s", ErrUpstreamTimeout, cfg.Provider) - } - return syncResult{}, err - } - - syncEntries := make([]store.CatalogSyncEntry, len(provModels)) - for i, m := range provModels { - syncEntries[i] = store.CatalogSyncEntry{ - ModelID: m.ID, - DisplayName: m.Name, - ModelType: m.Type, - Capabilities: m.Capabilities, - Pricing: m.Pricing, - } - } - - added, updated, err := stores.Catalog.UpsertFromSync(ctx, cfg.ID, syncEntries) - if err != nil { - return syncResult{}, fmt.Errorf("failed to sync: %w", err) - } - - // Sync pricing from provider catalog (won't overwrite manual admin overrides) - if stores.Pricing != nil { - for _, m := range provModels { - if m.Pricing != nil { - if err := stores.Pricing.UpsertFromCatalog(ctx, cfg.ID, m.ID, m.Pricing); err != nil { - log.Printf("warn: pricing sync for %s/%s failed: %v", cfg.ID, m.ID, err) - } - } - } - } - - return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil -} - -// syncAndEnableProviderModels fetches models and auto-enables them all. -// Used for personal (BYOK) providers — the user explicitly added this provider to use it. -func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) { - result, err := syncProviderModels(ctx, stores, cfg, apiKeyPlain) - if err != nil { - return result, err - } - - // Auto-enable: user added this key to USE it, not to stare at disabled models - if result.Total > 0 { - if err := stores.Catalog.BulkSetVisibility(ctx, cfg.ID, "enabled"); err != nil { - log.Printf("warn: auto-enable models for provider %s failed: %v", cfg.ID, err) - } - } - - return result, nil -} diff --git a/server/handlers/notes.go b/server/handlers/notes.go deleted file mode 100644 index 8b5da91..0000000 --- a/server/handlers/notes.go +++ /dev/null @@ -1,602 +0,0 @@ -package handlers - -import ( - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/notelinks" - "switchboard-core/store" -) - -// ── Request / Response Types ──────────────── - -type createNoteRequest struct { - Title string `json:"title" binding:"required"` - Content string `json:"content" binding:"required"` - FolderPath string `json:"folder_path"` - Tags []string `json:"tags"` - SourceChannelID string `json:"source_channel_id"` - SourceMessageID string `json:"source_message_id"` -} - -type updateNoteRequest struct { - Title *string `json:"title"` - Content *string `json:"content"` - FolderPath *string `json:"folder_path"` - Tags []string `json:"tags"` - // Mode controls how content is applied: "replace" (default), "append", "prepend" - Mode string `json:"mode"` -} - -type noteResponse struct { - ID string `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - FolderPath string `json:"folder_path"` - Tags []string `json:"tags"` - SourceChannelID *string `json:"source_channel_id,omitempty"` - SourceMessageID *string `json:"source_message_id,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type noteListItem struct { - ID string `json:"id"` - Title string `json:"title"` - FolderPath string `json:"folder_path"` - Tags []string `json:"tags"` - Preview string `json:"preview"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type searchResult struct { - noteListItem - Rank float64 `json:"rank"` - Headline string `json:"headline"` -} - -// NoteHandler handles notes CRUD. -type NoteHandler struct { - stores store.Stores -} - -// NewNoteHandler creates a new handler. -func NewNoteHandler(s ...store.Stores) *NoteHandler { - h := &NoteHandler{} - if len(s) > 0 { - h.stores = s[0] - } - return h -} - -// toNoteResponse converts a models.Note to a noteResponse. -func toNoteResponse(n *models.Note) noteResponse { - tags := n.Tags - if tags == nil { - tags = []string{} - } - return noteResponse{ - ID: n.ID, - Title: n.Title, - Content: n.Content, - FolderPath: n.FolderPath, - Tags: tags, - SourceChannelID: n.SourceChannelID, - SourceMessageID: n.SourceMessageID, - CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } -} - -func toNoteListItem(n models.Note) noteListItem { - tags := n.Tags - if tags == nil { - tags = []string{} - } - preview := n.Content - if len(preview) > 200 { - preview = preview[:200] - } - return noteListItem{ - ID: n.ID, - Title: n.Title, - FolderPath: n.FolderPath, - Tags: tags, - Preview: preview, - CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"), - } -} - -// ── Create ────────────────────────────────── -// POST /api/v1/notes - -func (h *NoteHandler) Create(c *gin.Context) { - var req createNoteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := getUserID(c) - - folder := normalizeFolderPath(req.FolderPath) - tags := req.Tags - if tags == nil { - tags = []string{} - } - - var sourceChannelID *string - if req.SourceChannelID != "" { - sourceChannelID = &req.SourceChannelID - } - var sourceMessageID *string - if req.SourceMessageID != "" { - sourceMessageID = &req.SourceMessageID - } - - note := &models.Note{ - UserID: userID, - Title: req.Title, - Content: req.Content, - FolderPath: folder, - Tags: tags, - SourceChannelID: sourceChannelID, - SourceMessageID: sourceMessageID, - } - - if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"}) - return - } - - // Extract and store wikilinks - h.extractAndStoreLinks(c, note.ID, note.Content, userID) - - // Resolve dangling links from other notes that reference this title - if h.stores.NoteLinks != nil { - h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title) - } - - // Auto-associate note with source channel's project (v0.19.0) - if req.SourceChannelID != "" && h.stores.Projects != nil { - projID, _ := h.stores.Projects.GetProjectIDForChannel( - c.Request.Context(), req.SourceChannelID) - if projID != "" { - _ = h.stores.Projects.AddNote(c.Request.Context(), projID, note.ID) - } - } - - c.JSON(http.StatusCreated, toNoteResponse(note)) -} - -// ── Get ───────────────────────────────────── -// GET /api/v1/notes/:id - -func (h *NoteHandler) Get(c *gin.Context) { - userID := getUserID(c) - noteID := c.Param("id") - - note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) - return - } - // Verify ownership - if note.UserID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) - return - } - - c.JSON(http.StatusOK, toNoteResponse(note)) -} - -// ── Update ────────────────────────────────── -// PUT /api/v1/notes/:id - -func (h *NoteHandler) Update(c *gin.Context) { - var req updateNoteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := getUserID(c) - noteID := c.Param("id") - - // Verify ownership - existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID) - if err != nil || existing.UserID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) - return - } - - // Build fields map - fields := map[string]interface{}{} - - if req.Title != nil { - fields["title"] = *req.Title - } - - if req.Content != nil { - mode := strings.ToLower(req.Mode) - switch mode { - case "append": - fields["content"] = existing.Content + *req.Content - case "prepend": - fields["content"] = *req.Content + existing.Content - default: // "replace" or empty - fields["content"] = *req.Content - } - } - - if req.FolderPath != nil { - fields["folder_path"] = normalizeFolderPath(*req.FolderPath) - } - - if req.Tags != nil { - fields["tags"] = req.Tags - } - - if len(fields) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return - } - - if err := h.stores.Notes.Update(c.Request.Context(), noteID, fields); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"}) - return - } - - // Re-fetch to get updated timestamps - updated, err := h.stores.Notes.GetByID(c.Request.Context(), noteID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated note"}) - return - } - - // Re-extract links if content changed - if req.Content != nil { - h.extractAndStoreLinks(c, noteID, updated.Content, userID) - } - - c.JSON(http.StatusOK, toNoteResponse(updated)) -} - -// ── Delete ────────────────────────────────── -// DELETE /api/v1/notes/:id - -func (h *NoteHandler) Delete(c *gin.Context) { - userID := getUserID(c) - noteID := c.Param("id") - - // Verify ownership - existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID) - if err != nil || existing.UserID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) - return - } - - if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// ── Bulk Delete ──────────────────────────── -// POST /api/v1/notes/bulk-delete - -func (h *NoteHandler) BulkDelete(c *gin.Context) { - var req struct { - IDs []string `json:"ids" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if len(req.IDs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"}) - return - } - if len(req.IDs) > 100 { - c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"}) - return - } - - userID := getUserID(c) - - count, err := h.stores.Notes.BulkDelete(c.Request.Context(), req.IDs, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": count}) -} - -// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc - -func (h *NoteHandler) List(c *gin.Context) { - userID := getUserID(c) - folder := c.Query("folder") - tag := c.Query("tag") - sort := c.DefaultQuery("sort", "updated_desc") - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) - offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) - - if limit <= 0 || limit > 200 { - limit = 50 - } - - opts := store.NoteListOptions{ - ListOptions: store.ListOptions{ - Limit: limit, - Offset: offset, - }, - FolderPath: normalizeFolderPath(folder), - Tag: tag, - } - if folder == "" { - opts.FolderPath = "" - } - - // Map sort parameter - switch sort { - case "created_asc": - opts.Sort = "created_at" - opts.Order = "ASC" - case "created_desc": - opts.Sort = "created_at" - opts.Order = "DESC" - case "updated_asc": - opts.Sort = "updated_at" - opts.Order = "ASC" - case "title_asc": - opts.Sort = "title" - opts.Order = "ASC" - case "title_desc": - opts.Sort = "title" - opts.Order = "DESC" - default: // "updated_desc" - opts.Sort = "" - opts.Order = "" - } - - notes, total, err := h.stores.Notes.ListForUser(c.Request.Context(), userID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"}) - return - } - - items := make([]noteListItem, 0, len(notes)) - for _, n := range notes { - items = append(items, toNoteListItem(n)) - } - - c.JSON(http.StatusOK, gin.H{ - "data": items, - "total": total, - "limit": limit, - "offset": offset, - }) -} - -// ── Search ────────────────────────────────── -// GET /api/v1/notes/search?q=query&limit=20 - -func (h *NoteHandler) Search(c *gin.Context) { - userID := getUserID(c) - q := strings.TrimSpace(c.Query("q")) - if q == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) - return - } - - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if limit <= 0 || limit > 100 { - limit = 20 - } - - // SearchKeyword handles dialect internally: PG uses ts_rank/ts_headline, SQLite uses LIKE. - storeResults, err := h.stores.Notes.SearchKeyword(c.Request.Context(), userID, q, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) - return - } - - results := make([]searchResult, 0, len(storeResults)) - for _, sr := range storeResults { - tags := sr.Tags - if tags == nil { - tags = []string{} - } - results = append(results, searchResult{ - noteListItem: noteListItem{ - ID: sr.ID, - Title: sr.Title, - FolderPath: sr.FolderPath, - Tags: tags, - Preview: sr.Excerpt, - }, - Rank: sr.Rank, - Headline: sr.Headline, - }) - } - - c.JSON(http.StatusOK, gin.H{ - "data": results, - "query": q, - "total": len(results), - }) -} - -// ── List Folders ──────────────────────────── -// GET /api/v1/notes/folders - -func (h *NoteHandler) ListFolders(c *gin.Context) { - userID := getUserID(c) - - storeFolders, err := h.stores.Notes.ListFolders(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) - return - } - - type folderInfo struct { - Path string `json:"path"` - Count int `json:"count"` - } - folders := make([]folderInfo, 0, len(storeFolders)) - for _, f := range storeFolders { - folders = append(folders, folderInfo{Path: f.Path, Count: f.Count}) - } - - c.JSON(http.StatusOK, gin.H{"data": folders}) -} - -// ── Helpers ───────────────────────────────── - -// normalizeFolderPath ensures consistent folder path format. -func normalizeFolderPath(p string) string { - p = strings.TrimSpace(p) - if p == "" { - return "/" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - if !strings.HasSuffix(p, "/") { - p = p + "/" - } - // Collapse double slashes - for strings.Contains(p, "//") { - p = strings.ReplaceAll(p, "//", "/") - } - return p -} - -// ── Wikilink Endpoints ────────────────────── - -// GET /api/v1/notes/search-titles?q=query&limit=10 -func (h *NoteHandler) SearchTitles(c *gin.Context) { - userID := getUserID(c) - q := strings.TrimSpace(c.Query("q")) - if q == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"}) - return - } - - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - if limit <= 0 || limit > 50 { - limit = 10 - } - - notes, err := h.stores.Notes.SearchTitles(c.Request.Context(), userID, q, limit) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) - return - } - - type titleResult struct { - ID string `json:"id"` - Title string `json:"title"` - } - - results := make([]titleResult, 0, len(notes)) - for _, n := range notes { - results = append(results, titleResult{ID: n.ID, Title: n.Title}) - } - - c.JSON(http.StatusOK, gin.H{"data": results}) -} - -// GET /api/v1/notes/:id/backlinks -func (h *NoteHandler) Backlinks(c *gin.Context) { - userID := getUserID(c) - noteID := c.Param("id") - - // Verify ownership - note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID) - if err != nil || note.UserID != userID { - c.JSON(http.StatusNotFound, gin.H{"error": "note not found"}) - return - } - - if h.stores.NoteLinks == nil { - c.JSON(http.StatusOK, gin.H{"data": []interface{}{}}) - return - } - - results, err := h.stores.NoteLinks.Backlinks(c.Request.Context(), noteID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load backlinks"}) - return - } - if results == nil { - results = []models.NoteLinkResult{} - } - - c.JSON(http.StatusOK, gin.H{"data": results}) -} - -// GET /api/v1/notes/graph -func (h *NoteHandler) Graph(c *gin.Context) { - userID := getUserID(c) - - if h.stores.NoteLinks == nil { - c.JSON(http.StatusOK, models.NoteGraph{ - Nodes: []models.NoteGraphNode{}, - Edges: []models.NoteGraphEdge{}, - Unresolved: []models.NoteGraphDangling{}, - }) - return - } - - graph, err := h.stores.NoteLinks.Graph(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load graph"}) - return - } - - c.JSON(http.StatusOK, graph) -} - -// ── Link Extraction Helper ────────────────── - -// extractAndStoreLinks parses [[wikilinks]] from content and stores them. -func (h *NoteHandler) extractAndStoreLinks(c *gin.Context, noteID, content, userID string) { - if h.stores.NoteLinks == nil { - return - } - - links := notelinks.ExtractWikilinks(content) - if len(links) == 0 { - // Clear any existing links - h.stores.NoteLinks.ReplaceLinks(c.Request.Context(), noteID, nil) - return - } - - // Resolve titles to note IDs - ctx := c.Request.Context() - for i, link := range links { - notes, err := h.stores.Notes.SearchTitles(ctx, userID, link.TargetTitle, 1) - if err == nil { - for _, n := range notes { - if strings.EqualFold(n.Title, link.TargetTitle) { - id := n.ID - links[i].TargetNoteID = &id - break - } - } - } - } - - h.stores.NoteLinks.ReplaceLinks(ctx, noteID, links) -} diff --git a/server/handlers/package_export.go b/server/handlers/package_export.go deleted file mode 100644 index 9ec8bf6..0000000 --- a/server/handlers/package_export.go +++ /dev/null @@ -1,127 +0,0 @@ -package handlers - -// package_export.go — v0.30.0 CS3 -// -// Exports an installed package as a downloadable .pkg archive for -// cross-instance sharing. Includes manifest, static assets, and -// Starlark scripts. Does NOT include extension table data or secrets. - -import ( - "archive/zip" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/store" -) - -// PackageExportHandler handles package export operations. -type PackageExportHandler struct { - stores store.Stores - packagesDir string -} - -// NewPackageExportHandler creates a new export handler. -func NewPackageExportHandler(s store.Stores, packagesDir string) *PackageExportHandler { - return &PackageExportHandler{stores: s, packagesDir: packagesDir} -} - -// ExportPackage downloads an installed package as a .pkg archive. -// GET /api/v1/admin/packages/:id/export -func (h *PackageExportHandler) ExportPackage(c *gin.Context) { - id := c.Param("id") - pkg, err := h.stores.Packages.Get(c.Request.Context(), id) - if err != nil || pkg == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) - return - } - - if pkg.Source == "core" { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot export core packages"}) - return - } - - // Build manifest for export - manifest := make(map[string]any, len(pkg.Manifest)) - for k, v := range pkg.Manifest { - // Skip internal fields that shouldn't be exported - if k == "_script" { - continue - } - manifest[k] = v - } - - // Include package_settings as default_settings so importing instance - // gets admin defaults (but not secrets) - if len(pkg.PackageSettings) > 0 && string(pkg.PackageSettings) != "{}" { - var ps map[string]any - if json.Unmarshal(pkg.PackageSettings, &ps) == nil && len(ps) > 0 { - manifest["default_settings"] = ps - } - } - - // Set response headers - filename := fmt.Sprintf("%s-%s.pkg", id, pkg.Version) - c.Header("Content-Type", "application/zip") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) - - // Create zip writer directly to response - zw := zip.NewWriter(c.Writer) - defer zw.Close() - - // Write manifest.json - manifestJSON, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"}) - return - } - mf, err := zw.Create("manifest.json") - if err != nil { - return - } - mf.Write(manifestJSON) - - // Include Starlark script as script.star if present - if script, ok := pkg.Manifest["_starlark_script"].(string); ok && script != "" { - sf, err := zw.Create("script.star") - if err == nil { - sf.Write([]byte(script)) - } - } - - // Walk packagesDir/{id}/ for static assets - if h.packagesDir != "" { - assetsDir := filepath.Join(h.packagesDir, id) - if info, err := os.Stat(assetsDir); err == nil && info.IsDir() { - filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - relPath, err := filepath.Rel(assetsDir, path) - if err != nil { - return nil - } - // Normalize path separators for zip - relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/") - - f, err := zw.Create(relPath) - if err != nil { - return nil - } - src, err := os.Open(path) - if err != nil { - return nil - } - defer src.Close() - io.Copy(f, src) - return nil - }) - } - } -} diff --git a/server/handlers/package_migrations.go b/server/handlers/package_migrations.go deleted file mode 100644 index c44f7a6..0000000 --- a/server/handlers/package_migrations.go +++ /dev/null @@ -1,160 +0,0 @@ -package handlers - -// package_migrations.go — v0.30.0 CS1 -// -// Schema migration engine for extension packages. When a package declares -// schema_version and migrations in its manifest, this engine runs Starlark -// migration scripts on install (fresh or upgrade). -// -// Manifest format: -// { -// "schema_version": 2, -// "migrations": { -// "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})", -// "2": "def migrate(db):\n ..." -// } -// } -// -// On fresh install: runs migrations 1..schema_version. -// On upgrade: runs migrations (current+1)..new_schema_version. -// On downgrade: rejected (409). - -import ( - "context" - "database/sql" - "fmt" - "log" - "strconv" - - "go.starlark.net/starlark" - - "switchboard-core/sandbox" - "switchboard-core/store" -) - -// ParseSchemaVersion extracts the "schema_version" integer from a manifest. -// Returns 0 if not present or not a number. -func ParseSchemaVersion(manifest map[string]any) int { - raw, ok := manifest["schema_version"] - if !ok { - return 0 - } - switch v := raw.(type) { - case float64: - return int(v) - case int: - return v - } - return 0 -} - -// ParseMigrations extracts the "migrations" map from a manifest. -// Keys are string integers ("1", "2", ...), values are Starlark source code. -func ParseMigrations(manifest map[string]any) map[int]string { - raw, ok := manifest["migrations"] - if !ok { - return nil - } - migrationsRaw, ok := raw.(map[string]any) - if !ok || len(migrationsRaw) == 0 { - return nil - } - - result := make(map[int]string, len(migrationsRaw)) - for key, val := range migrationsRaw { - version, err := strconv.Atoi(key) - if err != nil { - log.Printf("[pkg-migrate] skipping non-integer migration key %q", key) - continue - } - script, ok := val.(string) - if !ok || script == "" { - log.Printf("[pkg-migrate] skipping empty migration for version %d", version) - continue - } - result[version] = script - } - return result -} - -// RunSchemaMigrations executes Starlark migration scripts from fromVersion+1 -// to toVersion, updating schema_version in the store after each successful step. -// -// The sandbox runs each migration script with a db module at write level, -// scoped to the package's namespace. This bypasses the permission system -// because migrations are admin-initiated. -// -// On error, returns immediately — partial migration state is tracked via -// the per-step schema_version update. -func RunSchemaMigrations( - ctx context.Context, - sb *sandbox.Sandbox, - stores store.Stores, - db *sql.DB, - isPostgres bool, - packageID string, - manifest map[string]any, - fromVersion int, - toVersion int, -) error { - if fromVersion >= toVersion { - return nil - } - - migrations := ParseMigrations(manifest) - if migrations == nil && toVersion > 0 { - // No migration scripts but schema_version declared — just set the version - return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion) - } - - for step := fromVersion + 1; step <= toVersion; step++ { - script, ok := migrations[step] - if !ok { - // No script for this step — just bump the version - log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step) - if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil { - return fmt.Errorf("set schema_version to %d: %w", step, err) - } - continue - } - - log.Printf("[pkg-migrate] %s: running migration %d", packageID, step) - - // Build a db module at write level for the migration - dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{ - PackageID: packageID, - CanWrite: true, - DB: db, - IsPostgres: isPostgres, - }) - - modules := map[string]starlark.Value{ - "db": dbModule, - } - - // Execute the migration script - result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules) - if err != nil { - return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err) - } - - // Call migrate(db) if defined - if migrateFn, ok := result.Globals["migrate"]; ok { - if callable, ok := migrateFn.(starlark.Callable); ok { - _, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil) - if err != nil { - return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err) - } - } - } - - // Mark this step as complete - if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil { - return fmt.Errorf("set schema_version to %d: %w", step, err) - } - - log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step) - } - - return nil -} diff --git a/server/handlers/participants.go b/server/handlers/participants.go deleted file mode 100644 index f55f4ec..0000000 --- a/server/handlers/participants.go +++ /dev/null @@ -1,360 +0,0 @@ -package handlers - -import ( - "database/sql" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Channel Participants Handler ────────────── -// ICD §3.7: Manages polymorphic participants per channel. -// Routes: -// GET /channels/:id/participants — list -// POST /channels/:id/participants — add -// PATCH /channels/:id/participants/:participantId — update role -// DELETE /channels/:id/participants/:participantId — remove -// GET /channels/:id/presence — who's online - -type ParticipantHandler struct { - stores store.Stores -} - -func NewParticipantHandler(stores store.Stores) *ParticipantHandler { - return &ParticipantHandler{stores: stores} -} - -// ── List ───────────────────────────────────── - -func (h *ParticipantHandler) List(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - // Caller must be a participant (any role) - if !h.requireParticipant(c, channelID, userID) { - return - } - - participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"}) - return - } - if participants == nil { - participants = []models.ChannelParticipant{} - } - c.JSON(http.StatusOK, gin.H{"data": participants}) -} - -// ── Add ────────────────────────────────────── - -type addParticipantRequest struct { - ParticipantType string `json:"participant_type" binding:"required"` - ParticipantID string `json:"participant_id" binding:"required"` - Role string `json:"role"` -} - -func (h *ParticipantHandler) Add(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - // Only owners can add participants - if !h.requireOwner(c, channelID, userID) { - return - } - - var req addParticipantRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate participant_type - switch req.ParticipantType { - case "user", "persona", "session": - // valid - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"}) - return - } - - // Default role - role := req.Role - if role == "" { - role = "member" - } - switch role { - case "owner", "member", "observer": - // valid - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"}) - return - } - - // Build participant - p := &models.ChannelParticipant{ - ChannelID: channelID, - ParticipantType: req.ParticipantType, - ParticipantID: req.ParticipantID, - Role: role, - } - - // If adding a persona, resolve display_name and avatar from persona record, - // and auto-add persona's model to channel_models roster. - if req.ParticipantType == "persona" { - persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"}) - return - } - - dn := persona.Name - p.DisplayName = &dn - if persona.Avatar != "" { - p.AvatarURL = &persona.Avatar - } - - // Auto-add persona's model to channel_models roster - cm := &models.ChannelModel{ - ChannelID: channelID, - ModelID: persona.BaseModelID, - ProviderConfigID: derefStr(persona.ProviderConfigID), - PersonaID: &req.ParticipantID, - Handle: persona.Handle, - DisplayName: persona.Name, - SystemPrompt: persona.SystemPrompt, - IsDefault: false, - } - _ = h.stores.Channels.SetModel(c.Request.Context(), cm) - - } else if req.ParticipantType == "user" { - // Resolve user display name - user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"}) - return - } - dn := user.DisplayName - if dn == "" { - dn = user.Username - } - p.DisplayName = &dn - if user.AvatarURL != "" { - p.AvatarURL = &user.AvatarURL - } - } - - if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil { - // Check for duplicate - if isDuplicateErr(err) { - c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"}) - return - } - - // Return updated participant list - participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - c.JSON(http.StatusCreated, gin.H{"data": participants}) -} - -// ── Update Role ───────────────────────────── - -type updateParticipantRequest struct { - Role string `json:"role" binding:"required"` -} - -func (h *ParticipantHandler) Update(c *gin.Context) { - channelID := c.Param("id") - participantRecordID := c.Param("participantId") - userID := getUserID(c) - - if !h.requireOwner(c, channelID, userID) { - return - } - - var req updateParticipantRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - switch req.Role { - case "owner", "member", "observer": - // valid - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"}) - return - } - - // Verify participant belongs to this channel - p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"}) - return - } - if p.ChannelID != channelID { - c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"}) - return - } - - if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"}) - return - } - - participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - c.JSON(http.StatusOK, gin.H{"data": participants}) -} - -// ── Remove ────────────────────────────────── - -func (h *ParticipantHandler) Remove(c *gin.Context) { - channelID := c.Param("id") - participantRecordID := c.Param("participantId") - userID := getUserID(c) - - if !h.requireOwner(c, channelID, userID) { - return - } - - // Verify participant belongs to this channel - p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID) - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"}) - return - } - if p.ChannelID != channelID { - c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"}) - return - } - - // Cannot remove the last owner - if p.Role == "owner" { - owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner") - if owners <= 1 { - c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"}) - return - } - } - - // v0.23.2: DM guard — cannot drop below 2 human participants - channelType, _, _ := h.stores.Channels.GetTypeAndTeamID(c.Request.Context(), channelID) - - if channelType == "dm" && p.ParticipantType == "user" { - userCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "user") - if userCount <= 2 { - c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"}) - return - } - } - - // v0.23.2: Group guard — cannot remove the last persona - if channelType == "group" && p.ParticipantType == "persona" { - personaCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "persona") - if personaCount <= 1 { - c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"}) - return - } - } - - // If removing a persona, also remove its auto-created model roster entry - if p.ParticipantType == "persona" { - _ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID) - } - - if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"}) - return - } - - participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID) - c.JSON(http.StatusOK, gin.H{"data": participants}) -} - -// ── Helpers ────────────────────────────────── - -// requireParticipant checks the user is any participant in the channel. -func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool { - ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"}) - return false - } - if !ok { - // Fall back to legacy channels.user_id ownership for direct channels - if userOwnsChannel(c, channelID, userID) { - return true - } - return false - } - return true -} - -// requireOwner checks the user is an owner participant in the channel. -func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool { - role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID) - if err != nil { - // Fall back to legacy channels.user_id ownership for direct channels - if userOwnsChannel(c, channelID, userID) { - return true - } - return false - } - if role != "owner" { - c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"}) - return false - } - return true -} - -func derefStr(s *string) string { - if s == nil { - return "" - } - return *s -} - -// isDuplicateErr detects unique constraint violations across both Postgres and SQLite. -func isDuplicateErr(err error) bool { - if err == nil { - return false - } - msg := err.Error() - // Postgres: "duplicate key value violates unique constraint" - // SQLite: "UNIQUE constraint failed" - return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint") -} - -func contains(s, substr string) bool { - return len(s) >= len(substr) && searchStr(s, substr) -} - -func searchStr(s, sub string) bool { - for i := 0; i <= len(s)-len(sub); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} diff --git a/server/handlers/persona_groups.go b/server/handlers/persona_groups.go deleted file mode 100644 index 8bd92e9..0000000 --- a/server/handlers/persona_groups.go +++ /dev/null @@ -1,202 +0,0 @@ -package handlers - -// persona_groups.go — Persona group (roster template) CRUD (v0.23.2) -// -// v0.29.0: Raw SQL replaced with PersonaGroupStore methods. - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -type PersonaGroupHandler struct { - stores store.Stores -} - -func NewPersonaGroupHandler(s store.Stores) *PersonaGroupHandler { - return &PersonaGroupHandler{stores: s} -} - -// ── List ──────────────────────────────────────── - -func (h *PersonaGroupHandler) List(c *gin.Context) { - userID := getUserID(c) - - groups, err := h.stores.PersonaGroups.List(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"}) - return - } - - for i := range groups { - members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), groups[i].ID) - groups[i].Members = members - } - - c.JSON(http.StatusOK, gin.H{"data": groups}) -} - -// ── Get ───────────────────────────────────────── - -func (h *PersonaGroupHandler) Get(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - g, err := h.stores.PersonaGroups.Get(c.Request.Context(), id, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"}) - return - } - if g == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) - return - } - - members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), g.ID) - if members == nil { - members = []models.PersonaGroupMember{} - } - g.Members = members - - c.JSON(http.StatusOK, g) -} - -// ── Create ────────────────────────────────────── - -func (h *PersonaGroupHandler) Create(c *gin.Context) { - userID := getUserID(c) - - var req struct { - Name string `json:"name" binding:"required,min=1,max=100"` - Description string `json:"description"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - g := &models.PersonaGroup{ - Name: strings.TrimSpace(req.Name), - Description: req.Description, - OwnerID: userID, - Scope: "personal", - } - - if err := h.stores.PersonaGroups.Create(c.Request.Context(), g); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"}) - return - } - - g.Members = []models.PersonaGroupMember{} - c.JSON(http.StatusCreated, g) -} - -// ── Update ────────────────────────────────────── - -func (h *PersonaGroupHandler) Update(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - var req struct { - Name *string `json:"name"` - Description *string `json:"description"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify ownership - ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), id) - if err != nil || ownerID == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) - return - } - if ownerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) - return - } - - fields := map[string]interface{}{} - if req.Name != nil { - fields["name"] = strings.TrimSpace(*req.Name) - } - if req.Description != nil { - fields["description"] = *req.Description - } - if len(fields) > 0 { - _ = h.stores.PersonaGroups.Update(c.Request.Context(), id, fields) - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// ── Delete ────────────────────────────────────── - -func (h *PersonaGroupHandler) Delete(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - n, err := h.stores.PersonaGroups.Delete(c.Request.Context(), id, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"}) - return - } - if n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) - return - } - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// ── Add Member ────────────────────────────────── - -func (h *PersonaGroupHandler) AddMember(c *gin.Context) { - userID := getUserID(c) - groupID := c.Param("id") - - var req struct { - PersonaID string `json:"persona_id" binding:"required"` - IsLeader bool `json:"is_leader"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify ownership - ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID) - if err != nil || ownerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) - return - } - - if err := h.stores.PersonaGroups.AddMember(c.Request.Context(), groupID, req.PersonaID, req.IsLeader); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"}) - return - } - - c.JSON(http.StatusCreated, gin.H{"ok": true}) -} - -// ── Remove Member ─────────────────────────────── - -func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) { - userID := getUserID(c) - groupID := c.Param("id") - memberID := c.Param("memberId") - - ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID) - if err != nil || ownerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your group"}) - return - } - - _ = h.stores.PersonaGroups.RemoveMember(c.Request.Context(), memberID, groupID) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} diff --git a/server/handlers/personas.go b/server/handlers/personas.go deleted file mode 100644 index b746139..0000000 --- a/server/handlers/personas.go +++ /dev/null @@ -1,569 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// PersonaHandler handles persona endpoints. -type PersonaHandler struct { - stores store.Stores -} - -func NewPersonaHandler(s store.Stores) *PersonaHandler { - return &PersonaHandler{stores: s} -} - -// ── User Personas (personal scope) ────────── - -func (h *PersonaHandler) ListUserPersonas(c *gin.Context) { - userID := getUserID(c) - - personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"}) - return - } - - if personas == nil { - personas = []models.Persona{} - } - c.JSON(http.StatusOK, gin.H{"data": personas}) -} - -func (h *PersonaHandler) CreateUserPersona(c *gin.Context) { - userID := getUserID(c) - - // Check policy - allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas") - if !allowed { - c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"}) - return - } - - var req personaRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - persona := req.toPersona() - persona.Scope = models.ScopePersonal - persona.OwnerID = &userID - persona.CreatedBy = userID - - if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"}) - return - } - - c.JSON(http.StatusCreated, persona) -} - -func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - existing, err := h.stores.Personas.GetByID(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return - } - - // Users can only edit their own personal personas - if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"}) - return - } - - var patch models.PersonaPatch - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "persona updated"}) -} - -func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) { - userID := getUserID(c) - id := c.Param("id") - - existing, err := h.stores.Personas.GetByID(c.Request.Context(), id) - if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"}) - return - } - - if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "persona deleted"}) -} - -// ── Team Personas ─────────────────────────── - -func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) { - teamID := c.Param("teamId") - - personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"}) - return - } - - if personas == nil { - personas = []models.Persona{} - } - c.JSON(http.StatusOK, gin.H{"data": personas}) -} - -func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) { - userID := getUserID(c) - teamID := c.Param("teamId") - - var req personaRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - persona := req.toPersona() - persona.Scope = models.ScopeTeam - persona.OwnerID = &teamID - persona.CreatedBy = userID - - if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"}) - return - } - - c.JSON(http.StatusCreated, persona) -} - -// UpdateTeamPersona updates a team-scoped persona. Verifies the persona -// belongs to the team specified in the URL path. -func (h *PersonaHandler) UpdateTeamPersona(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - id := c.Param("id") - - var patch models.PersonaPatch - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "persona updated"}) -} - -// DeleteTeamPersona deletes a team-scoped persona. Verifies the persona -// belongs to the team specified in the URL path before deleting. -func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - id := c.Param("id") - - if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "persona deleted"}) -} - -// ── Admin Personas (global scope) ─────────── - -func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) { - personas, err := h.stores.Personas.ListGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"}) - return - } - - if personas == nil { - personas = []models.Persona{} - } - c.JSON(http.StatusOK, gin.H{"data": personas}) -} - -func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) { - userID := getUserID(c) - - var req personaRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - persona := req.toPersona() - persona.Scope = models.ScopeGlobal - persona.CreatedBy = userID - - if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"}) - return - } - - c.JSON(http.StatusCreated, persona) -} - -func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) { - id := c.Param("id") - - var patch models.PersonaPatch - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "persona updated"}) -} - -func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) { - id := c.Param("id") - - if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "persona deleted"}) -} - -// ── Request Types ─────────────────────────── - -type personaRequest struct { - Name string `json:"name" binding:"required"` - Handle string `json:"handle,omitempty"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - BaseModelID string `json:"base_model_id,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - SystemPrompt string `json:"system_prompt,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - ThinkingBudget *int `json:"thinking_budget,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - IsShared bool `json:"is_shared,omitempty"` -} - -func (r *personaRequest) toPersona() *models.Persona { - p := &models.Persona{ - Name: r.Name, - Handle: r.Handle, - Description: r.Description, - Icon: r.Icon, - BaseModelID: r.BaseModelID, - ProviderConfigID: r.ProviderConfigID, - SystemPrompt: r.SystemPrompt, - Temperature: r.Temperature, - MaxTokens: r.MaxTokens, - ThinkingBudget: r.ThinkingBudget, - TopP: r.TopP, - IsActive: true, - IsShared: r.IsShared, - } - return p -} - -// ResolvePersona loads a persona by ID and returns it if the user has access. -// Returns nil if not found, inactive, or not accessible. -func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona { - ctx := context.Background() - - p, err := stores.Personas.GetByID(ctx, personaID) - if err != nil || !p.IsActive { - return nil - } - - ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID) - if err != nil || !ok { - return nil - } - - return p -} - -// ── Persona-KB Binding Endpoints (v0.17.0) ────── - -// GetPersonaKBs returns the knowledge bases bound to a persona. -func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) { - personaID := c.Param("id") - kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": kbs}) -} - -// GetTeamPersonaKBs returns KBs bound to a persona, verifying team ownership. -func (h *PersonaHandler) GetTeamPersonaKBs(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - personaID := c.Param("id") - - kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": kbs}) -} - -// SetPersonaKBs replaces the knowledge bases bound to a persona. -func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) { - personaID := c.Param("id") - - var req struct { - KBIDs []string `json:"kb_ids"` - AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// SetTeamPersonaKBs replaces KBs bound to a persona, verifying team ownership. -func (h *PersonaHandler) SetTeamPersonaKBs(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - personaID := c.Param("id") - - var req struct { - KBIDs []string `json:"kb_ids"` - AutoSearch map[string]bool `json:"auto_search"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// ── Persona Tool Grants (v0.25.0) ─────────── - -// GetPersonaToolGrants returns the tool names granted to a persona. -// Empty list = persona inherits all context-available tools. -func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) { - personaID := c.Param("id") - grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) - return - } - if grants == nil { - grants = []string{} - } - c.JSON(http.StatusOK, gin.H{"data": grants}) -} - -// SetPersonaToolGrants replaces the tool grants for a persona. -// Sending an empty tool_names array clears all grants (persona inherits all tools). -func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) { - personaID := c.Param("id") - - var req struct { - ToolNames []string `json:"tool_names"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Convert tool names to Grant objects - grants := make([]models.Grant, 0, len(req.ToolNames)) - for _, name := range req.ToolNames { - grants = append(grants, models.Grant{ - PersonaID: personaID, - GrantType: models.GrantTypeTool, - GrantRef: name, - }) - } - - if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// ── User-Scoped Persona Avatar (with ownership check) ───── - -// UploadUserPersonaAvatar uploads an avatar for a personal persona, -// verifying the caller owns it. -func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) { - userID := getUserID(c) - personaID := c.Param("id") - - // Verify ownership - existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return - } - if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"}) - return - } - - // Delegate to the shared avatar handler - UploadPersonaAvatar(h.stores.Personas, c) -} - -// DeleteUserPersonaAvatar deletes an avatar for a personal persona, -// verifying the caller owns it. -func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) { - userID := getUserID(c) - personaID := c.Param("id") - - // Verify ownership - existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return - } - if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"}) - return - } - - // Delegate to the shared avatar handler - DeletePersonaAvatar(h.stores.Personas, c) -} - -// ── Team-Scoped Helpers ───────────────────── - -// requireTeamPersona loads a persona by ID and verifies it belongs to -// the team in the URL path. Returns the persona on success or writes -// an error response and returns nil. -func (h *PersonaHandler) requireTeamPersona(c *gin.Context) *models.Persona { - teamID := c.Param("teamId") - personaID := c.Param("id") - - existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"}) - return nil - } - if existing.Scope != models.ScopeTeam || existing.OwnerID == nil || *existing.OwnerID != teamID { - c.JSON(http.StatusForbidden, gin.H{"error": "persona does not belong to this team"}) - return nil - } - return existing -} - -// ── Team-Scoped Persona Tool Grants ───────── - -// GetTeamPersonaToolGrants returns tool grants for a team persona, -// verifying team ownership. -func (h *PersonaHandler) GetTeamPersonaToolGrants(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - personaID := c.Param("id") - grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"}) - return - } - if grants == nil { - grants = []string{} - } - c.JSON(http.StatusOK, gin.H{"data": grants}) -} - -// SetTeamPersonaToolGrants replaces tool grants for a team persona, -// verifying team ownership. -func (h *PersonaHandler) SetTeamPersonaToolGrants(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - personaID := c.Param("id") - - var req struct { - ToolNames []string `json:"tool_names"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - grants := make([]models.Grant, 0, len(req.ToolNames)) - for _, name := range req.ToolNames { - grants = append(grants, models.Grant{ - PersonaID: personaID, - GrantType: models.GrantTypeTool, - GrantRef: name, - }) - } - - if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// ── Team-Scoped Persona Avatar ────────────── - -// UploadTeamPersonaAvatar uploads an avatar for a team persona, -// verifying team ownership. -func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - UploadPersonaAvatar(h.stores.Personas, c) -} - -// DeleteTeamPersonaAvatar deletes an avatar for a team persona, -// verifying team ownership. -func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) { - if p := h.requireTeamPersona(c); p == nil { - return - } - DeletePersonaAvatar(h.stores.Personas, c) -} diff --git a/server/handlers/projects.go b/server/handlers/projects.go deleted file mode 100644 index e28d7f2..0000000 --- a/server/handlers/projects.go +++ /dev/null @@ -1,461 +0,0 @@ -package handlers - -import ( - "database/sql" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Request / Response Types ──────────────── - -type createProjectRequest struct { - Name string `json:"name" binding:"required,max=200"` - Description string `json:"description,omitempty"` - Color *string `json:"color,omitempty"` - Icon *string `json:"icon,omitempty"` -} - -type updateProjectRequest = models.ProjectPatch - -type addChannelRequest struct { - ChannelID string `json:"channel_id" binding:"required"` - Position int `json:"position"` -} - -type reorderChannelsRequest struct { - ChannelIDs []string `json:"channel_ids" binding:"required"` -} - -type addKBRequest struct { - KBID string `json:"kb_id" binding:"required"` - AutoSearch bool `json:"auto_search"` -} - -type addNoteRequest struct { - NoteID string `json:"note_id" binding:"required"` -} - -// ProjectHandler handles project CRUD and associations. -type ProjectHandler struct { - stores store.Stores -} - -// NewProjectHandler creates a new project handler. -func NewProjectHandler(s store.Stores) *ProjectHandler { - return &ProjectHandler{stores: s} -} - -// ── CRUD ──────────────────────────────────── - -func (h *ProjectHandler) List(c *gin.Context) { - userID := getUserID(c) - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) - includeArchived := c.Query("include_archived") == "true" - - projects, err := h.stores.Projects.ListForUser(c.Request.Context(), userID, teamIDs, includeArchived) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) - return - } - if projects == nil { - projects = []models.Project{} - } - c.JSON(http.StatusOK, gin.H{"data": projects}) -} - -func (h *ProjectHandler) Create(c *gin.Context) { - userID := getUserID(c) - var req createProjectRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - p := &models.Project{ - Name: req.Name, - Description: req.Description, - Color: req.Color, - Icon: req.Icon, - Scope: models.ScopePersonal, - OwnerID: userID, - } - - if err := h.stores.Projects.Create(c.Request.Context(), p); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create project"}) - return - } - - c.JSON(http.StatusCreated, p) -} - -func (h *ProjectHandler) Get(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - c.JSON(http.StatusOK, project) -} - -func (h *ProjectHandler) Update(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req updateProjectRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Projects.Update(c.Request.Context(), project.ID, req); err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update project"}) - return - } - - // Return refreshed project - updated, err := h.stores.Projects.GetByID(c.Request.Context(), project.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload project"}) - return - } - c.JSON(http.StatusOK, updated) -} - -func (h *ProjectHandler) Delete(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Only owner or admin can delete - userID := getUserID(c) - if project.OwnerID != userID { - role, _ := c.Get("role") - if role != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "only the project owner can delete it"}) - return - } - } - - if err := h.stores.Projects.Delete(c.Request.Context(), project.ID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete project"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "project deleted"}) -} - -// ── Channel Association ───────────────────── - -func (h *ProjectHandler) AddChannel(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req addChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify the user owns the channel - userID := getUserID(c) - owns, err := h.stores.Channels.UserOwns(c.Request.Context(), req.ChannelID, userID) - if err != nil || !owns { - c.JSON(http.StatusForbidden, gin.H{"error": "channel not found or not owned"}) - return - } - - if err := h.stores.Projects.AddChannel(c.Request.Context(), project.ID, req.ChannelID, req.Position); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add channel"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "channel added"}) -} - -func (h *ProjectHandler) RemoveChannel(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - channelID := c.Param("channelId") - if channelID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id required"}) - return - } - - if err := h.stores.Projects.RemoveChannel(c.Request.Context(), project.ID, channelID); err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not in project"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove channel"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "channel removed"}) -} - -func (h *ProjectHandler) ListChannels(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - channels, err := h.stores.Projects.ListChannels(c.Request.Context(), project.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) - return - } - if channels == nil { - channels = []models.ProjectChannel{} - } - c.JSON(http.StatusOK, gin.H{"data": channels}) -} - -func (h *ProjectHandler) ReorderChannels(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req reorderChannelsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Projects.ReorderChannels(c.Request.Context(), project.ID, req.ChannelIDs); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder channels"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "channels reordered"}) -} - -// ── KB Association ────────────────────────── - -func (h *ProjectHandler) AddKB(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req addKBRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Projects.AddKB(c.Request.Context(), project.ID, req.KBID, req.AutoSearch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add KB"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "KB added"}) -} - -func (h *ProjectHandler) RemoveKB(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - kbID := c.Param("kbId") - if kbID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "kb_id required"}) - return - } - - if err := h.stores.Projects.RemoveKB(c.Request.Context(), project.ID, kbID); err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "KB not in project"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove KB"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "KB removed"}) -} - -func (h *ProjectHandler) ListKBs(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - kbs, err := h.stores.Projects.ListKBs(c.Request.Context(), project.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list KBs"}) - return - } - if kbs == nil { - kbs = []models.ProjectKB{} - } - c.JSON(http.StatusOK, gin.H{"data": kbs}) -} - -// ── Note Association ──────────────────────── - -func (h *ProjectHandler) AddNote(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req addNoteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Projects.AddNote(c.Request.Context(), project.ID, req.NoteID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add note"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "note added"}) -} - -func (h *ProjectHandler) RemoveNote(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - noteID := c.Param("noteId") - if noteID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "note_id required"}) - return - } - - if err := h.stores.Projects.RemoveNote(c.Request.Context(), project.ID, noteID); err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "note not in project"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove note"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "note removed"}) -} - -func (h *ProjectHandler) ListNotes(c *gin.Context) { - project, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - notes, err := h.stores.Projects.ListNotes(c.Request.Context(), project.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"}) - return - } - if notes == nil { - notes = []models.ProjectNote{} - } - c.JSON(http.StatusOK, gin.H{"data": notes}) -} - -// ── Auth Helper ───────────────────────────── - -// ── Admin ──────────────────────────────────── - -// AdminList returns all projects (no scope filtering). Admin only. -func (h *ProjectHandler) AdminList(c *gin.Context) { - ctx := c.Request.Context() - includeArchived := c.Query("include_archived") == "true" - - projects, err := h.stores.Projects.AdminList(ctx, includeArchived) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) - return - } - - type adminProject struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Scope string `json:"scope"` - OwnerID string `json:"owner_id"` - TeamID *string `json:"team_id,omitempty"` - IsArchived bool `json:"is_archived"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ChannelCount int `json:"channel_count"` - KBCount int `json:"kb_count"` - NoteCount int `json:"note_count"` - OwnerName string `json:"owner_name"` - } - - result := make([]adminProject, 0, len(projects)) - for _, p := range projects { - result = append(result, adminProject{ - ID: p.ID, - Name: p.Name, - Description: p.Description, - Scope: p.Scope, - OwnerID: p.OwnerID, - TeamID: p.TeamID, - IsArchived: p.IsArchived, - CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), - ChannelCount: p.ChannelCount, - KBCount: p.KBCount, - NoteCount: p.NoteCount, - OwnerName: p.OwnerName, - }) - } - c.JSON(http.StatusOK, gin.H{"data": result}) -} - -// ── Auth Helper ───────────────────────────── - -// loadAndAuthorize loads the project by :id param and checks user access. -// Returns (project, true) on success, or writes an error response and returns (nil, false). -func (h *ProjectHandler) loadAndAuthorize(c *gin.Context) (*models.Project, bool) { - projectID := c.Param("id") - if projectID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "project id required"}) - return nil, false - } - - userID := getUserID(c) - - // Admins can access all projects - if c.GetString("role") != "admin" { - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) - - ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "access check failed"}) - return nil, false - } - if !ok { - c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) - return nil, false - } - } - - project, err := h.stores.Projects.GetByID(c.Request.Context(), projectID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load project"}) - return nil, false - } - - return project, true -} diff --git a/server/handlers/projects_test.go b/server/handlers/projects_test.go deleted file mode 100644 index 1babf74..0000000 --- a/server/handlers/projects_test.go +++ /dev/null @@ -1,373 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - - "switchboard-core/config" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/models" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// ── Project Test Harness ────────────────── - -type projectHarness struct { - *testHarness - stores store.Stores - userToken string - userID string - adminToken string - adminID string -} - -func setupProjectHarness(t *testing.T) *projectHarness { - t.Helper() - database.RequireTestDB(t) - database.TruncateAll(t) - - cfg := &config.Config{ - JWTSecret: testJWTSecret, - BasePath: "", - } - - var stores store.Stores - if database.IsSQLite() { - stores = sqlite.NewStores(database.TestDB) - } else { - stores = postgres.NewStores(database.TestDB) - } - userCache := middleware.NewUserStatusCache() - - r := gin.New() - api := r.Group("/api/v1") - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - projH := NewProjectHandler(stores) - protected.GET("/projects", projH.List) - protected.POST("/projects", projH.Create) - protected.GET("/projects/:id", projH.Get) - protected.PUT("/projects/:id", projH.Update) - protected.DELETE("/projects/:id", projH.Delete) - protected.POST("/projects/:id/channels", projH.AddChannel) - protected.DELETE("/projects/:id/channels/:channelId", projH.RemoveChannel) - protected.GET("/projects/:id/channels", projH.ListChannels) - protected.PUT("/projects/:id/channels/reorder", projH.ReorderChannels) - protected.POST("/projects/:id/knowledge-bases", projH.AddKB) - protected.DELETE("/projects/:id/knowledge-bases/:kbId", projH.RemoveKB) - protected.GET("/projects/:id/knowledge-bases", projH.ListKBs) - protected.POST("/projects/:id/notes", projH.AddNote) - protected.DELETE("/projects/:id/notes/:noteId", projH.RemoveNote) - protected.GET("/projects/:id/notes", projH.ListNotes) - - // Admin routes - admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg, stores.Users, userCache)) - admin.Use(middleware.RequireAdmin()) - admin.GET("/projects", projH.AdminList) - admin.DELETE("/projects/:id", projH.Delete) - - userID := database.SeedTestUser(t, "projuser", "projuser@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) - userToken := makeToken(userID, "projuser@test.com", "user") - - adminID := database.SeedTestUser(t, "projadmin", "projadmin@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, role = 'admin' WHERE id = $1"), adminID) - adminToken := makeToken(adminID, "projadmin@test.com", "admin") - - return &projectHarness{ - testHarness: &testHarness{router: r, t: t}, - stores: stores, - userToken: userToken, - userID: userID, - adminToken: adminToken, - adminID: adminID, - } -} - -// seedProject creates a project via the store and returns it. -func (h *projectHarness) seedProject(name, ownerID string) *models.Project { - h.t.Helper() - p := &models.Project{ - Name: name, - Scope: models.ScopePersonal, - OwnerID: ownerID, - } - if err := h.stores.Projects.Create(context.Background(), p); err != nil { - h.t.Fatalf("seedProject: %v", err) - } - return p -} - -// ── GET /projects — envelope ────────────── - -func TestProjects_List_Empty_Envelope(t *testing.T) { - h := setupProjectHarness(t) - - resp := h.request("GET", "/api/v1/projects", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - data, ok := body["data"] - if !ok { - t.Fatal("GET /projects must return {\"data\": [...]}, missing \"data\" key") - } - arr, ok := data.([]interface{}) - if !ok { - t.Fatalf("data should be an array, got %T", data) - } - if len(arr) != 0 { - t.Errorf("expected empty array, got %d items", len(arr)) - } -} - -func TestProjects_List_WithData_Shape(t *testing.T) { - h := setupProjectHarness(t) - h.seedProject("test-proj", h.userID) - - resp := h.request("GET", "/api/v1/projects", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - data, ok := body["data"].([]interface{}) - if !ok { - t.Fatal("data must be an array") - } - if len(data) != 1 { - t.Fatalf("expected 1 project, got %d", len(data)) - } - - proj := data[0].(map[string]interface{}) - for _, key := range []string{"id", "name", "scope", "owner_id", "is_archived", "created_at", "updated_at"} { - if _, exists := proj[key]; !exists { - t.Errorf("missing field %q in project object", key) - } - } - if proj["name"] != "test-proj" { - t.Errorf("name: got %v, want test-proj", proj["name"]) - } -} - -// ── POST /projects — create ────────────── - -func TestProjects_Create_201_Shape(t *testing.T) { - h := setupProjectHarness(t) - - resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{ - "name": "new-project", - "description": "test desc", - "color": "#ff0000", - "icon": "star", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Bare object, not wrapped in "data" - if _, exists := body["data"]; exists { - t.Error("POST /projects should return bare object, not wrapped in 'data'") - } - if body["name"] != "new-project" { - t.Errorf("name: got %v, want new-project", body["name"]) - } - if body["scope"] != "personal" { - t.Errorf("scope should be forced to personal, got %v", body["scope"]) - } - if body["owner_id"] != h.userID { - t.Errorf("owner_id: got %v, want %s", body["owner_id"], h.userID) - } - if body["color"] != "#ff0000" { - t.Errorf("color: got %v, want #ff0000", body["color"]) - } - if body["icon"] != "star" { - t.Errorf("icon: got %v, want star", body["icon"]) - } -} - -func TestProjects_Create_RequiresName(t *testing.T) { - h := setupProjectHarness(t) - - resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{ - "description": "no name", - }) - if resp.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for missing name, got %d", resp.Code) - } -} - -// ── GET /projects/:id — single object ───── - -func TestProjects_Get_Shape(t *testing.T) { - h := setupProjectHarness(t) - p := h.seedProject("detail-proj", h.userID) - - resp := h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Bare object - if _, exists := body["data"]; exists { - t.Error("GET /:id should return object directly, not wrapped in 'data'") - } - if body["id"] != p.ID { - t.Errorf("id: got %v, want %s", body["id"], p.ID) - } - // Computed counts use omitempty — absent when zero, present as number when nonzero. - // With a fresh project all counts are 0, so they'll be omitted. Verify that if - // present they're numeric (the AdminList test covers nonzero count visibility). - for _, key := range []string{"channel_count", "kb_count", "note_count"} { - if val, exists := body[key]; exists { - if _, ok := val.(float64); !ok { - t.Errorf("%s should be numeric, got %T", key, val) - } - } - } -} - -func TestProjects_Get_NotFound(t *testing.T) { - h := setupProjectHarness(t) - - resp := h.request("GET", "/api/v1/projects/00000000-0000-0000-0000-000000000000", h.userToken, nil) - if resp.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", resp.Code) - } -} - -// ── PUT /projects/:id — update ──────────── - -func TestProjects_Update_ReturnsRefreshed(t *testing.T) { - h := setupProjectHarness(t) - p := h.seedProject("old-name", h.userID) - - resp := h.request("PUT", "/api/v1/projects/"+p.ID, h.userToken, map[string]interface{}{ - "name": "new-name", - }) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - if body["name"] != "new-name" { - t.Errorf("name should be updated to new-name, got %v", body["name"]) - } - if body["id"] != p.ID { - t.Errorf("id mismatch: got %v, want %s", body["id"], p.ID) - } -} - -// ── DELETE /projects/:id ────────────────── - -func TestProjects_Delete_Shape(t *testing.T) { - h := setupProjectHarness(t) - p := h.seedProject("doomed", h.userID) - - resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - if body["message"] != "project deleted" { - t.Errorf("expected message 'project deleted', got %v", body["message"]) - } - - // Confirm actually deleted - resp = h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil) - if resp.Code != http.StatusNotFound { - t.Errorf("project should be gone, got %d", resp.Code) - } -} - -func TestProjects_Delete_ForbiddenForNonOwner(t *testing.T) { - h := setupProjectHarness(t) - - otherID := database.SeedTestUser(t, "other", "other@test.com") - p := h.seedProject("others-proj", otherID) - - resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil) - // Non-owner, non-admin user should not be able to access (404 from loadAndAuthorize) - if resp.Code != http.StatusNotFound { - t.Fatalf("expected 404 for other user's personal project, got %d", resp.Code) - } -} - -// ── Auth ────────────────────────────────── - -func TestProjects_RequiresAuth(t *testing.T) { - h := setupProjectHarness(t) - - resp := h.request("GET", "/api/v1/projects", "", nil) - if resp.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 without token, got %d", resp.Code) - } -} - -// ── Admin List ──────────────────────────── - -func TestProjects_AdminList_Envelope(t *testing.T) { - h := setupProjectHarness(t) - h.seedProject("admin-visible", h.userID) - - resp := h.request("GET", "/api/v1/admin/projects", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - data, ok := body["data"].([]interface{}) - if !ok { - t.Fatal("admin list must return {\"data\": [...]}") - } - if len(data) < 1 { - t.Fatal("expected at least 1 project in admin list") - } - - proj := data[0].(map[string]interface{}) - // Admin list has extra owner_name field - if _, exists := proj["owner_name"]; !exists { - t.Error("admin list project should include owner_name") - } - for _, key := range []string{"channel_count", "kb_count", "note_count"} { - if _, exists := proj[key]; !exists { - t.Errorf("admin list missing computed field %q", key) - } - } -} - -func TestProjects_AdminList_ForbiddenForUser(t *testing.T) { - h := setupProjectHarness(t) - - resp := h.request("GET", "/api/v1/admin/projects", h.userToken, nil) - if resp.Code != http.StatusForbidden { - t.Fatalf("expected 403 for non-admin, got %d", resp.Code) - } -} diff --git a/server/handlers/provider_resolver_adapter.go b/server/handlers/provider_resolver_adapter.go deleted file mode 100644 index beb5d29..0000000 --- a/server/handlers/provider_resolver_adapter.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package handlers — provider_resolver_adapter.go -// -// v0.29.1 CS2: Adapter that satisfies sandbox.ProviderResolver using -// ResolveProviderConfig + providers.Get. Avoids circular dependency -// (sandbox → handlers → sandbox) by having handlers implement the -// sandbox-defined interface. -package handlers - -import ( - "context" - "fmt" - - "switchboard-core/crypto" - "switchboard-core/providers" - "switchboard-core/sandbox" - "switchboard-core/store" -) - -// ProviderResolverAdapter implements sandbox.ProviderResolver using -// the existing ResolveProviderConfig function and provider registry. -type ProviderResolverAdapter struct { - stores store.Stores - vault *crypto.KeyResolver -} - -// NewProviderResolverAdapter creates an adapter for Starlark provider resolution. -func NewProviderResolverAdapter(stores store.Stores, vault *crypto.KeyResolver) *ProviderResolverAdapter { - return &ProviderResolverAdapter{stores: stores, vault: vault} -} - -// Resolve satisfies sandbox.ProviderResolver. It resolves the provider -// config via the BYOK chain and returns the provider instance + config. -func (a *ProviderResolverAdapter) Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*sandbox.ProviderResolution, error) { - res, err := ResolveProviderConfig(a.stores, a.vault, userID, channelID, providerConfigID, model) - if err != nil { - return nil, err - } - - provider, err := providers.Get(res.ProviderID) - if err != nil { - return nil, fmt.Errorf("provider unavailable: %w", err) - } - - return &sandbox.ProviderResolution{ - Provider: provider, - Config: res.Config, - ProviderID: res.ProviderID, - Model: res.Model, - ConfigID: res.ConfigID, - }, nil -} diff --git a/server/handlers/resolve.go b/server/handlers/resolve.go deleted file mode 100644 index 4331372..0000000 --- a/server/handlers/resolve.go +++ /dev/null @@ -1,306 +0,0 @@ -// Package handlers — resolve.go -// -// v0.27.2: Extracted provider resolution and tool definition building -// from CompletionHandler methods to standalone functions. Both the HTTP -// completion handler and the headless task scheduler need these paths. -// -// The CompletionHandler methods (resolveConfig, buildToolDefs) remain as -// thin wrappers for backward compat — existing callers are unchanged. -// -// v0.29.0-cs7a: Replaced raw SQL with store methods. -package handlers - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "log" - - "switchboard-core/crypto" - "switchboard-core/providers" - "switchboard-core/store" - "switchboard-core/tools" -) - -// ── Provider Resolution ──────────────────────── - -// ProviderResolution holds the resolved provider configuration and metadata. -type ProviderResolution struct { - Config providers.ProviderConfig - ProviderID string // e.g. "anthropic", "openai" - Model string // resolved model ID - ConfigID string // provider_configs.id - ProviderScope string // "personal", "team", "global" -} - -// ResolveProviderConfig resolves the provider configuration for a completion -// request. Resolution order: -// 1. Explicit providerConfigID (from request or task definition) -// 2. Channel's configured provider (provider_config_id on channels table) -// 3. User's first active config (personal first, then global) -// -// The vault is used to decrypt API keys. Pass nil for unencrypted fallback. -func ResolveProviderConfig( - stores store.Stores, - vault *crypto.KeyResolver, - userID, channelID, providerConfigID, modelID string, -) (ProviderResolution, error) { - ctx := context.Background() - configID := providerConfigID - - // 2. Config from channel - if configID == "" && channelID != "" { - channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID) - if err == nil && channelConfigID != nil { - configID = *channelConfigID - } - } - - // 3. User's first active config (personal first, then global — excludes team providers) - if configID == "" { - var err error - configID, err = stores.Providers.FindFirstForUser(ctx, userID) - if err != nil { - return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs") - } - } - - // Load the config — allow personal, global, OR team configs the user belongs to - cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID) - if err == sql.ErrNoRows { - return ProviderResolution{}, fmt.Errorf("API config not found or not accessible") - } - if err != nil { - return ProviderResolution{}, fmt.Errorf("failed to load API config: %w", err) - } - - // Resolve model: explicit > config default - model := modelID - if model == "" && cfg.ModelDefault != "" { - model = cfg.ModelDefault - } - if model == "" { - return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config") - } - - // Decrypt API key using the appropriate tier - key := "" - if len(cfg.APIKeyEnc) > 0 { - if vault != nil { - var err error - key, err = vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID) - if err != nil { - if err == crypto.ErrVaultLocked { - return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again") - } - return ProviderResolution{}, fmt.Errorf("failed to decrypt API key: %w", err) - } - } else { - // No vault — key stored as raw bytes (unencrypted fallback) - key = string(cfg.APIKeyEnc) - } - } - - // Parse custom headers - customHeaders := make(map[string]string) - if cfg.Headers != nil { - b, _ := json.Marshal(cfg.Headers) - _ = json.Unmarshal(b, &customHeaders) - } - - // Parse provider-specific settings - providerSettings := make(map[string]interface{}) - if cfg.Settings != nil { - for k, v := range cfg.Settings { - providerSettings[k] = v - } - } - - proxyMode := cfg.ProxyMode - if proxyMode == "" { - proxyMode = "system" - } - proxyURLStr := "" - if cfg.ProxyURL != nil { - proxyURLStr = *cfg.ProxyURL - } - - return ProviderResolution{ - Config: providers.ProviderConfig{ - Endpoint: cfg.Endpoint, - APIKey: key, - CustomHeaders: customHeaders, - Settings: providerSettings, - ProxyMode: proxyMode, - ProxyURL: proxyURLStr, - }, - ProviderID: cfg.Provider, - Model: model, - ConfigID: cfg.ID, - ProviderScope: cfg.Scope, - }, nil -} - -// ── Tool Definition Building ─────────────────── - -// BuildToolDefs assembles the tool definitions for a completion request. -// Includes server-registered tools filtered by context predicates, -// browser extension tools (if includeBrowser), and persona tool grant -// allowlisting. -// -// Additional tool grant filtering (e.g. task-level grants) can be applied -// by the caller after this function returns. -func BuildToolDefs( - ctx context.Context, - stores store.Stores, - userID string, - includeBrowser bool, - disabledTools []string, - tctx tools.ToolContext, - personaID string, -) []providers.ToolDef { - // Build disabled set for O(1) lookup - disabled := make(map[string]bool, len(disabledTools)) - for _, name := range disabledTools { - disabled[name] = true - } - - // v0.25.0: Tools self-declare availability via predicates. - allTools := tools.AvailableFor(tctx, disabled) - defs := make([]providers.ToolDef, 0, len(allTools)) - for _, t := range allTools { - defs = append(defs, providers.ToolDef{ - Type: "function", - Function: providers.FunctionDef{ - Name: t.Name, - Description: t.Description, - Parameters: t.Parameters, - }, - }) - } - - // Append browser-defined and starlark-tier tool schemas from extensions. - // v0.29.2: starlark tools are always included (not gated on browser connection). - if stores.Packages != nil { - pkgs, err := stores.Packages.ListForUser(ctx, userID) - if err != nil { - log.Printf("⚠️ Failed to load extensions for tools: %v", err) - return defs - } - for _, pkg := range pkgs { - // Browser tools only when a browser client is connected. - if pkg.Tier == "browser" && !includeBrowser { - continue - } - // Only browser and starlark tiers expose tools this way. - if pkg.Tier != "browser" && pkg.Tier != "starlark" { - continue - } - // Starlark packages must be active to expose tools. - if pkg.Tier == "starlark" && pkg.Status != "active" { - continue - } - var manifest struct { - Tools []struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` - } `json:"tools"` - } - if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil { - continue - } - for _, t := range manifest.Tools { - if disabled[t.Name] { - continue - } - defs = append(defs, providers.ToolDef{ - Type: "function", - Function: providers.FunctionDef{ - Name: t.Name, - Description: t.Description, - Parameters: t.Parameters, - }, - }) - } - } - } - - // v0.25.0: Persona tool grants — second-pass allowlist. - // If the active persona has explicit tool grants, restrict to only those tools. - // Empty grants = persona inherits all context-available tools (backward compat). - if personaID != "" && stores.Personas != nil { - grants, err := stores.Personas.GetToolGrants(ctx, personaID) - if err != nil { - log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err) - } else if len(grants) > 0 { - allowed := make(map[string]bool, len(grants)) - for _, g := range grants { - allowed[g] = true - } - filtered := defs[:0] - for _, d := range defs { - if allowed[d.Function.Name] { - filtered = append(filtered, d) - } - } - defs = filtered - } - } - - return defs -} - -// BuildExtToolMap returns a map of toolName → PackageRegistration for all -// active starlark-tier extensions that declare tools in their manifest. -// Used by the tool loop to dispatch matched calls to on_tool_call. -func BuildExtToolMap(ctx context.Context, stores store.Stores, userID string) map[string]*store.PackageRegistration { - if stores.Packages == nil { - return nil - } - pkgs, err := stores.Packages.ListForUser(ctx, userID) - if err != nil { - return nil - } - result := make(map[string]*store.PackageRegistration) - for i, pkg := range pkgs { - if pkg.Tier != "starlark" || pkg.Status != "active" { - continue - } - var manifest struct { - Tools []struct { - Name string `json:"name"` - } `json:"tools"` - } - if json.Unmarshal(marshalManifest(pkg.Manifest), &manifest) != nil { - continue - } - for _, t := range manifest.Tools { - if t.Name != "" { - result[t.Name] = &pkgs[i].PackageRegistration - } - } - } - return result -} - -// FilterToolDefsByGrants applies an additional allowlist to tool defs. -// Used by the task scheduler to enforce task-level tool grants on top -// of persona-level grants. Passing nil or empty grants returns defs unchanged. -func FilterToolDefsByGrants(defs []providers.ToolDef, grants []string) []providers.ToolDef { - if len(grants) == 0 { - return defs - } - allowed := make(map[string]bool, len(grants)) - for _, g := range grants { - allowed[g] = true - } - filtered := make([]providers.ToolDef, 0, len(defs)) - for _, d := range defs { - if allowed[d.Function.Name] { - filtered = append(filtered, d) - } - } - return filtered -} diff --git a/server/handlers/roles.go b/server/handlers/roles.go deleted file mode 100644 index 85ef9ad..0000000 --- a/server/handlers/roles.go +++ /dev/null @@ -1,243 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/roles" - "switchboard-core/store" -) - -// RolesHandler manages model role configuration. -type RolesHandler struct { - stores store.Stores - resolver *roles.Resolver -} - -// NewRolesHandler creates a roles handler. -func NewRolesHandler(s store.Stores, resolver *roles.Resolver) *RolesHandler { - return &RolesHandler{stores: s, resolver: resolver} -} - -// ── List All Role Configs ────────────────── -// GET /admin/roles - -func (h *RolesHandler) ListRoles(c *gin.Context) { - allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"}) - return - } - c.JSON(http.StatusOK, allRoles) -} - -// ── Get Single Role Config ───────────────── -// GET /admin/roles/:role - -func (h *RolesHandler) GetRole(c *gin.Context) { - role := c.Param("role") - if !roles.IsValidRole(role) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role}) - return - } - - cfg, err := h.resolver.GetConfig(c.Request.Context(), role, "", nil) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, cfg) -} - -// ── Update Role Config ───────────────────── -// PUT /admin/roles/:role - -func (h *RolesHandler) UpdateRole(c *gin.Context) { - role := c.Param("role") - if !roles.IsValidRole(role) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role}) - return - } - - var req roles.RoleConfig - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Load current global model_roles - allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"}) - return - } - if allRoles == nil { - allRoles = models.JSONMap{} - } - - // Update the specific role - allRoles[role] = req - - // Persist - userID := getUserID(c) - if err := h.stores.GlobalConfig.Set(c.Request.Context(), "model_roles", allRoles, userID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save role"}) - return - } - - c.JSON(http.StatusOK, req) -} - -// ── Test Role ────────────────────────────── -// POST /admin/roles/:role/test - -func (h *RolesHandler) TestRole(c *gin.Context) { - role := c.Param("role") - if !roles.IsValidRole(role) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role}) - return - } - - if role == roles.RoleEmbedding { - // Test embedding - result, err := h.resolver.Embed(c.Request.Context(), role, "", nil, []string{"test embedding"}) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "model": result.Model, - "provider": result.ProviderID, - "dimensions": len(result.Embeddings[0]), - "used_fallback": result.UsedFallback, - }) - return - } - - // Test completion with a minimal prompt - result, err := h.resolver.Complete(c.Request.Context(), role, "", nil, []providers.Message{ - {Role: "user", Content: "Say 'ok' and nothing else."}, - }) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "model": result.Model, - "provider": result.ProviderID, - "content": result.Content, - "input_tokens": result.InputTokens, - "output_tokens": result.OutputTokens, - "used_fallback": result.UsedFallback, - }) -} - -// ── Team Role Overrides ──────────────────── - -// ListTeamRoles returns role overrides for a specific team. -// GET /teams/:teamId/roles -func (h *RolesHandler) ListTeamRoles(c *gin.Context) { - teamID := c.Param("teamId") - - team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) - return - } - - roleOverrides := make(map[string]interface{}) - if team.Settings != nil { - if raw, ok := team.Settings["model_roles"]; ok { - if m, ok := raw.(map[string]interface{}); ok { - roleOverrides = m - } - } - } - - c.JSON(http.StatusOK, roleOverrides) -} - -// UpdateTeamRole sets a team role override. -// PUT /teams/:teamId/roles/:role -func (h *RolesHandler) UpdateTeamRole(c *gin.Context) { - teamID := c.Param("teamId") - role := c.Param("role") - if !roles.IsValidRole(role) { - c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role}) - return - } - - var req roles.RoleConfig - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) - return - } - - settings := team.Settings - if settings == nil { - settings = models.JSONMap{} - } - - roleOverrides, _ := settings["model_roles"].(map[string]interface{}) - if roleOverrides == nil { - roleOverrides = make(map[string]interface{}) - } - roleOverrides[role] = req - settings["model_roles"] = roleOverrides - - if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{ - "settings": settings, - }); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save team role"}) - return - } - - c.JSON(http.StatusOK, req) -} - -// DeleteTeamRole removes a team role override (falls back to global). -// DELETE /teams/:teamId/roles/:role -func (h *RolesHandler) DeleteTeamRole(c *gin.Context) { - teamID := c.Param("teamId") - role := c.Param("role") - - team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "team not found"}) - return - } - - settings := team.Settings - if settings == nil { - c.JSON(http.StatusOK, gin.H{"message": "no override to remove"}) - return - } - - roleOverrides, _ := settings["model_roles"].(map[string]interface{}) - if roleOverrides != nil { - delete(roleOverrides, role) - settings["model_roles"] = roleOverrides - } - - if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{ - "settings": settings, - }); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove team role"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "override removed"}) -} diff --git a/server/handlers/routing_admin.go b/server/handlers/routing_admin.go deleted file mode 100644 index 773f789..0000000 --- a/server/handlers/routing_admin.go +++ /dev/null @@ -1,242 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/health" - "switchboard-core/models" - "switchboard-core/routing" - "switchboard-core/store" -) - -// ── Routing Admin Handler ─────────────────── - -type RoutingAdminHandler struct { - stores store.Stores - evaluator *routing.Evaluator - healthStore health.Store -} - -func NewRoutingAdminHandler(stores store.Stores, evaluator *routing.Evaluator, hs health.Store) *RoutingAdminHandler { - return &RoutingAdminHandler{stores: stores, evaluator: evaluator, healthStore: hs} -} - -// ── CRUD ──────────────────────────────────── - -// ListPolicies returns all routing policies. -// GET /api/v1/admin/routing/policies -func (h *RoutingAdminHandler) ListPolicies(c *gin.Context) { - if h.stores.RoutingPolicies == nil { - c.JSON(http.StatusOK, gin.H{"data": []models.RoutingPolicy{}}) - return - } - - policies, err := h.stores.RoutingPolicies.ListAll(context.Background()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list policies: " + err.Error()}) - return - } - if policies == nil { - policies = []models.RoutingPolicy{} - } - c.JSON(http.StatusOK, gin.H{"data": policies}) -} - -// GetPolicy returns a single routing policy. -// GET /api/v1/admin/routing/policies/:id -func (h *RoutingAdminHandler) GetPolicy(c *gin.Context) { - if h.stores.RoutingPolicies == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "routing not available"}) - return - } - - p, err := h.stores.RoutingPolicies.GetByID(context.Background(), c.Param("id")) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"}) - return - } - c.JSON(http.StatusOK, p) -} - -// CreatePolicy creates a new routing policy. -// POST /api/v1/admin/routing/policies -func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) { - if h.stores.RoutingPolicies == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"}) - return - } - - var p models.RoutingPolicy - if err := c.ShouldBindJSON(&p); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate policy type - switch routing.PolicyType(p.Type) { - case routing.PolicyProviderPrefer, routing.PolicyTeamRoute, - routing.PolicyCostLimit, routing.PolicyModelAlias, - routing.PolicyCapabilityMatch: - // valid - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type}) - return - } - - // Validate scope - if p.Scope != "global" && p.Scope != "team" { - c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be 'global' or 'team'"}) - return - } - if p.Scope == "team" && (p.TeamID == nil || *p.TeamID == "") { - c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped policies"}) - return - } - - if err := h.stores.RoutingPolicies.Create(context.Background(), &p); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create policy: " + err.Error()}) - return - } - - c.JSON(http.StatusCreated, p) -} - -// UpdatePolicy updates an existing routing policy. -// PUT /api/v1/admin/routing/policies/:id -func (h *RoutingAdminHandler) UpdatePolicy(c *gin.Context) { - if h.stores.RoutingPolicies == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"}) - return - } - - id := c.Param("id") - - // Verify exists - if _, err := h.stores.RoutingPolicies.GetByID(context.Background(), id); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"}) - return - } - - var p models.RoutingPolicy - if err := c.ShouldBindJSON(&p); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - p.ID = id - - if err := h.stores.RoutingPolicies.Update(context.Background(), &p); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, p) -} - -// DeletePolicy removes a routing policy. -// DELETE /api/v1/admin/routing/policies/:id -func (h *RoutingAdminHandler) DeletePolicy(c *gin.Context) { - if h.stores.RoutingPolicies == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"}) - return - } - - if err := h.stores.RoutingPolicies.Delete(context.Background(), c.Param("id")); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete policy: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// ── Dry-Run Test ──────────────────────────── - -type routingTestRequest struct { - Model string `json:"model" binding:"required"` - UserID string `json:"user_id" binding:"required"` - TeamIDs []string `json:"team_ids,omitempty"` -} - -// TestRouting performs a dry-run policy evaluation for a given model + user. -// POST /api/v1/admin/routing/test -func (h *RoutingAdminHandler) TestRouting(c *gin.Context) { - if h.stores.RoutingPolicies == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"}) - return - } - - var req routingTestRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Load active policies - dbPolicies, err := h.stores.RoutingPolicies.ListActive(context.Background()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load policies"}) - return - } - policies := routing.FromModels(dbPolicies) - - // Build candidates from all active provider configs - candidates, err := h.buildCandidates(c) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build candidates: " + err.Error()}) - return - } - - // Build health status - healthStatus := make(map[string]models.ProviderStatus) - if h.healthStore != nil { - windows, _ := h.healthStore.ListAllCurrent(context.Background()) - for _, w := range windows { - healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount) - } - } - - ctx := &routing.Context{ - UserID: req.UserID, - TeamIDs: req.TeamIDs, - Model: req.Model, - HealthStatus: healthStatus, - Pricing: map[string]*models.ModelPricing{}, - } - - result, decision := h.evaluator.Evaluate(ctx, policies, candidates) - - c.JSON(http.StatusOK, gin.H{ - "candidates": result, - "decision": decision, - "policies": len(policies), - }) -} - -// buildCandidates creates routing candidates from all active provider configs. -func (h *RoutingAdminHandler) buildCandidates(c *gin.Context) ([]routing.Candidate, error) { - if h.stores.Providers == nil { - return nil, nil - } - - // For dry-run, list all global configs (admin scope). - configs, err := h.stores.Providers.ListGlobal(context.Background()) - if err != nil { - return nil, err - } - - candidates := make([]routing.Candidate, 0, len(configs)) - for _, cfg := range configs { - if !cfg.IsActive { - continue - } - candidates = append(candidates, routing.Candidate{ - ConfigID: cfg.ID, - ProviderID: cfg.Provider, - Model: "", // model is applied per-policy or from request - Endpoint: cfg.Endpoint, - }) - } - return candidates, nil -} diff --git a/server/handlers/seed_providers.go b/server/handlers/seed_providers.go deleted file mode 100644 index b815abc..0000000 --- a/server/handlers/seed_providers.go +++ /dev/null @@ -1,141 +0,0 @@ -package handlers - -import ( - "context" - "log" - "strings" - - "switchboard-core/config" - "switchboard-core/crypto" - "switchboard-core/models" - "switchboard-core/store" -) - -// Known provider default endpoints for seeding. -var seedDefaultEndpoints = map[string]string{ - "openai": "https://api.openai.com/v1", - "anthropic": "https://api.anthropic.com", - "openrouter": "https://openrouter.ai/api/v1", - "venice": "https://api.venice.ai/api/v1", - "mistral": "https://api.mistral.ai/v1", - "groq": "https://api.groq.com/openai/v1", - "together": "https://api.together.xyz/v1", - "fireworks": "https://api.fireworks.ai/inference/v1", - "deepseek": "https://api.deepseek.com/v1", - "perplexity": "https://api.perplexity.ai", -} - -// SeedProviders creates global providers from the SEED_PROVIDERS env var. -// -// Format: "provider:api_key[:name],provider:api_key[:name]" -// -// Examples: -// -// SEED_PROVIDERS="openai:sk-xxx,anthropic:sk-ant-xxx" -// SEED_PROVIDERS="openai:sk-xxx:My OpenAI,openrouter:sk-or-xxx" -// -// Idempotent: skips if a global provider with the same name already exists. -// Blocked in production. -func SeedProviders(cfg *config.Config, stores store.Stores, resolver *crypto.KeyResolver) { - if cfg.SeedProviders == "" { - return - } - - if cfg.Environment == "production" { - log.Printf("⚠ SEED_PROVIDERS ignored in production environment") - return - } - - ctx := context.Background() - entries := strings.Split(cfg.SeedProviders, ",") - log.Printf(" 🌱 SEED_PROVIDERS: %d entries to process", len(entries)) - - for _, entry := range entries { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - - parts := strings.SplitN(entry, ":", 3) - if len(parts) < 2 { - log.Printf("⚠ Seed provider skipped (bad format, want provider:key[:name]): %q", entry) - continue - } - - provider := strings.ToLower(strings.TrimSpace(parts[0])) - apiKey := strings.TrimSpace(parts[1]) - - // Derive name: explicit or "OpenAI (seed)", "Anthropic (seed)", etc. - name := "" - if len(parts) >= 3 { - name = strings.TrimSpace(parts[2]) - } - if name == "" { - // Capitalize provider name - name = strings.ToUpper(provider[:1]) + provider[1:] + " (seed)" - } - - // Resolve endpoint - endpoint, ok := seedDefaultEndpoints[provider] - if !ok { - // Unknown provider — treat as openai-compatible with custom endpoint - log.Printf("⚠ Seed provider '%s': unknown provider, skipping (no default endpoint)", provider) - continue - } - - if apiKey == "" { - log.Printf("⚠ Seed provider '%s': empty API key, skipping", name) - continue - } - - // Check if already exists (idempotent) - existing, _ := stores.Providers.ListGlobal(ctx) - found := false - for _, p := range existing { - if p.Name == name || (p.Provider == provider && p.Scope == models.ScopeGlobal) { - found = true - break - } - } - if found { - log.Printf(" 🌱 Seed provider '%s' already exists, skipping", name) - continue - } - - // Create provider config - pcfg := &models.ProviderConfig{ - Name: name, - Provider: provider, - Endpoint: endpoint, - Scope: models.ScopeGlobal, - KeyScope: models.ScopeGlobal, - IsActive: true, - } - - // Encrypt API key - if resolver != nil { - enc, nonce, err := resolver.EncryptForScope(apiKey, "global", "") - if err != nil { - log.Printf("⚠ Seed provider '%s': encrypt failed: %v", name, err) - continue - } - pcfg.APIKeyEnc = enc - pcfg.KeyNonce = nonce - } else { - pcfg.APIKeyEnc = []byte(apiKey) - } - - if err := stores.Providers.Create(ctx, pcfg); err != nil { - log.Printf("⚠ Seed provider '%s': create failed: %v", name, err) - continue - } - - // Auto-fetch and enable models - result, err := syncAndEnableProviderModels(ctx, stores, pcfg, apiKey) - if err != nil { - log.Printf(" 🌱 Seed provider '%s' created (model fetch failed: %v)", name, err) - } else { - log.Printf(" 🌱 Seed provider '%s' created (%d models fetched)", name, result.Total) - } - } -} diff --git a/server/handlers/stream_loop.go b/server/handlers/stream_loop.go deleted file mode 100644 index df2b904..0000000 --- a/server/handlers/stream_loop.go +++ /dev/null @@ -1,203 +0,0 @@ -package handlers - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "log" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/events" - "switchboard-core/metrics" - "switchboard-core/providers" - "switchboard-core/sandbox" - "switchboard-core/store" - "switchboard-core/tools" -) - -// recordHealthFn records provider health from standalone streaming functions -// and updates Prometheus completion metrics (v0.33.0). -func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) { - duration := time.Since(start) - latencyMs := int(duration.Milliseconds()) - - if configID != "" { - metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds()) - } - - if hr == nil || configID == "" { - return - } - if err != nil { - errMsg := err.Error() - status := "error" - if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") { - hr.RecordRateLimit(configID, latencyMs, errMsg) - status = "rate_limited" - } else { - hr.RecordError(configID, latencyMs, errMsg) - } - metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc() - } else { - hr.RecordSuccess(configID, latencyMs) - metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc() - } -} - -// streamWithToolLoop is the SSE streaming entry point for single-model -// completion with tool execution. Sets SSE headers, delegates to -// CoreToolLoop with an sseSink, and returns the accumulated result. -// -// Used by: -// - streamCompletion (normal chat — persists via persistMessage) -// - Regenerate (regen/edit — persists as sibling with tree metadata) -func streamWithToolLoop( - c *gin.Context, - provider providers.Provider, - cfg providers.ProviderConfig, - req *providers.CompletionRequest, - model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string, - hub *events.Hub, - health HealthRecorder, - runner *sandbox.Runner, - extTools map[string]*store.PackageRegistration, -) LoopResult { - // Set SSE headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - c.Status(http.StatusOK) - - sink := newSSESink(c, model) - - return CoreToolLoop(c.Request.Context(), LoopConfig{ - Provider: provider, - Cfg: cfg, - Req: req, - Model: model, - ProviderType: providerType, - ExecCtx: tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - PersonaID: personaID, - WorkspaceID: workspaceID, - TeamID: teamID, - }, - Hub: hub, - Health: health, - ConfigID: configID, - Budget: LoopBudget{}, - Streaming: true, - Runner: runner, - ExtTools: extTools, - }, sink) -} - -const browserToolTimeout = 30 * time.Second - -// streamModelResponse is the multi-model streaming variant. Runs on an -// already-established SSE connection — does NOT set SSE headers or send -// [DONE]. SSE deltas include a "model_display" field for frontend attribution. -func streamModelResponse( - c *gin.Context, - provider providers.Provider, - cfg providers.ProviderConfig, - req *providers.CompletionRequest, - model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string, - hub *events.Hub, - health HealthRecorder, - runner *sandbox.Runner, - extTools map[string]*store.PackageRegistration, -) LoopResult { - sink := newSSEModelSink(c, model, displayName) - - return CoreToolLoop(c.Request.Context(), LoopConfig{ - Provider: provider, - Cfg: cfg, - Req: req, - Model: model, - ProviderType: providerType, - ExecCtx: tools.ExecutionContext{ - UserID: userID, - ChannelID: channelID, - PersonaID: personaID, - WorkspaceID: workspaceID, - TeamID: teamID, - }, - Hub: hub, - Health: health, - ConfigID: configID, - Budget: LoopBudget{}, - Streaming: true, - Runner: runner, - ExtTools: extTools, - }, sink) -} - -// executeBrowserTool sends a tool call to the user's browser via WebSocket -// and blocks until a result is returned or the timeout elapses. -func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult { - b := make([]byte, 16) - rand.Read(b) - callID := hex.EncodeToString(b) - - // Publish tool.call event to the user's browser - payload, _ := json.Marshal(map[string]interface{}{ - "call_id": callID, - "tool": call.Name, - "arguments": json.RawMessage(call.Arguments), - }) - - hub.PublishToUser(userID, events.Event{ - Label: "tool.call." + callID, - Payload: payload, - Ts: time.Now().UnixMilli(), - }) - - // Block until browser sends tool.result.{callID} or timeout - event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout) - if !ok { - log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID) - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`, - IsError: true, - } - } - - // Parse result from the browser event payload - var result struct { - CallID string `json:"call_id"` - Result string `json:"result"` - Error string `json:"error"` - } - if err := json.Unmarshal(event.Payload, &result); err != nil { - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: `{"error":"invalid result from browser tool"}`, - IsError: true, - } - } - - if result.Error != "" { - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: result.Error, - IsError: true, - } - } - - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: result.Result, - } -} diff --git a/server/handlers/summarize.go b/server/handlers/summarize.go deleted file mode 100644 index 4c6f6cb..0000000 --- a/server/handlers/summarize.go +++ /dev/null @@ -1,93 +0,0 @@ -package handlers - -// summarize.go — Manual conversation summarization via HTTP. -// -// v0.29.0: Raw SQL replaced with ChannelStore methods. - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/compaction" - "switchboard-core/roles" - "switchboard-core/store" -) - -// SummarizeHandler handles manual conversation summarization via HTTP. -type SummarizeHandler struct { - stores store.Stores - compaction *compaction.Service -} - -// NewSummarizeHandler creates a new handler backed by the compaction service. -func NewSummarizeHandler(stores store.Stores, svc *compaction.Service) *SummarizeHandler { - return &SummarizeHandler{stores: stores, compaction: svc} -} - -// ── Summarize & Continue ────────────────── -// POST /channels/:id/summarize - -func (h *SummarizeHandler) Summarize(c *gin.Context) { - channelID := c.Param("id") - userID := getUserID(c) - - // ── Verify channel ownership ── - ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID) - if err != nil || ch == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - if ch.UserID != userID { - c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"}) - return - } - - // ── Check utility role is configured ── - resolver := h.compaction.Resolver() - if !resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) { - if !resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.", - }) - return - } - } - - // ── Rate limiting (org-funded calls only) ── - isPersonal := resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) - if !isPersonal { - if err := h.compaction.CheckRateLimit(c.Request.Context(), userID); err != nil { - c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) - return - } - } - - // ── Compact ── - teamID := h.compaction.GetUserTeamID(c.Request.Context(), userID) - result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{ - ChannelID: channelID, - UserID: userID, - TeamID: teamID, - Trigger: "manual", - }) - if err != nil { - switch err.Error() { - case "conversation too short to summarize": - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - case "not enough new messages since last summary": - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - default: - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - } - return - } - - c.JSON(http.StatusOK, gin.H{ - "summary_id": result.SummaryID, - "summarized_count": result.SummarizedCount, - "model": result.Model, - "used_fallback": result.UsedFallback, - "content": result.Content, - }) -} diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go deleted file mode 100644 index 8d76de5..0000000 --- a/server/handlers/team_providers.go +++ /dev/null @@ -1,373 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "log" - "net/http" - - "github.com/gin-gonic/gin" - - capspkg "switchboard-core/capabilities" - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/store" -) - -// ── Team Provider Handlers ────────────────── - -// ListTeamProviders returns API configs scoped to a team. -func (h *TeamHandler) ListTeamProviders(c *gin.Context) { - teamID := getTeamID(c) - ctx := c.Request.Context() - - configs, err := h.stores.Providers.ListAllForTeam(ctx, teamID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"}) - return - } - - type teamProvider struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - Endpoint string `json:"endpoint"` - HasKey bool `json:"has_key"` - ModelDefault *string `json:"model_default"` - Config map[string]interface{} `json:"config"` - IsActive bool `json:"is_active"` - IsPrivate bool `json:"is_private"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - - result := make([]teamProvider, 0, len(configs)) - for _, cfg := range configs { - var md *string - if cfg.ModelDefault != "" { - md = &cfg.ModelDefault - } - cfgMap := map[string]interface{}{} - if cfg.Config != nil { - cfgMap = cfg.Config - } - result = append(result, teamProvider{ - ID: cfg.ID, - Name: cfg.Name, - Provider: cfg.Provider, - Endpoint: cfg.Endpoint, - HasKey: cfg.HasKey(), - ModelDefault: md, - Config: cfgMap, - IsActive: cfg.IsActive, - IsPrivate: cfg.IsPrivate, - CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"), - }) - } - - c.JSON(http.StatusOK, gin.H{ - "data": result, - "allow_team_providers": isTeamProvidersAllowed(h.stores, teamID), - }) -} - -// CreateTeamProvider creates an API config scoped to a team. -func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { - teamID := getTeamID(c) - - if !isTeamProvidersAllowed(h.stores, teamID) { - c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"}) - return - } - - var req struct { - Name string `json:"name" binding:"required,max=100"` - Provider string `json:"provider" binding:"required"` - Endpoint string `json:"endpoint" binding:"required"` - APIKey string `json:"api_key"` - ModelDefault string `json:"model_default,omitempty"` - Config map[string]interface{} `json:"config,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - IsPrivate bool `json:"is_private,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if _, err := providers.Get(req.Provider); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "unsupported provider: " + req.Provider, - "supported_providers": providers.List(), - }) - return - } - - // Encrypt the API key for team scope - var apiKeyEnc, keyNonce []byte - if req.APIKey != "" { - if h.vault != nil { - var err error - apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - } else { - apiKeyEnc = []byte(req.APIKey) - } - } - - headersMap := models.JSONMap{} - if req.Headers != nil { - for k, v := range req.Headers { - headersMap[k] = v - } - } - - cfg := &models.ProviderConfig{ - Scope: models.ScopeTeam, - OwnerID: &teamID, - Name: req.Name, - Provider: req.Provider, - Endpoint: req.Endpoint, - APIKeyEnc: apiKeyEnc, - KeyNonce: keyNonce, - KeyScope: "team", - ModelDefault: req.ModelDefault, - Config: models.JSONMap(req.Config), - Headers: headersMap, - IsActive: true, - IsPrivate: req.IsPrivate, - } - - if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { - log.Printf("[WARN] Failed to create team provider: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"}) - return - } - - c.JSON(http.StatusCreated, gin.H{"id": cfg.ID}) -} - -// UpdateTeamProvider updates a team-scoped API config. -func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { - teamID := getTeamID(c) - providerID := c.Param("id") - ctx := c.Request.Context() - - var req struct { - Name *string `json:"name,omitempty"` - Endpoint *string `json:"endpoint,omitempty"` - APIKey *string `json:"api_key,omitempty"` - ModelDefault *string `json:"model_default,omitempty"` - Config map[string]interface{} `json:"config,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - IsPrivate *bool `json:"is_private,omitempty"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Verify provider belongs to this team - existing, err := h.stores.Providers.GetByID(ctx, providerID) - if err != nil || existing.Scope != models.ScopeTeam || (existing.OwnerID != nil && *existing.OwnerID != teamID) { - c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) - return - } - - // Build patch - patch := models.ProviderConfigPatch{} - fieldCount := 0 - - if req.Name != nil { - patch.Name = req.Name - fieldCount++ - } - if req.Endpoint != nil { - patch.Endpoint = req.Endpoint - fieldCount++ - } - if req.APIKey != nil && *req.APIKey != "" { - if h.vault != nil { - enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - patch.APIKeyEnc = enc - patch.KeyNonce = nonce - } else { - patch.APIKeyEnc = []byte(*req.APIKey) - } - fieldCount++ - } - if req.ModelDefault != nil { - patch.ModelDefault = req.ModelDefault - fieldCount++ - } - if req.IsActive != nil { - patch.IsActive = req.IsActive - fieldCount++ - } - if req.IsPrivate != nil { - patch.IsPrivate = req.IsPrivate - fieldCount++ - } - if req.Config != nil { - patch.Config = models.JSONMap(req.Config) - fieldCount++ - } - if req.Headers != nil { - headersMap := models.JSONMap{} - for k, v := range req.Headers { - headersMap[k] = v - } - patch.Headers = headersMap - fieldCount++ - } - - if fieldCount == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return - } - - if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"}) - return - } - - c.JSON(http.StatusOK, gin.H{"id": providerID, "updated": true}) -} - -// DeleteTeamProvider removes a team-scoped API config. -func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) { - teamID := getTeamID(c) - providerID := c.Param("id") - - n, err := h.stores.Providers.DeleteByIDAndTeam(c.Request.Context(), providerID, teamID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"}) - return - } - if n == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) - return - } - - c.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -// ListTeamProviderModels lists models available from a team provider (live query). -func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { - teamID := getTeamID(c) - providerID := c.Param("id") - ctx := c.Request.Context() - - cfg, err := h.stores.Providers.GetByID(ctx, providerID) - if err != nil || cfg.Scope != models.ScopeTeam || !cfg.IsActive { - c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) - return - } - if cfg.OwnerID == nil || *cfg.OwnerID != teamID { - c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) - return - } - - provider, err := providers.Get(cfg.Provider) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"}) - return - } - - key := "" - if cfg.HasKey() { - if h.vault != nil { - var err error - key, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"}) - return - } - } else { - key = string(cfg.APIKeyEnc) - } - } - - var customHeaders map[string]string - if cfg.Headers != nil { - b, _ := json.Marshal(cfg.Headers) - _ = json.Unmarshal(b, &customHeaders) - } - - modelList, err := provider.ListModels(ctx, providers.ProviderConfig{ - Endpoint: cfg.Endpoint, - APIKey: key, - CustomHeaders: customHeaders, - }) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) - return - } - - type modelInfo struct { - ID string `json:"id"` - Type string `json:"type"` - Capabilities models.ModelCapabilities `json:"capabilities"` - } - - out := make([]modelInfo, 0, len(modelList)) - for _, m := range modelList { - caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil) - caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps) - out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps}) - } - - c.JSON(http.StatusOK, gin.H{"data": out, "provider": cfg.Name}) -} - -// parseJSONBConfig parses a JSONB text string into a map. -func parseJSONBConfig(raw string) map[string]interface{} { - if raw == "" || raw == "{}" || raw == "null" { - return map[string]interface{}{} - } - var m map[string]interface{} - if err := json.Unmarshal([]byte(raw), &m); err != nil { - return map[string]interface{}{} - } - return m -} - -// isTeamProvidersAllowed checks if team providers are enabled. -func isTeamProvidersAllowed(stores store.Stores, teamID string) bool { - if stores.GlobalConfig == nil { - return false - } - - ctx := context.Background() - - // Check global setting - globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers") - if err == nil && globalVal == "false" { - return false - } - - // Check team-level setting - team, err := stores.Teams.GetByID(ctx, teamID) - if err != nil { - return true // fail open - } - - if team.Settings != nil { - if v, ok := team.Settings["allow_team_providers"]; ok { - if bVal, ok := v.(bool); ok { - return bVal - } - } - } - - return true -} diff --git a/server/handlers/title.go b/server/handlers/title.go deleted file mode 100644 index 6678d0e..0000000 --- a/server/handlers/title.go +++ /dev/null @@ -1,110 +0,0 @@ -package handlers - -import ( - "context" - "fmt" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/providers" - "switchboard-core/roles" - "switchboard-core/store" -) - -// TitleHandler generates chat titles using the utility role. -type TitleHandler struct { - stores store.Stores - resolver *roles.Resolver -} - -// NewTitleHandler creates a title generation handler. -func NewTitleHandler(s store.Stores, r *roles.Resolver) *TitleHandler { - return &TitleHandler{stores: s, resolver: r} -} - -// GenerateTitle generates a title for a channel using the utility role. -// POST /channels/:id/generate-title -func (h *TitleHandler) GenerateTitle(c *gin.Context) { - channelID := c.Param("id") - userID := c.GetString("user_id") - teamID := c.GetString("team_id") - - // Verify channel access - owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID) - if err != nil || !owns { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - - ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - - // Load first few messages - msgs, err := h.stores.Messages.ListForChannel(c.Request.Context(), channelID, store.ListOptions{ - Limit: 4, Order: "asc", - }) - if err != nil || len(msgs) == 0 { - c.JSON(http.StatusOK, gin.H{"title": ch.Title}) // keep existing - return - } - - // Build a snippet from the first messages - var snippet strings.Builder - for _, m := range msgs { - content := m.Content - if len(content) > 200 { - content = content[:200] + "…" - } - fmt.Fprintf(&snippet, "%s: %s\n", m.Role, content) - } - - // Call utility role - ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) - defer cancel() - - var tID *string - if teamID != "" { - tID = &teamID - } - - result, err := h.resolver.Complete(ctx, roles.RoleUtility, userID, tID, []providers.Message{ - { - Role: "system", - Content: "You are a title generator. Given a conversation snippet, produce a concise title of 6 words or fewer. Respond with ONLY the title, no quotes, no punctuation at the end, no explanation.", - }, - { - Role: "user", - Content: "Title this conversation:\n\n" + snippet.String(), - }, - }) - if err != nil { - // Fallback: truncate first user message - c.JSON(http.StatusOK, gin.H{"title": ch.Title}) - return - } - - title := strings.TrimSpace(result.Content) - // Strip surrounding quotes if the model added them - title = strings.Trim(title, "\"'`\u201c\u201d\u2018\u2019") - if title == "" { - c.JSON(http.StatusOK, gin.H{"title": ch.Title}) - return - } - // Cap at 100 chars - if len(title) > 100 { - title = title[:100] - } - - // Update channel - _ = h.stores.Channels.Update(c.Request.Context(), channelID, map[string]interface{}{ - "title": title, - }) - - c.JSON(http.StatusOK, gin.H{"title": title}) -} diff --git a/server/handlers/tool_loop.go b/server/handlers/tool_loop.go deleted file mode 100644 index eb83d19..0000000 --- a/server/handlers/tool_loop.go +++ /dev/null @@ -1,782 +0,0 @@ -// Package handlers — tool_loop.go -// -// v0.27.2: Extracted core tool loop with sink abstraction. -// -// coreToolLoop is the single implementation of multi-round LLM completion -// with tool execution. All callers (SSE streaming, multi-model streaming, -// sync JSON, headless scheduler) provide a LoopSink for I/O and a LoopConfig -// for dependencies. The loop handles provider calls, tool dispatch (server + -// browser bridge), health recording, budget enforcement, and workspace events. -// -// Prior to this extraction, the same logic was duplicated in three places: -// - streamWithToolLoop (SSE streaming) -// - streamModelResponse (multi-model SSE) -// - syncCompletion (non-streaming JSON) -// -// Those functions are now thin wrappers over coreToolLoop. -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "time" - - "github.com/gin-gonic/gin" - "go.starlark.net/starlark" - - "switchboard-core/events" - "switchboard-core/providers" - "switchboard-core/sandbox" - "switchboard-core/store" - "switchboard-core/tools" -) - -// ── Types ────────────────────────────────────── - -// defaultMaxRounds is the safety limit on tool call iterations. -// Callers can override via LoopBudget.MaxRounds. -const defaultMaxRounds = 10 - -// LoopResult holds the accumulated output from a completion with tool -// execution. Callers are responsible for persistence. -// FileRef tracks a file created by a tool for auto-linking to the assistant message. -type FileRef struct { - WorkspaceID string `json:"workspace_id"` - Path string `json:"path"` -} - -type LoopResult struct { - Content string - ToolActivity []map[string]interface{} - FileRefs []FileRef // v0.37.18: workspace files created by tools - InputTokens int - OutputTokens int - CacheCreationTokens int - CacheReadTokens int - ToolCallCount int // total tool calls across all rounds - BudgetExceeded string // "" | "tokens" | "tool_calls" | "max_rounds" - Error error // non-nil if the loop terminated due to a provider error -} - -// LoopBudget controls resource limits for the tool loop. -// Zero values mean "use default" for MaxRounds and "unlimited" for the rest. -type LoopBudget struct { - MaxRounds int // max tool-call iterations; 0 = defaultMaxRounds - MaxToolCalls int // total tool calls across all rounds; 0 = unlimited - MaxTokens int // total input+output tokens; 0 = unlimited -} - -func (b LoopBudget) maxRounds() int { - if b.MaxRounds > 0 { - return b.MaxRounds - } - return defaultMaxRounds -} - -// LoopConfig bundles all dependencies for the core tool loop. -type LoopConfig struct { - Provider providers.Provider - Cfg providers.ProviderConfig - Req *providers.CompletionRequest - Model string - ProviderType string - ExecCtx tools.ExecutionContext - Hub *events.Hub // nil for headless (scheduler) - Health HealthRecorder // nil for headless - ConfigID string - Budget LoopBudget - Streaming bool // true = StreamCompletion, false = ChatCompletion - // v0.29.2: extension tool dispatch (nil = no extension tools) - Runner *sandbox.Runner - ExtTools map[string]*store.PackageRegistration // toolName → package -} - -// ── Sink Interface ───────────────────────────── - -// LoopSink receives events from the core tool loop for I/O. -// Implementations control what happens with each event: write SSE to -// a gin.Context, accumulate silently, log to stdout, etc. -type LoopSink interface { - // OnDelta receives a text content delta from the LLM. - OnDelta(delta string) - - // OnReasoning receives a reasoning/thinking delta from the LLM. - OnReasoning(delta string) - - // OnError receives a fatal error (provider failure, stream error). - OnError(err error) - - // OnToolUse is called when the LLM requests tool calls. - OnToolUse(calls []providers.ToolCall) - - // OnToolResult is called after each tool execution completes. - OnToolResult(result map[string]interface{}) - - // OnFinish is called when the LLM produces a finish_reason. - OnFinish(reason string) - - // OnDone signals end of the entire completion (after finish or budget breach). - OnDone() - - // OnMaxRounds is called when the loop hits its iteration limit. - OnMaxRounds() -} - -// ── Core Tool Loop ───────────────────────────── - -// coreToolLoop is the single, canonical tool-loop implementation. -// It drives multi-round LLM completion with tool execution, budget -// enforcement, and health recording. I/O is delegated to the sink. -// -// The caller is responsible for: -// - Setting SSE headers (if streaming to a client) -// - Calling providers.GetHooks().PreRequest() before entry -// - Persisting the result after return -// - Sending [DONE] or HTTP response after return (via sink.OnDone) -func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResult { - var result LoopResult - maxRounds := lcfg.Budget.maxRounds() - - for iteration := 0; iteration < maxRounds; iteration++ { - var iterContent string - var iterReasoning string - var toolCalls []providers.ToolCall - var iterInput, iterOutput, iterCacheCreate, iterCacheRead int - - if lcfg.Streaming { - done := runStreamingRound(ctx, lcfg, sink, &result, - &iterContent, &iterReasoning, &toolCalls, - &iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead) - if done { - return result - } - } else { - done := runSyncRound(ctx, lcfg, sink, &result, - &iterContent, &iterReasoning, &toolCalls, - &iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead) - if done { - return result - } - } - - // ── Tool execution round ──────────────── - if len(toolCalls) == 0 { - // Stream/call ended without tool calls or finish — edge case - if iterReasoning != "" { - result.Content += "" + iterReasoning + "" - } - result.Content += iterContent - sink.OnDone() - return result - } - - // Budget check: tool calls (pre-execution) - result.ToolCallCount += len(toolCalls) - if lcfg.Budget.MaxToolCalls > 0 && result.ToolCallCount > lcfg.Budget.MaxToolCalls { - result.BudgetExceeded = "tool_calls" - result.Content += iterContent - sink.OnFinish("budget_exceeded") - sink.OnDone() - return result - } - - sink.OnToolUse(toolCalls) - - // Append assistant message (with tool_calls, possibly with text) to conversation - assistantMsg := providers.Message{ - Role: "assistant", - Content: iterContent, - } - for _, tc := range toolCalls { - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc) - } - lcfg.Req.Messages = append(lcfg.Req.Messages, assistantMsg) - - // Execute all tool calls - for _, tc := range toolCalls { - call := tools.ToolCall{ - ID: tc.ID, - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - } - - toolResult := executeToolCall(ctx, lcfg, call) - - // Notify sink - resultMap := map[string]interface{}{ - "tool_call_id": toolResult.ToolCallID, - "name": toolResult.Name, - "content": toolResult.Content, - "is_error": toolResult.IsError, - } - sink.OnToolResult(resultMap) - - // Emit workspace.file.changed for live editor updates (v0.21.5) - emitWorkspaceEvent(lcfg, call, toolResult) - - // Collect file refs for tool_output auto-save (v0.37.18) - if ref := collectFileRef(lcfg, call, toolResult); ref != nil { - result.FileRefs = append(result.FileRefs, *ref) - } - - // Collect for persistence - result.ToolActivity = append(result.ToolActivity, map[string]interface{}{ - "id": tc.ID, - "name": tc.Function.Name, - "arguments": tc.Function.Arguments, - "result": toolResult.Content, - "is_error": toolResult.IsError, - }) - - // Append tool result to conversation for next iteration - lcfg.Req.Messages = append(lcfg.Req.Messages, providers.Message{ - Role: "tool", - Content: toolResult.Content, - ToolCallID: toolResult.ToolCallID, - Name: toolResult.Name, - }) - } - - result.Content += iterContent - - // Budget check: tokens (post-round) - if lcfg.Budget.MaxTokens > 0 && (result.InputTokens+result.OutputTokens) > lcfg.Budget.MaxTokens { - result.BudgetExceeded = "tokens" - sink.OnFinish("budget_exceeded") - sink.OnDone() - return result - } - - // Loop: send updated messages back to provider - } - - // Hit max iterations - result.BudgetExceeded = "max_rounds" - sink.OnMaxRounds() - sink.OnDone() - return result -} - -// ── Streaming Round ──────────────────────────── - -// runStreamingRound executes one provider StreamCompletion call and -// processes the event stream. Returns true if the loop should return -// (normal finish or error), false if tool execution should follow. -func runStreamingRound( - ctx context.Context, - lcfg LoopConfig, - sink LoopSink, - result *LoopResult, - iterContent, iterReasoning *string, - toolCalls *[]providers.ToolCall, - iterInput, iterOutput, iterCacheCreate, iterCacheRead *int, -) bool { - callStart := time.Now() - ch, err := lcfg.Provider.StreamCompletion(ctx, lcfg.Cfg, *lcfg.Req) - if err != nil { - recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err) - result.Error = err - sink.OnError(err) - return true - } - - for event := range ch { - // Apply provider-specific post-processing hooks (v0.22.1) - if hooks := providers.GetHooks(lcfg.ProviderType); hooks != nil { - hooks.PostStreamEvent(lcfg.Cfg, &event) - } - - if event.Error != nil { - recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, event.Error) - result.Error = event.Error - sink.OnError(event.Error) - return true - } - - // Reasoning deltas - if event.Reasoning != "" { - *iterReasoning += event.Reasoning - sink.OnReasoning(event.Reasoning) - } - - // Content deltas - if event.Delta != "" { - *iterContent += event.Delta - sink.OnDelta(event.Delta) - } - - if event.Done { - recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, nil) - - if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 { - *toolCalls = event.ToolCalls - result.InputTokens += event.InputTokens - result.OutputTokens += event.OutputTokens - result.CacheCreationTokens += event.CacheCreationTokens - result.CacheReadTokens += event.CacheReadTokens - return false // continue to tool execution - } - - // Normal completion - finishReason := event.FinishReason - if finishReason == "" { - finishReason = "stop" - } - if *iterReasoning != "" { - result.Content += "" + *iterReasoning + "" - } - result.Content += *iterContent - result.InputTokens += event.InputTokens - result.OutputTokens += event.OutputTokens - result.CacheCreationTokens += event.CacheCreationTokens - result.CacheReadTokens += event.CacheReadTokens - - sink.OnFinish(finishReason) - sink.OnDone() - return true - } - } - - // Channel closed without Done event — shouldn't happen - return false -} - -// ── Sync Round ───────────────────────────────── - -// runSyncRound executes one provider ChatCompletion call. Returns true -// if the loop should return, false if tool execution should follow. -func runSyncRound( - ctx context.Context, - lcfg LoopConfig, - sink LoopSink, - result *LoopResult, - iterContent, iterReasoning *string, - toolCalls *[]providers.ToolCall, - iterInput, iterOutput, iterCacheCreate, iterCacheRead *int, -) bool { - callStart := time.Now() - resp, err := lcfg.Provider.ChatCompletion(ctx, lcfg.Cfg, *lcfg.Req) - recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err) - if err != nil { - result.Error = err - sink.OnError(err) - return true - } - - *iterContent = resp.Content - *iterInput = resp.InputTokens - *iterOutput = resp.OutputTokens - *iterCacheCreate = resp.CacheCreationTokens - *iterCacheRead = resp.CacheReadTokens - - result.InputTokens += resp.InputTokens - result.OutputTokens += resp.OutputTokens - result.CacheCreationTokens += resp.CacheCreationTokens - result.CacheReadTokens += resp.CacheReadTokens - - if resp.FinishReason == "tool_calls" && len(resp.ToolCalls) > 0 { - *toolCalls = resp.ToolCalls - return false // continue to tool execution - } - - // Normal completion — deliver full content as single delta - result.Content += resp.Content - sink.OnDelta(resp.Content) - finishReason := resp.FinishReason - if finishReason == "" { - finishReason = "stop" - } - sink.OnFinish(finishReason) - sink.OnDone() - return true -} - -// ── Tool Execution ───────────────────────────── - -// executeToolCall dispatches a single tool call: server-side first, -// browser bridge fallback, unknown tool error as last resort. -func executeToolCall(ctx context.Context, lcfg LoopConfig, call tools.ToolCall) tools.ToolResult { - toolStart := time.Now() - - // Server-side tool - if tools.Get(call.Name) != nil { - log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID) - result := tools.ExecuteCall(ctx, lcfg.ExecCtx, call) - if lcfg.Health != nil { - toolLatency := int(time.Since(toolStart).Milliseconds()) - if result.IsError { - lcfg.Health.RecordToolError(call.Name, toolLatency, result.Content) - } else { - lcfg.Health.RecordToolSuccess(call.Name, toolLatency) - } - } - return result - } - - // v0.29.2: Starlark extension tool - if pkg, ok := lcfg.ExtTools[call.Name]; ok && lcfg.Runner != nil { - log.Printf("🔧 Executing tool (extension): %s (call %s, pkg %s)", call.Name, call.ID, pkg.ID) - return executeExtensionTool(ctx, lcfg, pkg, call) - } - - // Browser bridge (only available when hub is connected) - if lcfg.Hub != nil && lcfg.Hub.IsConnected(lcfg.ExecCtx.UserID) { - log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID) - return executeBrowserTool(lcfg.Hub, lcfg.ExecCtx.UserID, call) - } - - // Unknown tool - log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID) - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: `{"error":"unknown tool or browser not connected"}`, - IsError: true, - } -} - -// executeExtensionTool calls a starlark extension's on_tool_call entry point -// and serializes the return value to a JSON string for the tool result. -func executeExtensionTool(ctx context.Context, lcfg LoopConfig, pkg *store.PackageRegistration, call tools.ToolCall) tools.ToolResult { - // Parse JSON arguments into a Starlark dict. - var rawArgs map[string]interface{} - if call.Arguments != "" { - if err := json.Unmarshal([]byte(call.Arguments), &rawArgs); err != nil { - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: `{"error":"invalid tool arguments JSON"}`, - IsError: true, - } - } - } - - // Build the call dict passed to on_tool_call(call). - callDict := starlark.NewDict(3) - _ = callDict.SetKey(starlark.String("tool_name"), starlark.String(call.Name)) - _ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String(call.ID)) - _ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(rawArgs)) - - rc := &sandbox.RunContext{ - UserID: lcfg.ExecCtx.UserID, - ChannelID: lcfg.ExecCtx.ChannelID, - } - val, output, err := lcfg.Runner.CallEntryPoint(ctx, pkg, "on_tool_call", - starlark.Tuple{callDict}, nil, rc) - if output != "" { - log.Printf(" 🔧 ext tool %s print: %s", pkg.ID, output) - } - if err != nil { - log.Printf("⚠️ ext tool %s on_tool_call error: %v", pkg.ID, err) - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: fmt.Sprintf(`{"error":%q}`, err.Error()), - IsError: true, - } - } - - // Serialize the Starlark return value to JSON. - content, jsonErr := json.Marshal(starlarkValueToGo(val)) - if jsonErr != nil { - content = []byte(fmt.Sprintf(`{"error":%q}`, jsonErr.Error())) - } - return tools.ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: string(content), - } -} - -// jsonToStarlark recursively converts a decoded-JSON value (map/slice/scalar) -// to its Starlark equivalent. -func jsonToStarlark(v interface{}) starlark.Value { - if v == nil { - return starlark.None - } - switch val := v.(type) { - case map[string]interface{}: - d := starlark.NewDict(len(val)) - for k, v := range val { - _ = d.SetKey(starlark.String(k), jsonToStarlark(v)) - } - return d - case []interface{}: - elems := make([]starlark.Value, len(val)) - for i, v := range val { - elems[i] = jsonToStarlark(v) - } - return starlark.NewList(elems) - case string: - return starlark.String(val) - case float64: - if val == float64(int64(val)) { - return starlark.MakeInt64(int64(val)) - } - return starlark.Float(val) - case bool: - return starlark.Bool(val) - default: - return starlark.String(fmt.Sprintf("%v", val)) - } -} - -// starlarkValueToGo recursively converts a Starlark value to a Go value -// suitable for json.Marshal. -func starlarkValueToGo(v starlark.Value) interface{} { - if v == nil || v == starlark.None { - return nil - } - switch val := v.(type) { - case starlark.String: - return string(val) - case starlark.Int: - i64, ok := val.Int64() - if ok { - return i64 - } - return val.String() - case starlark.Float: - return float64(val) - case starlark.Bool: - return bool(val) - case *starlark.Dict: - m := make(map[string]interface{}, val.Len()) - for _, item := range val.Items() { - k, ok := starlark.AsString(item[0]) - if !ok { - k = item[0].String() - } - m[k] = starlarkValueToGo(item[1]) - } - return m - case *starlark.List: - list := make([]interface{}, val.Len()) - for i := 0; i < val.Len(); i++ { - list[i] = starlarkValueToGo(val.Index(i)) - } - return list - default: - return v.String() - } -} - -// emitWorkspaceEvent sends workspace.file.changed when a write tool succeeds. -func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) { - if lcfg.Hub == nil || result.IsError || lcfg.ExecCtx.WorkspaceID == "" { - return - } - if call.Name != "workspace_write" && call.Name != "workspace_patch" { - return - } - var toolArgs struct { - Path string `json:"path"` - } - if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" { - lcfg.Hub.PublishToUser(lcfg.ExecCtx.UserID, events.Event{ - Label: "workspace.file.changed", - Payload: events.MustJSON(map[string]string{ - "workspace_id": lcfg.ExecCtx.WorkspaceID, - "path": toolArgs.Path, - }), - }) - } -} - -// collectFileRef returns a FileRef when workspace_write or workspace_patch succeeds. -func collectFileRef(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) *FileRef { - if result.IsError || lcfg.ExecCtx.WorkspaceID == "" { - return nil - } - if call.Name != "workspace_write" && call.Name != "workspace_patch" { - return nil - } - var toolArgs struct { - Path string `json:"path"` - } - if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" { - return &FileRef{ - WorkspaceID: lcfg.ExecCtx.WorkspaceID, - Path: toolArgs.Path, - } - } - return nil -} - -// ── Sink Implementations ─────────────────────── - -// sseSink writes SSE events to a gin.Context writer for interactive streaming. -type sseSink struct { - w http.ResponseWriter - flush func() - model string -} - -func newSSESink(c *gin.Context, model string) *sseSink { - flusher, _ := c.Writer.(http.Flusher) - return &sseSink{ - w: c.Writer, - model: model, - flush: func() { - if flusher != nil { - flusher.Flush() - } - }, - } -} - -func (s *sseSink) sendData(data string) { - fmt.Fprintf(s.w, "data: %s\n\n", data) - s.flush() -} - -func (s *sseSink) sendEvent(event, data string) { - fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, data) - s.flush() -} - -func (s *sseSink) OnDelta(delta string) { - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`, - delta, s.model)) -} - -func (s *sseSink) OnReasoning(delta string) { - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`, - delta, s.model)) -} - -func (s *sseSink) OnError(err error) { - s.sendData(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error()))) -} - -func (s *sseSink) OnToolUse(calls []providers.ToolCall) { - toolCallsJSON, _ := json.Marshal(calls) - s.sendEvent("tool_use", string(toolCallsJSON)) -} - -func (s *sseSink) OnToolResult(result map[string]interface{}) { - resultJSON, _ := json.Marshal(result) - s.sendEvent("tool_result", string(resultJSON)) -} - -func (s *sseSink) OnFinish(reason string) { - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`, - reason, s.model)) -} - -func (s *sseSink) OnDone() { - s.sendData("[DONE]") -} - -func (s *sseSink) OnMaxRounds() { - log.Printf("⚠️ Tool loop hit max iterations for model %s", s.model) - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`, - escapeJSON("[Tool execution limit reached]"), s.model)) -} - -// sseModelSink extends sseSink with model_display attribution for -// multi-model streaming. Does NOT send [DONE] — the multiModelStream -// caller sends it after all models finish. -type sseModelSink struct { - sseSink - displayName string -} - -func newSSEModelSink(c *gin.Context, model, displayName string) *sseModelSink { - base := newSSESink(c, model) - return &sseModelSink{ - sseSink: *base, - displayName: displayName, - } -} - -func (s *sseModelSink) OnDelta(delta string) { - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`, - delta, s.model, s.displayName)) -} - -func (s *sseModelSink) OnReasoning(delta string) { - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`, - delta, s.model, s.displayName)) -} - -func (s *sseModelSink) OnFinish(reason string) { - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`, - reason, s.model, s.displayName)) -} - -// OnDone is a no-op for multi-model — caller sends [DONE] after all models. -func (s *sseModelSink) OnDone() {} - -func (s *sseModelSink) OnMaxRounds() { - log.Printf("⚠️ Multi-model tool loop hit max iterations for model %s", s.displayName) - s.sendData(fmt.Sprintf( - `{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`, - escapeJSON("[Tool execution limit reached]"), s.model, s.displayName)) -} - -// accumSink accumulates silently — used by syncCompletion where the -// caller formats the HTTP response after the loop returns. -type accumSink struct{} - -func (accumSink) OnDelta(string) {} -func (accumSink) OnReasoning(string) {} -func (accumSink) OnError(error) {} -func (accumSink) OnToolUse([]providers.ToolCall) {} -func (accumSink) OnToolResult(map[string]interface{}) {} -func (accumSink) OnFinish(string) {} -func (accumSink) OnDone() {} -func (accumSink) OnMaxRounds() {} - -// HeadlessSink is used by the task scheduler — accumulates and logs. -// No client connection, no SSE output. -type HeadlessSink struct { - taskID string -} - -func NewHeadlessSink(taskID string) *HeadlessSink { - return &HeadlessSink{taskID: taskID} -} - -func (s *HeadlessSink) OnDelta(string) {} -func (s *HeadlessSink) OnReasoning(string) {} - -func (s *HeadlessSink) OnError(err error) { - log.Printf("[task:%s] Error: %v", s.taskID, err) -} - -func (s *HeadlessSink) OnToolUse(calls []providers.ToolCall) { - names := make([]string, len(calls)) - for i, tc := range calls { - names[i] = tc.Function.Name - } - log.Printf("[task:%s] Tool calls: %v", s.taskID, names) -} - -func (s *HeadlessSink) OnToolResult(result map[string]interface{}) { - name, _ := result["name"].(string) - isErr, _ := result["is_error"].(bool) - if isErr { - log.Printf("[task:%s] Tool %s failed: %v", s.taskID, name, result["content"]) - } -} - -func (s *HeadlessSink) OnFinish(reason string) { - log.Printf("[task:%s] Finished: %s", s.taskID, reason) -} - -func (s *HeadlessSink) OnDone() {} - -func (s *HeadlessSink) OnMaxRounds() { - log.Printf("[task:%s] Hit max tool iterations", s.taskID) -} diff --git a/server/handlers/tree.go b/server/handlers/tree.go deleted file mode 100644 index 00133e1..0000000 --- a/server/handlers/tree.go +++ /dev/null @@ -1,59 +0,0 @@ -package handlers - -// This file previously contained all tree traversal logic (path building, -// sibling queries, cursor management). As of v0.15.0, the core logic lives -// in the treepath package; these are package-local aliases so existing -// handler code (messages.go, completion.go) compiles with minimal churn. -// -// New code should import treepath directly. - -import ( - "switchboard-core/treepath" -) - -// ── Type Aliases ──────────────────────────── -// Existing handler code references these types by unqualified name. - -type PathMessage = treepath.PathMessage -type SiblingInfo = treepath.SiblingInfo - -// ── Function Wrappers ─────────────────────── - -func getActiveLeaf(channelID, userID string) (*string, error) { - return treepath.GetActiveLeaf(channelID, userID) -} - -func getActivePath(channelID, userID string) ([]PathMessage, error) { - return treepath.GetActivePath(channelID, userID) -} - -func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) { - return treepath.GetPathToLeaf(channelID, leafID) -} - -func getSiblingCount(channelID string, parentID *string) int { - return treepath.GetSiblingCount(channelID, parentID) -} - -func getSiblings(messageID string) ([]SiblingInfo, int, error) { - return treepath.GetSiblings(messageID) -} - -func findLeafFromMessage(messageID string) (string, error) { - return treepath.FindLeafFromMessage(messageID) -} - -func nextSiblingIndex(channelID string, parentID *string) int { - return treepath.NextSiblingIndex(channelID, parentID) -} - -func updateCursor(channelID, userID, messageID string) error { - return treepath.UpdateCursor(channelID, userID, messageID) -} - -// isSummaryMessage checks if a PathMessage has summary metadata. -// This was previously defined in completion.go; moved here alongside the -// other tree helpers so all callers use the canonical treepath version. -func isSummaryMessage(m *PathMessage) bool { - return treepath.IsSummaryMessage(m) -} diff --git a/server/handlers/usage.go b/server/handlers/usage.go deleted file mode 100644 index f55d1cc..0000000 --- a/server/handlers/usage.go +++ /dev/null @@ -1,236 +0,0 @@ -package handlers - -import ( - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// UsageHandler manages usage tracking and pricing endpoints. -type UsageHandler struct { - stores store.Stores -} - -// NewUsageHandler creates a usage handler. -func NewUsageHandler(s store.Stores) *UsageHandler { - return &UsageHandler{stores: s} -} - -// ── Admin: Aggregated Usage ──────────────── -// GET /admin/usage?since=...&until=...&group_by=model|user|day|provider - -func (h *UsageHandler) AdminUsage(c *gin.Context) { - opts := h.parseQueryOptions(c) - opts.ExcludeBYOK = true // Admin views exclude personal BYOK - - results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"}) - return - } - - totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "totals": totals, - "results": results, - }) -} - -// ── Admin: Per-User Usage ────────────────── -// GET /admin/usage/users/:id - -func (h *UsageHandler) AdminUserUsage(c *gin.Context) { - userID := c.Param("id") - opts := h.parseQueryOptions(c) - opts.ExcludeBYOK = true - - results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"}) - return - } - - c.JSON(http.StatusOK, gin.H{"results": results}) -} - -// ── Admin: Per-Team Usage ────────────────── -// GET /admin/usage/teams/:id - -func (h *UsageHandler) AdminTeamUsage(c *gin.Context) { - teamID := c.Param("id") - opts := h.parseQueryOptions(c) - opts.ExcludeBYOK = true - - results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"}) - return - } - - c.JSON(http.StatusOK, gin.H{"results": results}) -} - -// ── User: Personal Usage ─────────────────── -// GET /usage - -func (h *UsageHandler) PersonalUsage(c *gin.Context) { - userID := getUserID(c) - opts := h.parseQueryOptions(c) - - totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"}) - return - } - - results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "totals": totals, - "results": results, - }) -} - -// ── Team Admin: Team Provider Usage ──────── -// GET /teams/:teamId/usage -// -// Shows usage against providers owned by this team. Only team admins -// (enforced by RequireTeamAdmin middleware) can see this view. - -func (h *UsageHandler) TeamUsage(c *gin.Context) { - teamID := c.Param("teamId") - opts := h.parseQueryOptions(c) - - totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"}) - return - } - - results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "totals": totals, - "results": results, - }) -} - -// ── Admin: List Pricing ──────────────────── -// GET /admin/pricing - -func (h *UsageHandler) ListPricing(c *gin.Context) { - entries, err := h.stores.Pricing.List(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"}) - return - } - if entries == nil { - entries = []models.PricingEntry{} - } - - c.JSON(http.StatusOK, entries) -} - -// ── Admin: Upsert Pricing ────────────────── -// PUT /admin/pricing - -func (h *UsageHandler) UpsertPricing(c *gin.Context) { - var req models.PricingEntry - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate provider is admin-managed (global or team), not personal BYOK - pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID) - if err != nil || pc == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"}) - return - } - if pc.Scope == "personal" { - c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"}) - return - } - - userID := getUserID(c) - req.Source = "manual" - req.UpdatedBy = &userID - - if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "pricing saved"}) -} - -// ── Admin: Delete Pricing ────────────────── -// DELETE /admin/pricing/:provider/:model - -func (h *UsageHandler) DeletePricing(c *gin.Context) { - providerConfigID := c.Param("provider") - modelID := c.Param("model") - - if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"}) -} - -// ── Helpers ──────────────────────────────── - -func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions { - opts := store.UsageQueryOptions{ - GroupBy: c.DefaultQuery("group_by", "model"), - Limit: 100, - } - - if since := c.Query("since"); since != "" { - if t, err := time.Parse(time.RFC3339, since); err == nil { - opts.Since = &t - } - } - if until := c.Query("until"); until != "" { - if t, err := time.Parse(time.RFC3339, until); err == nil { - opts.Until = &t - } - } - - // Convenience shorthand: ?period=7d|30d|90d - if period := c.Query("period"); period != "" && opts.Since == nil { - var dur time.Duration - switch period { - case "7d": - dur = 7 * 24 * time.Hour - case "30d": - dur = 30 * 24 * time.Hour - case "90d": - dur = 90 * 24 * time.Hour - } - if dur > 0 { - t := time.Now().Add(-dur) - opts.Since = &t - } - } - - return opts -} diff --git a/server/handlers/workspace_test.go b/server/handlers/workspace_test.go deleted file mode 100644 index 244d4ff..0000000 --- a/server/handlers/workspace_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - - "switchboard-core/config" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/models" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// ── Workspace Test Harness ──────────────── - -type workspaceHarness struct { - *testHarness - stores store.Stores - userToken string - userID string -} - -func setupWorkspaceHarness(t *testing.T) *workspaceHarness { - t.Helper() - database.RequireTestDB(t) - database.TruncateAll(t) - - cfg := &config.Config{ - JWTSecret: testJWTSecret, - BasePath: "", - } - - var stores store.Stores - if database.IsSQLite() { - stores = sqlite.NewStores(database.TestDB) - } else { - stores = postgres.NewStores(database.TestDB) - } - userCache := middleware.NewUserStatusCache() - - r := gin.New() - api := r.Group("/api/v1") - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - // Workspace handler — nil wfs is fine for CRUD tests that don't touch filesystem - wsH := NewWorkspaceHandler(stores, nil) - protected.GET("/workspaces", wsH.List) - protected.GET("/workspaces/:id", wsH.Get) - - // Git credentials — nil vault is fine for List (doesn't decrypt) - gitCredH := NewGitCredentialHandler(stores, nil) - protected.GET("/git-credentials", gitCredH.List) - - userID := database.SeedTestUser(t, "wsuser", "wsuser@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) - userToken := makeToken(userID, "wsuser@test.com", "user") - - return &workspaceHarness{ - testHarness: &testHarness{router: r, t: t}, - stores: stores, - userToken: userToken, - userID: userID, - } -} - -// seedWorkspace creates a workspace via the store and returns it. -func (h *workspaceHarness) seedWorkspace(name, ownerType, ownerID string) *models.Workspace { - h.t.Helper() - w := &models.Workspace{ - Name: name, - OwnerType: ownerType, - OwnerID: ownerID, - Status: models.WorkspaceStatusActive, - RootPath: "workspaces/test-" + name, - } - w.ID = store.NewID() - if err := h.stores.Workspaces.Create(context.Background(), w); err != nil { - h.t.Fatalf("seedWorkspace: %v", err) - } - return w -} - -// ── GET /workspaces — envelope ──────────── - -func TestWorkspaces_List_Empty_Envelope(t *testing.T) { - h := setupWorkspaceHarness(t) - - resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Must have "data" key - data, ok := body["data"] - if !ok { - t.Fatal("GET /workspaces must return {\"data\": [...]}, missing \"data\" key") - } - - // data must be an array (not null) - arr, ok := data.([]interface{}) - if !ok { - t.Fatalf("data should be an array, got %T", data) - } - if len(arr) != 0 { - t.Errorf("expected empty array, got %d items", len(arr)) - } -} - -func TestWorkspaces_List_WithData_Envelope(t *testing.T) { - h := setupWorkspaceHarness(t) - - // Seed a workspace owned by the user - h.seedWorkspace("test-ws", models.WorkspaceOwnerUser, h.userID) - - resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - data, ok := body["data"].([]interface{}) - if !ok { - t.Fatal("data must be an array") - } - if len(data) != 1 { - t.Fatalf("expected 1 workspace, got %d", len(data)) - } - - // Verify workspace object shape - ws, ok := data[0].(map[string]interface{}) - if !ok { - t.Fatal("workspace entry should be an object") - } - - for _, key := range []string{"id", "name", "owner_type", "owner_id", "status", "created_at", "updated_at"} { - if _, exists := ws[key]; !exists { - t.Errorf("missing field %q in workspace object", key) - } - } - - if ws["name"] != "test-ws" { - t.Errorf("name: got %v, want test-ws", ws["name"]) - } - if ws["owner_type"] != "user" { - t.Errorf("owner_type: got %v, want user", ws["owner_type"]) - } -} - -func TestWorkspaces_List_DoesNotExposeRootPath(t *testing.T) { - h := setupWorkspaceHarness(t) - h.seedWorkspace("secret-ws", models.WorkspaceOwnerUser, h.userID) - - resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil) - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - data := body["data"].([]interface{}) - ws := data[0].(map[string]interface{}) - - if _, exists := ws["root_path"]; exists { - t.Error("root_path must never be exposed in API responses") - } -} - -func TestWorkspaces_List_IsolatedByUser(t *testing.T) { - h := setupWorkspaceHarness(t) - - // Seed workspace for another user - otherID := database.SeedTestUser(t, "other", "other@test.com") - h.seedWorkspace("other-ws", models.WorkspaceOwnerUser, otherID) - - // Also seed one for our user - h.seedWorkspace("my-ws", models.WorkspaceOwnerUser, h.userID) - - resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil) - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - data := body["data"].([]interface{}) - if len(data) != 1 { - t.Fatalf("expected 1 workspace (own only), got %d", len(data)) - } - - ws := data[0].(map[string]interface{}) - if ws["name"] != "my-ws" { - t.Errorf("should only see own workspace, got %v", ws["name"]) - } -} - -// ── GET /workspaces/:id — single object ─── - -func TestWorkspaces_Get_Shape(t *testing.T) { - h := setupWorkspaceHarness(t) - w := h.seedWorkspace("detail-ws", models.WorkspaceOwnerUser, h.userID) - - resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Single object — no "data" wrapper - if _, exists := body["data"]; exists { - t.Error("GET /:id should return object directly, not wrapped in 'data'") - } - if body["id"] != w.ID { - t.Errorf("id: got %v, want %s", body["id"], w.ID) - } - if body["name"] != "detail-ws" { - t.Errorf("name: got %v, want detail-ws", body["name"]) - } -} - -func TestWorkspaces_Get_NotFound(t *testing.T) { - h := setupWorkspaceHarness(t) - - resp := h.request("GET", "/api/v1/workspaces/00000000-0000-0000-0000-000000000000", h.userToken, nil) - if resp.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", resp.Code) - } -} - -func TestWorkspaces_Get_ForbiddenForOtherUser(t *testing.T) { - h := setupWorkspaceHarness(t) - - otherID := database.SeedTestUser(t, "stranger", "stranger@test.com") - w := h.seedWorkspace("private-ws", models.WorkspaceOwnerUser, otherID) - - resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil) - if resp.Code != http.StatusForbidden { - t.Fatalf("expected 403 for other user's workspace, got %d", resp.Code) - } -} - -// ── GET /git-credentials — envelope ─────── - -func TestGitCredentials_List_Empty_Envelope(t *testing.T) { - h := setupWorkspaceHarness(t) - - resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) - } - - var body map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &body) - - // Must have "data" key - data, ok := body["data"] - if !ok { - t.Fatal("GET /git-credentials must return {\"data\": [...]}, missing \"data\" key") - } - - // data must be an array (not null) - arr, ok := data.([]interface{}) - if !ok { - t.Fatalf("data should be an array, got %T", data) - } - if len(arr) != 0 { - t.Errorf("expected empty array, got %d items", len(arr)) - } -} - -// ── Auth ────────────────────────────────── - -func TestWorkspaces_RequiresAuth(t *testing.T) { - h := setupWorkspaceHarness(t) - - resp := h.request("GET", "/api/v1/workspaces", "", nil) - if resp.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 without token, got %d", resp.Code) - } -} - -func TestGitCredentials_RequiresAuth(t *testing.T) { - h := setupWorkspaceHarness(t) - - resp := h.request("GET", "/api/v1/git-credentials", "", nil) - if resp.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 without token, got %d", resp.Code) - } -} diff --git a/server/handlers/workspaces.go b/server/handlers/workspaces.go deleted file mode 100644 index 27d06b0..0000000 --- a/server/handlers/workspaces.go +++ /dev/null @@ -1,589 +0,0 @@ -package handlers - -import ( - "context" - "database/sql" - "fmt" - "io" - "log" - "net/http" - "os" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/workspace" -) - -// ── Request Types ─────────────────────────── - -type createWorkspaceRequest struct { - Name string `json:"name" binding:"required,max=200"` - OwnerType string `json:"owner_type" binding:"required,oneof=user project channel team"` - OwnerID string `json:"owner_id" binding:"required"` - MaxBytes *int64 `json:"max_bytes,omitempty"` -} - -type updateWorkspaceRequest = models.WorkspacePatch - -// ── Handler ───────────────────────────────── - -// WorkspaceHandler handles workspace CRUD, file operations, and archive management. -type WorkspaceHandler struct { - stores store.Stores - wfs *workspace.FS -} - -// NewWorkspaceHandler creates a new workspace handler. -func NewWorkspaceHandler(s store.Stores, wfs *workspace.FS) *WorkspaceHandler { - return &WorkspaceHandler{stores: s, wfs: wfs} -} - -// ── Workspace CRUD ────────────────────────── - -func (h *WorkspaceHandler) Create(c *gin.Context) { - var req createWorkspaceRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Authorization: verify the caller owns or can access the owner entity - if !h.canAccessOwner(c, req.OwnerType, req.OwnerID) { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) - return - } - - w := &models.Workspace{ - OwnerType: req.OwnerType, - OwnerID: req.OwnerID, - Name: req.Name, - MaxBytes: req.MaxBytes, - Status: models.WorkspaceStatusActive, - } - - // Pre-generate ID so root_path can be set before DB insert. - // Works for both Postgres (overrides gen_random_uuid()) and SQLite (store.NewID()). - w.ID = store.NewID() - w.RootPath = "workspaces/" + w.ID - - if err := h.stores.Workspaces.Create(c.Request.Context(), w); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"}) - log.Printf("workspace create: %v", err) - return - } - - // Create directory on disk - if err := h.wfs.CreateDir(w); err != nil { - log.Printf("workspace mkdir: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace directory"}) - return - } - - c.JSON(http.StatusCreated, w) -} - -// GetDefault returns the current user's personal workspace, creating it on first access. -// The personal workspace is the first user-owned workspace (by created_at) or a new -// "My Files" workspace if none exists. Idempotent — safe to call on every page load. -func (h *WorkspaceHandler) GetDefault(c *gin.Context) { - userID := getUserID(c) - ctx := c.Request.Context() - - w, err := h.stores.Workspaces.GetByOwner(ctx, models.WorkspaceOwnerUser, userID) - if err == nil { - // Enrich with stats - stats, _ := h.stores.Workspaces.GetStats(ctx, w.ID) - if stats != nil { - w.FileCount = stats.FileCount - w.TotalBytes = stats.TotalBytes - } - c.JSON(http.StatusOK, w) - return - } - if err != sql.ErrNoRows { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch workspace"}) - log.Printf("workspace getDefault: %v", err) - return - } - - // Auto-create personal workspace - w = &models.Workspace{ - OwnerType: models.WorkspaceOwnerUser, - OwnerID: userID, - Name: "My Files", - Status: models.WorkspaceStatusActive, - } - w.ID = store.NewID() - w.RootPath = "workspaces/" + w.ID - - if err := h.stores.Workspaces.Create(ctx, w); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create default workspace"}) - log.Printf("workspace getDefault create: %v", err) - return - } - - if err := h.wfs.CreateDir(w); err != nil { - log.Printf("workspace getDefault mkdir: %v", err) - // Workspace record exists but directory failed — still return the workspace. - // Directory will be created on first file upload. - } - - c.JSON(http.StatusOK, w) -} - -func (h *WorkspaceHandler) Get(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Enrich with stats - stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID) - if err == nil { - w.FileCount = stats.FileCount - w.TotalBytes = stats.TotalBytes - } - - c.JSON(http.StatusOK, w) -} - -// List returns all workspaces accessible to the current user. -// Includes user-owned workspaces and those owned by the user's teams. -func (h *WorkspaceHandler) List(c *gin.Context) { - userID := getUserID(c) - ctx := c.Request.Context() - - // Fetch user-owned workspaces - userWs, err := h.stores.Workspaces.ListByOwner(ctx, "user", userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - all := make([]models.Workspace, 0, len(userWs)) - all = append(all, userWs...) - - // Also include team-owned workspaces - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) - for _, tid := range teamIDs { - teamWs, err := h.stores.Workspaces.ListByOwner(ctx, "team", tid) - if err == nil { - all = append(all, teamWs...) - } - } - - c.JSON(http.StatusOK, gin.H{"data": all}) -} - -func (h *WorkspaceHandler) Update(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - var req updateWorkspaceRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if err := h.stores.Workspaces.Update(c.Request.Context(), w.ID, req); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workspace"}) - return - } - - // Re-fetch to return the updated object - updated, err := h.stores.Workspaces.GetByID(c.Request.Context(), w.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload workspace"}) - return - } - c.JSON(http.StatusOK, updated) -} - -func (h *WorkspaceHandler) Delete(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Mark as deleting - status := models.WorkspaceStatusDeleting - h.stores.Workspaces.Update(c.Request.Context(), w.ID, models.WorkspacePatch{Status: &status}) - - // Async cleanup (use background context — request ctx will be cancelled) - go func() { - ctx := context.Background() - if err := h.wfs.Destroy(ctx, w); err != nil { - log.Printf("workspace destroy %s: %v", w.ID, err) - } - h.stores.Workspaces.Delete(ctx, w.ID) - log.Printf("workspace deleted: %s", w.ID) - }() - - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -// ── File Operations ───────────────────────── - -func (h *WorkspaceHandler) ListFiles(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - path := c.Query("path") - recursive := c.Query("recursive") == "true" - - files, err := h.wfs.ListDir(c.Request.Context(), w, path, recursive) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) - return - } - if files == nil { - files = []models.WorkspaceFile{} - } - c.JSON(http.StatusOK, gin.H{"data": files}) -} - -func (h *WorkspaceHandler) ReadFile(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - path := c.Query("path") - if path == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - rc, size, err := h.wfs.ReadFile(c.Request.Context(), w, path) - if err != nil { - if strings.Contains(err.Error(), "not found") { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } - return - } - defer rc.Close() - - // Detect content type for response header - f, _ := h.wfs.Stat(c.Request.Context(), w, path) - contentType := "application/octet-stream" - if f != nil && f.ContentType != "" { - contentType = f.ContentType - } - - c.Header("Content-Length", fmt.Sprintf("%d", size)) - c.DataFromReader(http.StatusOK, size, contentType, rc, nil) -} - -func (h *WorkspaceHandler) WriteFile(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - path := c.Query("path") - if path == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - // Quota check - stats, _ := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID) - quota := workspace.DefaultMaxBytes - if w.MaxBytes != nil { - quota = *w.MaxBytes - } - if stats != nil && stats.TotalBytes+c.Request.ContentLength > quota { - c.JSON(http.StatusRequestEntityTooLarge, gin.H{ - "error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota), - }) - return - } - - if err := h.wfs.WriteFile(c.Request.Context(), w, path, c.Request.Body, c.Request.ContentLength); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true, "path": path}) -} - -func (h *WorkspaceHandler) DeleteFileHandler(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - path := c.Query("path") - if path == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - recursive := c.Query("recursive") == "true" - - if err := h.wfs.DeleteFile(c.Request.Context(), w, path, recursive); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (h *WorkspaceHandler) Mkdir(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - path := c.Query("path") - if path == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) - return - } - - if err := h.wfs.Mkdir(c.Request.Context(), w, path); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, gin.H{"ok": true, "path": path}) -} - -// ── Archive Operations ────────────────────── - -func (h *WorkspaceHandler) UploadArchive(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - // Accept multipart file upload - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"}) - return - } - defer file.Close() - - // Detect format from filename - format := detectArchiveFormat(header.Filename) - if format == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"}) - return - } - - // Save to temp file (archive extraction needs seekable file for zip) - tmp, err := os.CreateTemp("", "ws-upload-*") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) - return - } - tmpName := tmp.Name() - defer os.Remove(tmpName) - - if _, err := io.Copy(tmp, file); err != nil { - tmp.Close() - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"}) - return - } - tmp.Close() - - count, err := h.wfs.ExtractArchive(c.Request.Context(), w, tmpName, format) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "ok": true, - "files_extracted": count, - }) -} - -func (h *WorkspaceHandler) DownloadArchive(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - format := c.DefaultQuery("format", "zip") - if format != "zip" && format != "tar.gz" && format != "tgz" { - c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"}) - return - } - - archivePath, err := h.wfs.CreateArchive(c.Request.Context(), w, format) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - defer os.Remove(archivePath) - - ext := "zip" - contentType := "application/zip" - if format == "tar.gz" || format == "tgz" { - ext = "tar.gz" - contentType = "application/gzip" - } - - filename := fmt.Sprintf("%s.%s", w.Name, ext) - c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) - c.File(archivePath) - _ = contentType // c.File sets the content type from the file -} - -// ── Reconcile + Stats ─────────────────────── - -func (h *WorkspaceHandler) Reconcile(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - added, removed, updated, err := h.wfs.Reconcile(c.Request.Context(), w) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "added": added, - "removed": removed, - "updated": updated, - }) -} - -func (h *WorkspaceHandler) Stats(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"}) - return - } - - c.JSON(http.StatusOK, stats) -} - -// IndexStatus returns indexing progress for all files in the workspace (v0.21.2). -func (h *WorkspaceHandler) IndexStatus(c *gin.Context) { - w, ok := h.loadAndAuthorize(c) - if !ok { - return - } - - ctx := c.Request.Context() - files, err := h.stores.Workspaces.ListFiles(ctx, w.ID, "", true) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) - return - } - - // Aggregate counts by status - counts := map[string]int{ - "pending": 0, "indexing": 0, "ready": 0, - "error": 0, "skipped": 0, - } - totalChunks := 0 - for _, f := range files { - if f.IsDirectory { - continue - } - status := f.IndexStatus - if status == "" { - status = "pending" - } - counts[status]++ - totalChunks += f.ChunkCount - } - - c.JSON(http.StatusOK, gin.H{ - "workspace_id": w.ID, - "indexing_enabled": w.IndexingEnabled, - "status_counts": counts, - "total_chunks": totalChunks, - }) -} - -// ── Authorization Helpers ─────────────────── - -// loadAndAuthorize loads a workspace by :id param and checks user access. -func (h *WorkspaceHandler) loadAndAuthorize(c *gin.Context) (*models.Workspace, bool) { - wsID := c.Param("id") - ctx := c.Request.Context() - - w, err := h.stores.Workspaces.GetByID(ctx, wsID) - if err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"}) - } - return nil, false - } - - if !h.canAccessOwner(c, w.OwnerType, w.OwnerID) { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) - return nil, false - } - - return w, true -} - -// canAccessOwner checks if the current user can access the workspace's owner entity. -func (h *WorkspaceHandler) canAccessOwner(c *gin.Context, ownerType, ownerID string) bool { - userID := getUserID(c) - ctx := c.Request.Context() - - switch ownerType { - case models.WorkspaceOwnerUser: - return ownerID == userID - - case models.WorkspaceOwnerChannel: - // User must own the channel - ch, err := h.stores.Channels.GetByID(ctx, ownerID) - if err != nil { - return false - } - return ch.UserID == userID - - case models.WorkspaceOwnerProject: - teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) - ok, _ := h.stores.Projects.UserCanAccess(ctx, userID, ownerID, teamIDs) - return ok - - case models.WorkspaceOwnerTeam: - // User must be a member of the team - _, err := h.stores.Teams.GetMember(ctx, ownerID, userID) - return err == nil - - default: - return false - } -} - -// ── Helpers ───────────────────────────────── - -// detectArchiveFormat returns "zip" or "tar.gz" based on filename, or "" if unsupported. -func detectArchiveFormat(filename string) string { - lower := strings.ToLower(filename) - if strings.HasSuffix(lower, ".zip") { - return "zip" - } - if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") { - return "tar.gz" - } - return "" -} diff --git a/server/knowledge/chunker.go b/server/knowledge/chunker.go deleted file mode 100644 index 1d47176..0000000 --- a/server/knowledge/chunker.go +++ /dev/null @@ -1,244 +0,0 @@ -package knowledge - -import ( - "strings" - "unicode/utf8" -) - -// ── Configuration ──────────────────────────── - -// ChunkConfig controls how text is split into chunks. -type ChunkConfig struct { - ChunkSize int // target chars per chunk (default 1000) - ChunkOverlap int // overlap chars between adjacent chunks (default 200) - Separators []string // split hierarchy, tried in order -} - -// DefaultChunkConfig returns sensible defaults for general documents. -// ~250 tokens per chunk at ~4 chars/token. -func DefaultChunkConfig() ChunkConfig { - return ChunkConfig{ - ChunkSize: 1000, - ChunkOverlap: 200, - Separators: []string{"\n\n", "\n", ". ", " "}, - } -} - -// ── Output ─────────────────────────────────── - -// Chunk is a single text segment with position metadata. -type Chunk struct { - Content string // chunk text - Index int // ordinal within document (0-based) - TokenCount int // estimated token count (~chars/4) - Metadata map[string]any // extensible: page, heading, etc. -} - -// ── Splitter ───────────────────────────────── - -// SplitText splits text into overlapping chunks using recursive -// character splitting. It tries separators in order, splitting on -// the first one that produces segments smaller than ChunkSize. -// Segments that are still too large are recursively split with the -// next separator. -func SplitText(text string, cfg ChunkConfig) []Chunk { - if cfg.ChunkSize <= 0 { - cfg.ChunkSize = 1000 - } - if cfg.ChunkOverlap < 0 { - cfg.ChunkOverlap = 0 - } - if cfg.ChunkOverlap >= cfg.ChunkSize { - cfg.ChunkOverlap = cfg.ChunkSize / 5 - } - if len(cfg.Separators) == 0 { - cfg.Separators = DefaultChunkConfig().Separators - } - - text = strings.TrimSpace(text) - if text == "" { - return nil - } - - // If text fits in one chunk, return it directly. - if utf8.RuneCountInString(text) <= cfg.ChunkSize { - return []Chunk{{ - Content: text, - Index: 0, - TokenCount: estimateTokens(text), - Metadata: map[string]any{}, - }} - } - - // Recursively split, then merge into overlapping windows. - segments := recursiveSplit(text, cfg.Separators, cfg.ChunkSize) - return mergeSegments(segments, cfg.ChunkSize, cfg.ChunkOverlap) -} - -// recursiveSplit tries each separator in order. For the first separator -// that actually splits the text, it checks if each piece fits within -// chunkSize. Pieces that are still too large recurse with the remaining -// separators. Pieces that fit are kept as-is. -func recursiveSplit(text string, separators []string, chunkSize int) []string { - if utf8.RuneCountInString(text) <= chunkSize { - return []string{text} - } - - if len(separators) == 0 { - // No separators left — hard split by character count. - return hardSplit(text, chunkSize) - } - - sep := separators[0] - rest := separators[1:] - - parts := splitKeepSep(text, sep) - if len(parts) <= 1 { - // This separator didn't split anything — try next. - return recursiveSplit(text, rest, chunkSize) - } - - var result []string - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if utf8.RuneCountInString(part) <= chunkSize { - result = append(result, part) - } else { - // Still too large — recurse with remaining separators. - result = append(result, recursiveSplit(part, rest, chunkSize)...) - } - } - return result -} - -// splitKeepSep splits text on sep. Unlike strings.Split, the separator -// is kept at the end of each segment (except the last) to preserve -// context like sentence-ending periods. -func splitKeepSep(text, sep string) []string { - parts := strings.Split(text, sep) - if len(parts) <= 1 { - return parts - } - - result := make([]string, 0, len(parts)) - for i, part := range parts { - if i < len(parts)-1 { - result = append(result, part+sep) - } else if part != "" { - result = append(result, part) - } - } - return result -} - -// hardSplit cuts text into pieces of at most chunkSize runes. -// Last resort when no separator works. -func hardSplit(text string, chunkSize int) []string { - runes := []rune(text) - var result []string - for i := 0; i < len(runes); i += chunkSize { - end := i + chunkSize - if end > len(runes) { - end = len(runes) - } - result = append(result, string(runes[i:end])) - } - return result -} - -// mergeSegments combines small segments into chunks up to chunkSize, -// then applies overlap between adjacent chunks. -func mergeSegments(segments []string, chunkSize, overlap int) []Chunk { - if len(segments) == 0 { - return nil - } - - // First pass: merge small adjacent segments into windows up to chunkSize. - var merged []string - var current strings.Builder - - for _, seg := range segments { - segLen := utf8.RuneCountInString(seg) - curLen := utf8.RuneCountInString(current.String()) - - if curLen > 0 && curLen+segLen > chunkSize { - // Flush current buffer. - merged = append(merged, strings.TrimSpace(current.String())) - current.Reset() - } - if current.Len() > 0 { - current.WriteString(" ") - } - current.WriteString(seg) - } - if current.Len() > 0 { - merged = append(merged, strings.TrimSpace(current.String())) - } - - if overlap <= 0 || len(merged) <= 1 { - // No overlap needed — convert directly to Chunks. - chunks := make([]Chunk, len(merged)) - for i, text := range merged { - chunks[i] = Chunk{ - Content: text, - Index: i, - TokenCount: estimateTokens(text), - Metadata: map[string]any{}, - } - } - return chunks - } - - // Second pass: create overlapping chunks. - // Each chunk includes up to `overlap` chars from the end of the - // previous chunk as prefix context. - chunks := make([]Chunk, 0, len(merged)) - for i, text := range merged { - if i > 0 { - prev := merged[i-1] - suffix := overlapSuffix(prev, overlap) - if suffix != "" { - text = suffix + " " + text - } - } - chunks = append(chunks, Chunk{ - Content: text, - Index: i, - TokenCount: estimateTokens(text), - Metadata: map[string]any{}, - }) - } - return chunks -} - -// overlapSuffix returns the last `n` characters of s, breaking at a -// word boundary to avoid splitting mid-word. -func overlapSuffix(s string, n int) string { - runes := []rune(s) - if len(runes) <= n { - return s - } - - start := len(runes) - n - suffix := string(runes[start:]) - - // Try to break at a space to avoid mid-word splits. - if idx := strings.Index(suffix, " "); idx >= 0 && idx < len(suffix)/2 { - suffix = suffix[idx+1:] - } - return suffix -} - -// estimateTokens gives a rough token count. Most tokenizers average -// ~4 characters per token for English text. -func estimateTokens(s string) int { - n := utf8.RuneCountInString(s) - tokens := n / 4 - if tokens == 0 && n > 0 { - tokens = 1 - } - return tokens -} diff --git a/server/knowledge/chunker_test.go b/server/knowledge/chunker_test.go deleted file mode 100644 index 044a958..0000000 --- a/server/knowledge/chunker_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package knowledge - -import ( - "strings" - "testing" - "unicode/utf8" -) - -func TestSplitText_EmptyInput(t *testing.T) { - chunks := SplitText("", DefaultChunkConfig()) - if chunks != nil { - t.Errorf("expected nil for empty input, got %d chunks", len(chunks)) - } -} - -func TestSplitText_WhitespaceOnly(t *testing.T) { - chunks := SplitText(" \n\n ", DefaultChunkConfig()) - if chunks != nil { - t.Errorf("expected nil for whitespace input, got %d chunks", len(chunks)) - } -} - -func TestSplitText_SmallText(t *testing.T) { - text := "Hello, world." - chunks := SplitText(text, DefaultChunkConfig()) - if len(chunks) != 1 { - t.Fatalf("expected 1 chunk, got %d", len(chunks)) - } - if chunks[0].Content != text { - t.Errorf("content mismatch: %q", chunks[0].Content) - } - if chunks[0].Index != 0 { - t.Errorf("expected index 0, got %d", chunks[0].Index) - } - if chunks[0].TokenCount <= 0 { - t.Error("expected positive token count") - } -} - -func TestSplitText_ParagraphSplit(t *testing.T) { - // Two paragraphs, each under chunk size, but together over. - para := strings.Repeat("word ", 60) // ~300 chars each - text := para + "\n\n" + para - - cfg := ChunkConfig{ChunkSize: 400, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}} - chunks := SplitText(text, cfg) - - if len(chunks) < 2 { - t.Fatalf("expected at least 2 chunks from paragraph split, got %d", len(chunks)) - } - - for i, c := range chunks { - if utf8.RuneCountInString(c.Content) == 0 { - t.Errorf("chunk %d is empty", i) - } - } -} - -func TestSplitText_OverlapPresent(t *testing.T) { - // Build text that will split into multiple chunks. - sentences := make([]string, 20) - for i := range sentences { - sentences[i] = "This is sentence number " + strings.Repeat("x", 40) + ". " - } - text := strings.Join(sentences, "") - - cfg := ChunkConfig{ChunkSize: 200, ChunkOverlap: 50, Separators: []string{". ", " "}} - chunks := SplitText(text, cfg) - - if len(chunks) < 3 { - t.Fatalf("expected at least 3 chunks, got %d", len(chunks)) - } - - // Verify chunks have sequential indexes. - for i, c := range chunks { - if c.Index != i { - t.Errorf("chunk %d has index %d", i, c.Index) - } - } - - // With overlap > 0 and multiple chunks, later chunks should share - // some content with the previous chunk's tail. - // Just verify the overlap logic ran (chunks 1+ are longer or contain - // content from the previous chunk's ending). - if len(chunks) >= 2 { - // Chunk 1 should contain some overlap from chunk 0's tail. - // We can't check exact content easily, but the chunk should be - // non-trivially sized. - if len(chunks[1].Content) < 50 { - t.Errorf("chunk 1 seems too short for overlap: %d chars", len(chunks[1].Content)) - } - } -} - -func TestSplitText_ZeroOverlap(t *testing.T) { - text := strings.Repeat("A", 500) + "\n\n" + strings.Repeat("B", 500) - cfg := ChunkConfig{ChunkSize: 400, ChunkOverlap: 0, Separators: []string{"\n\n"}} - chunks := SplitText(text, cfg) - - if len(chunks) < 2 { - t.Fatalf("expected at least 2 chunks, got %d", len(chunks)) - } - - // Without overlap, chunks should not share content. - for i, c := range chunks { - if c.Index != i { - t.Errorf("chunk %d index = %d", i, c.Index) - } - } -} - -func TestSplitText_HardSplitFallback(t *testing.T) { - // No separators at all in the text — forces hard character split. - text := strings.Repeat("x", 500) - cfg := ChunkConfig{ChunkSize: 200, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}} - chunks := SplitText(text, cfg) - - if len(chunks) < 2 { - t.Fatalf("expected multiple chunks from hard split, got %d", len(chunks)) - } - - // All chunks should be at most chunkSize. - for i, c := range chunks { - if utf8.RuneCountInString(c.Content) > cfg.ChunkSize { - t.Errorf("chunk %d exceeds chunk size: %d > %d", - i, utf8.RuneCountInString(c.Content), cfg.ChunkSize) - } - } -} - -func TestSplitText_TokenEstimate(t *testing.T) { - text := strings.Repeat("word ", 100) // 500 chars - chunks := SplitText(text, ChunkConfig{ChunkSize: 2000, ChunkOverlap: 0}) - - if len(chunks) != 1 { - t.Fatalf("expected 1 chunk, got %d", len(chunks)) - } - - // ~500 chars / 4 = ~125 tokens - if chunks[0].TokenCount < 100 || chunks[0].TokenCount > 150 { - t.Errorf("expected ~125 tokens, got %d", chunks[0].TokenCount) - } -} - -func TestSplitText_InvalidConfig(t *testing.T) { - text := "Hello world. This is a test." - - // ChunkSize 0 should use default. - chunks := SplitText(text, ChunkConfig{ChunkSize: 0}) - if len(chunks) != 1 { - t.Errorf("expected 1 chunk with zero chunk size (uses default), got %d", len(chunks)) - } - - // Overlap >= ChunkSize should be clamped. - chunks = SplitText(strings.Repeat("word ", 200), ChunkConfig{ - ChunkSize: 100, - ChunkOverlap: 100, - }) - if len(chunks) == 0 { - t.Error("expected chunks with overlap == chunk_size (should clamp)") - } -} - -func TestSplitText_SentenceSplit(t *testing.T) { - text := "First sentence. Second sentence. Third sentence. Fourth sentence. Fifth sentence." - cfg := ChunkConfig{ChunkSize: 40, ChunkOverlap: 0, Separators: []string{"\n\n", "\n", ". ", " "}} - chunks := SplitText(text, cfg) - - if len(chunks) < 2 { - t.Fatalf("expected multiple sentence-split chunks, got %d", len(chunks)) - } - - // Each chunk should be within bounds. - for i, c := range chunks { - if utf8.RuneCountInString(c.Content) == 0 { - t.Errorf("chunk %d is empty", i) - } - } -} - -func TestSplitText_MetadataInitialized(t *testing.T) { - chunks := SplitText("Some text here.", DefaultChunkConfig()) - if len(chunks) != 1 { - t.Fatal("expected 1 chunk") - } - if chunks[0].Metadata == nil { - t.Error("metadata should be initialized, got nil") - } -} - -func TestEstimateTokens(t *testing.T) { - cases := []struct { - input string - minTok int - maxTok int - }{ - {"", 0, 0}, - {"hi", 1, 1}, - {"hello world", 2, 4}, - {strings.Repeat("a", 100), 20, 30}, - } - for _, tc := range cases { - got := estimateTokens(tc.input) - if got < tc.minTok || got > tc.maxTok { - t.Errorf("estimateTokens(%q): got %d, want [%d, %d]", tc.input, got, tc.minTok, tc.maxTok) - } - } -} - -func TestOverlapSuffix(t *testing.T) { - s := "the quick brown fox jumps over the lazy dog" - - // Should return last ~20 chars, breaking at word boundary. - suffix := overlapSuffix(s, 20) - if len(suffix) == 0 { - t.Error("expected non-empty suffix") - } - if len(suffix) > 25 { // some slack for word boundary adjustment - t.Errorf("suffix too long: %d chars: %q", len(suffix), suffix) - } - - // Short string: return whole thing. - short := "hello" - if got := overlapSuffix(short, 100); got != short { - t.Errorf("expected %q, got %q", short, got) - } -} diff --git a/server/knowledge/embedder.go b/server/knowledge/embedder.go deleted file mode 100644 index 48e9a7a..0000000 --- a/server/knowledge/embedder.go +++ /dev/null @@ -1,184 +0,0 @@ -package knowledge - -import ( - "context" - "fmt" - "log" - - "switchboard-core/models" - "switchboard-core/roles" - "switchboard-core/store" -) - -// ── Configuration ──────────────────────────── - -const ( - // DefaultBatchSize is the max chunks per embedding API call. - // Most providers handle 100 inputs per request comfortably. - DefaultBatchSize = 100 - - // MaxVectorDim is the column width in pgvector (vector(3072)). - // Vectors shorter than this are zero-padded on storage. - MaxVectorDim = 3072 -) - -// ── Embedder ───────────────────────────────── - -// Embedder generates vector embeddings for text chunks using the -// embedding role resolver. It handles batching and dimension -// normalization (zero-padding to MaxVectorDim). -type Embedder struct { - resolver *roles.Resolver - stores store.Stores // optional — for usage tracking - batchSize int -} - -// NewEmbedder creates an embedder that dispatches through the role resolver. -func NewEmbedder(resolver *roles.Resolver) *Embedder { - return &Embedder{ - resolver: resolver, - batchSize: DefaultBatchSize, - } -} - -// WithStores attaches stores for usage tracking. Returns self for chaining. -func (e *Embedder) WithStores(s store.Stores) *Embedder { - e.stores = s - return e -} - -// EmbedResult holds the output of a batch embedding operation. -type EmbedResult struct { - Vectors [][]float64 // one per input chunk, zero-padded to MaxVectorDim - Model string // model that produced the embeddings - Dimensions int // native dimension before padding - InputTokens int // total tokens consumed across all batches - ConfigID string // provider config that was used - ProviderScope string // "personal", "team", or "global" -} - -// EmbedChunks generates embeddings for a slice of text chunks. -// Chunks are batched in groups of batchSize to avoid provider limits. -// Uses the embedding role resolution chain: personal → team → global. -// -// Returns vectors in the same order as input chunks. -func (e *Embedder) EmbedChunks(ctx context.Context, userID string, teamID *string, texts []string) (*EmbedResult, error) { - if len(texts) == 0 { - return &EmbedResult{Vectors: [][]float64{}}, nil - } - - // Verify embedding role is configured before starting - cfg, err := e.resolver.GetConfig(ctx, roles.RoleEmbedding, userID, teamID) - if err != nil { - return nil, fmt.Errorf("embedding role not configured: %w", err) - } - if cfg.Primary == nil { - return nil, fmt.Errorf("embedding role has no primary binding") - } - - allVectors := make([][]float64, 0, len(texts)) - var model string - var nativeDim int - var configID, provScope string - totalTokens := 0 - - for i := 0; i < len(texts); i += e.batchSize { - end := i + e.batchSize - if end > len(texts) { - end = len(texts) - } - batch := texts[i:end] - - log.Printf(" embed batch %d/%d (%d chunks)", - (i/e.batchSize)+1, (len(texts)+e.batchSize-1)/e.batchSize, len(batch)) - - result, err := e.resolver.Embed(ctx, roles.RoleEmbedding, userID, teamID, batch) - if err != nil { - return nil, fmt.Errorf("embed batch starting at chunk %d: %w", i, err) - } - - if len(result.Embeddings) != len(batch) { - return nil, fmt.Errorf("embed batch %d: expected %d vectors, got %d", - i/e.batchSize, len(batch), len(result.Embeddings)) - } - - // Capture model info from first batch - if model == "" { - model = result.Model - configID = result.ConfigID - provScope = result.ProviderScope - if len(result.Embeddings) > 0 { - nativeDim = len(result.Embeddings[0]) - } - } - - totalTokens += result.InputTokens - allVectors = append(allVectors, result.Embeddings...) - } - - // Zero-pad to MaxVectorDim for uniform storage - for i, vec := range allVectors { - allVectors[i] = padVector(vec, MaxVectorDim) - } - - return &EmbedResult{ - Vectors: allVectors, - Model: model, - Dimensions: nativeDim, - InputTokens: totalTokens, - ConfigID: configID, - ProviderScope: provScope, - }, nil -} - -// LogUsage records an embedding usage entry. Silently no-ops if stores -// were not attached via WithStores or if there are no tokens to log. -func (e *Embedder) LogUsage(ctx context.Context, userID string, channelID *string, result *EmbedResult) { - if e.stores.Usage == nil || result == nil || result.InputTokens == 0 { - return - } - - role := "embedding" - entry := &models.UsageEntry{ - ChannelID: channelID, - UserID: userID, - ProviderConfigID: strPtr(result.ConfigID), - ProviderScope: result.ProviderScope, - ModelID: result.Model, - Role: &role, - PromptTokens: result.InputTokens, - CompletionTokens: 0, - } - - if err := e.stores.Usage.Log(ctx, entry); err != nil { - log.Printf("⚠ Failed to log embedding usage: %v", err) - } -} - -// IsConfigured returns true if the embedding role has at least a primary binding. -func (e *Embedder) IsConfigured(ctx context.Context) bool { - return e.resolver.IsConfigured(ctx, roles.RoleEmbedding) -} - -// ── Helpers ────────────────────────────────── - -// padVector zero-pads a vector to the target dimension. -// If the vector is already the target size or larger, it's truncated. -func padVector(vec []float64, targetDim int) []float64 { - if len(vec) == targetDim { - return vec - } - if len(vec) > targetDim { - return vec[:targetDim] - } - padded := make([]float64, targetDim) - copy(padded, vec) - return padded -} - -func strPtr(s string) *string { - if s == "" { - return nil - } - return &s -} diff --git a/server/knowledge/ingest.go b/server/knowledge/ingest.go deleted file mode 100644 index b439c3f..0000000 --- a/server/knowledge/ingest.go +++ /dev/null @@ -1,268 +0,0 @@ -package knowledge - -import ( - "context" - "fmt" - "io" - "log" - "strings" - "sync" - "time" - - "switchboard-core/models" - "switchboard-core/notifications" - "switchboard-core/storage" - "switchboard-core/store" -) - -// ── Configuration ──────────────────────────── - -const ( - // DefaultConcurrency limits parallel ingestion goroutines. - DefaultConcurrency = 3 - - // MaxTextLength is the upper bound for extracted text (10 MB). - // Documents larger than this are truncated with a warning. - MaxTextLength = 10 * 1024 * 1024 - - // contextTimeout for the entire ingestion of one document. - ingestTimeout = 10 * time.Minute -) - -// textMIMETypes are content types we can extract text from directly. -var textMIMETypes = map[string]bool{ - "text/plain": true, - "text/markdown": true, - "text/csv": true, - "text/html": true, -} - -// ── Ingester ───────────────────────────────── - -// Ingester manages the document ingestion pipeline. -// It runs chunk + embed as background goroutines with a concurrency -// semaphore to avoid overwhelming the embedding provider. -type Ingester struct { - stores store.Stores - embedder *Embedder - objStore storage.ObjectStore - sem chan struct{} - wg sync.WaitGroup -} - -// NewIngester creates an ingestion pipeline. -func NewIngester(stores store.Stores, embedder *Embedder, objStore storage.ObjectStore, concurrency int) *Ingester { - if concurrency <= 0 { - concurrency = DefaultConcurrency - } - return &Ingester{ - stores: stores, - embedder: embedder, - objStore: objStore, - sem: make(chan struct{}, concurrency), - } -} - -// IngestDocument starts async ingestion for a document. -// The document must already exist in kb_documents with status "pending" -// and the file must be stored at doc.StorageKey in the object store. -// -// The goroutine progresses through: pending → extracting → chunking → -// embedding → ready (or → error on failure). -func (ing *Ingester) IngestDocument(kb *models.KnowledgeBase, doc *models.KBDocument, userID string, teamID *string) { - ing.wg.Add(1) - go func() { - defer ing.wg.Done() - - // Acquire semaphore slot - ing.sem <- struct{}{} - defer func() { <-ing.sem }() - - ctx, cancel := context.WithTimeout(context.Background(), ingestTimeout) - defer cancel() - - if err := ing.ingest(ctx, kb, doc, userID, teamID); err != nil { - errMsg := err.Error() - log.Printf("❌ ingest doc %s (%s): %v", doc.ID, doc.Filename, err) - ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "error", &errMsg) - ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"}) - // Notify document owner of ingestion failure (v0.20.0) - if svc := notifications.Default(); svc != nil { - notifications.NotifyKBError(svc, userID, kb.ID, kb.Name, errMsg) - } - } - }() -} - -// Wait blocks until all in-flight ingestion goroutines complete. -// Call during graceful shutdown. -func (ing *Ingester) Wait() { - ing.wg.Wait() -} - -// ── Internal Pipeline ──────────────────────── - -func (ing *Ingester) ingest(ctx context.Context, kb *models.KnowledgeBase, doc *models.KBDocument, userID string, teamID *string) error { - start := time.Now() - log.Printf("▶ ingest %s (%s, %d bytes)", doc.Filename, doc.ContentType, doc.SizeBytes) - - // Mark KB as processing - ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "processing"}) - - // ── Step 1: Extract text ──────────────── - ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "extracting", nil) - - text, err := ing.extractText(ctx, doc) - if err != nil { - return fmt.Errorf("extract: %w", err) - } - - if strings.TrimSpace(text) == "" { - return fmt.Errorf("extract: no text content found in %s", doc.Filename) - } - - // Truncate very large documents - if len(text) > MaxTextLength { - log.Printf(" ⚠ truncating %s from %d to %d chars", doc.Filename, len(text), MaxTextLength) - text = text[:MaxTextLength] - } - - // Store extracted text for potential re-chunking later - if err := ing.stores.KnowledgeBases.UpdateDocumentText(ctx, doc.ID, text, 0); err != nil { - log.Printf(" ⚠ failed to store extracted text for %s: %v", doc.ID, err) - } - - // ── Step 2: Chunk ─────────────────────── - ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "chunking", nil) - - cfg := DefaultChunkConfig() - chunks := SplitText(text, cfg) - if len(chunks) == 0 { - return fmt.Errorf("chunk: produced zero chunks from %s", doc.Filename) - } - - log.Printf(" ✓ chunked %s → %d chunks", doc.Filename, len(chunks)) - - // ── Step 3: Embed ─────────────────────── - ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "embedding", nil) - - texts := make([]string, len(chunks)) - for i, c := range chunks { - texts[i] = c.Content - } - - embedResult, err := ing.embedder.EmbedChunks(ctx, userID, teamID, texts) - if err != nil { - return fmt.Errorf("embed: %w", err) - } - - log.Printf(" ✓ embedded %d chunks (model=%s, dim=%d, tokens=%d)", - len(chunks), embedResult.Model, embedResult.Dimensions, embedResult.InputTokens) - - // Track embedding usage (role = "embedding") - ing.embedder.LogUsage(ctx, userID, nil, embedResult) - - // ── Step 4: Store chunks with vectors ─── - dbChunks := make([]models.KBChunk, len(chunks)) - for i, c := range chunks { - dbChunks[i] = models.KBChunk{ - KBID: kb.ID, - DocumentID: doc.ID, - ChunkIndex: c.Index, - Content: c.Content, - TokenCount: c.TokenCount, - Embedding: embedResult.Vectors[i], - Metadata: models.JSONMap{ - "char_start": i * (cfg.ChunkSize - cfg.ChunkOverlap), // approximate - }, - } - } - - if err := ing.stores.KnowledgeBases.InsertChunks(ctx, dbChunks); err != nil { - return fmt.Errorf("store chunks: %w", err) - } - - // ── Step 5: Update stats ──────────────── - // Update document status + chunk count - if err := ing.stores.KnowledgeBases.UpdateDocumentText(ctx, doc.ID, text, len(chunks)); err != nil { - log.Printf(" ⚠ failed to update chunk count for %s: %v", doc.ID, err) - } - ing.stores.KnowledgeBases.UpdateDocumentStatus(ctx, doc.ID, "ready", nil) - - // Update KB-level stats - if err := ing.stores.KnowledgeBases.UpdateStats(ctx, kb.ID); err != nil { - log.Printf(" ⚠ failed to update KB stats: %v", err) - } - - // Store embedding model info in KB config if not set - if kb.EmbeddingConfig == nil || kb.EmbeddingConfig["model"] == nil { - ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{ - "embedding_config": models.JSONMap{ - "model": embedResult.Model, - "dimensions": embedResult.Dimensions, - }, - }) - } - - // Mark KB as active - ing.stores.KnowledgeBases.Update(ctx, kb.ID, map[string]interface{}{"status": "active"}) - - log.Printf(" ✓ ingest complete: %s (%d chunks, %s)", - doc.Filename, len(chunks), time.Since(start).Round(time.Millisecond)) - - // Notify document owner of successful ingestion (v0.20.0) - if svc := notifications.Default(); svc != nil { - notifications.NotifyKBReady(svc, userID, kb.ID, kb.Name, len(chunks)) - } - - return nil -} - -// ── Text Extraction ────────────────────────── - -// extractText reads the document file and returns its text content. -// For text-based files, reads directly from object store. -// For complex formats (PDF, docx), returns an error — the extraction -// sidecar integration is planned for a future phase. -func (ing *Ingester) extractText(ctx context.Context, doc *models.KBDocument) (string, error) { - // If extracted text was already stored (e.g. from a rebuild), use it - if doc.ExtractedText != nil && *doc.ExtractedText != "" { - return *doc.ExtractedText, nil - } - - // Text-based files: read directly - if textMIMETypes[doc.ContentType] { - return ing.readTextFile(ctx, doc.StorageKey) - } - - // Complex document types are not yet supported for inline extraction. - // The extraction sidecar (v0.12.0) handles files but isn't yet - // wired into the KB pipeline. Planned for a future phase. - return "", fmt.Errorf("unsupported content type %q — text-based files only for now (txt, md, csv, html)", doc.ContentType) -} - -// readTextFile reads a text file from the object store and returns its contents. -func (ing *Ingester) readTextFile(ctx context.Context, storageKey string) (string, error) { - rc, size, _, err := ing.objStore.Get(ctx, storageKey) - if err != nil { - return "", fmt.Errorf("read %s: %w", storageKey, err) - } - defer rc.Close() - - // Guard against absurdly large files - if size > MaxTextLength+1024 { - // Read up to limit - limited := io.LimitReader(rc, MaxTextLength) - data, err := io.ReadAll(limited) - if err != nil { - return "", fmt.Errorf("read %s: %w", storageKey, err) - } - return string(data), nil - } - - data, err := io.ReadAll(rc) - if err != nil { - return "", fmt.Errorf("read %s: %w", storageKey, err) - } - return string(data), nil -} diff --git a/server/memory/compactor.go b/server/memory/compactor.go deleted file mode 100644 index d62b8e8..0000000 --- a/server/memory/compactor.go +++ /dev/null @@ -1,157 +0,0 @@ -package memory - -import ( - "context" - "encoding/json" - "fmt" - "log" - "strings" - - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/roles" - "switchboard-core/store" -) - -// compactionThreshold is the minimum number of active memories before -// compaction is considered worthwhile for a user. -const compactionThreshold = 30 - -// compactionPrompt instructs the utility model to merge related memories. -const compactionPrompt = `You are a memory compaction assistant. Given a set of user memories (key-value facts), merge related or redundant entries into fewer, more precise facts. - -Rules: -- Combine facts about the same topic into a single entry -- Remove duplicates, keeping the most specific/recent version -- Preserve all unique information — do not drop facts -- Keep keys short (2-5 words, lowercase) -- Keep values concise (1-2 sentences max) -- Set confidence based on how well-supported the merged fact is - -Respond ONLY with a JSON array of merged facts: -[{"key": "...", "value": "...", "confidence": 0.0-1.0}] - -If no merging is possible, return the original facts unchanged.` - -// Compactor merges related memories to reduce count and improve quality. -type Compactor struct { - stores store.Stores - roleResolver *roles.Resolver -} - -// NewCompactor creates a memory compaction service. -func NewCompactor(stores store.Stores, rr *roles.Resolver) *Compactor { - return &Compactor{stores: stores, roleResolver: rr} -} - -// CompactResult holds the outcome of a compaction run. -type CompactResult struct { - Before int `json:"before"` - After int `json:"after"` - Merged int `json:"merged"` - Archived int `json:"archived"` -} - -// Compact loads active memories for a user, sends them to the utility model -// for merging, then upserts merged entries and archives originals. -func (c *Compactor) Compact(ctx context.Context, userID string, teamID *string) (*CompactResult, error) { - if c.stores.Memories == nil { - return nil, fmt.Errorf("memory store not available") - } - - // Load all active user-scope memories - memories, err := c.stores.Memories.List(ctx, models.MemoryFilter{ - Scope: models.MemoryScopeUser, - OwnerID: userID, - Status: models.MemoryStatusActive, - Limit: 500, - }) - if err != nil { - return nil, fmt.Errorf("list memories: %w", err) - } - - if len(memories) < compactionThreshold { - return &CompactResult{Before: len(memories), After: len(memories)}, nil - } - - // Build memory text for the model - var sb strings.Builder - for i, m := range memories { - sb.WriteString(fmt.Sprintf("%d. [%s] %s (confidence: %.2f)\n", i+1, m.Key, m.Value, m.Confidence)) - } - - apiMessages := []providers.Message{ - {Role: "system", Content: compactionPrompt}, - {Role: "user", Content: fmt.Sprintf("Compact these %d memories:\n\n%s", len(memories), sb.String())}, - } - - result, err := c.roleResolver.Complete(ctx, "utility", userID, teamID, apiMessages) - if err != nil { - return nil, fmt.Errorf("compaction completion: %w", err) - } - - // Parse merged facts - merged, err := parseExtractionResponse(result.Content) - if err != nil { - return nil, fmt.Errorf("parse compaction response: %w", err) - } - - if len(merged) == 0 || len(merged) >= len(memories) { - // Model returned nothing useful or didn't reduce — skip - log.Printf("🧠 memory compaction: user %s — model returned %d facts (was %d), skipping", - userID, len(merged), len(memories)) - return &CompactResult{Before: len(memories), After: len(memories)}, nil - } - - // Archive all original memories - archived := 0 - for _, m := range memories { - if err := c.stores.Memories.Archive(ctx, m.ID); err == nil { - archived++ - } - } - - // Upsert merged memories - for _, fact := range merged { - mem := &models.Memory{ - Scope: models.MemoryScopeUser, - OwnerID: userID, - Key: fact.Key, - Value: fact.Value, - Confidence: fact.Confidence, - Status: models.MemoryStatusActive, - } - if err := c.stores.Memories.Upsert(ctx, mem); err != nil { - log.Printf("⚠ memory compaction upsert failed: key=%s: %v", fact.Key, err) - } - } - - log.Printf("🧠 memory compaction: user %s — %d → %d memories (archived %d)", - userID, len(memories), len(merged), archived) - - return &CompactResult{ - Before: len(memories), - After: len(merged), - Merged: len(merged), - Archived: archived, - }, nil -} - -// parseCompactionResponse parses the model's JSON array response. -// Reuses the same extractedFact struct from extractor.go. -func parseCompactionResponse(content string) ([]extractedFact, error) { - content = strings.TrimSpace(content) - // Strip markdown code fences if present - if strings.HasPrefix(content, "```") { - lines := strings.Split(content, "\n") - if len(lines) > 2 { - content = strings.Join(lines[1:len(lines)-1], "\n") - } - } - - var facts []extractedFact - if err := json.Unmarshal([]byte(content), &facts); err != nil { - return nil, err - } - return facts, nil -} diff --git a/server/memory/extractor.go b/server/memory/extractor.go deleted file mode 100644 index e0cf65e..0000000 --- a/server/memory/extractor.go +++ /dev/null @@ -1,242 +0,0 @@ -package memory - -import ( - "context" - "encoding/json" - "fmt" - "log" - "strings" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/notifications" - "switchboard-core/providers" - "switchboard-core/roles" - "switchboard-core/store" -) - -// defaultExtractionPrompt is used when the persona has no custom prompt. -const defaultExtractionPrompt = `You are a memory extraction assistant. Analyze the following conversation and extract memorable facts about the user. - -Focus on: -- Personal preferences (language, tools, frameworks, communication style) -- Technical details (deployment stack, database choices, architecture patterns) -- Project context (project names, team size, deadlines, goals) -- Biographical facts (role, company, location, experience level) - -Respond ONLY with a JSON array of extracted facts. Each fact must have: -- "key": short label (2-5 words, lowercase) -- "value": the detail/fact (1-2 sentences max) -- "confidence": 0.0-1.0 how certain you are - -Example: -[ - {"key": "preferred language", "value": "Go with Gin framework for backend services", "confidence": 0.95}, - {"key": "deployment target", "value": "Kubernetes on AWS with PostgreSQL databases", "confidence": 0.8} -] - -If no memorable facts are found, respond with an empty array: []` - -// maxConversationChars limits the conversation text sent for extraction. -const maxConversationChars = 30000 - -// minMessagesForExtraction is the minimum new messages before extraction triggers. -const minMessagesForExtraction = 6 - -// extractedFact is the JSON structure returned by the utility model. -type extractedFact struct { - Key string `json:"key"` - Value string `json:"value"` - Confidence float64 `json:"confidence"` -} - -// Extractor analyzes conversations and extracts memorable facts. -type Extractor struct { - stores store.Stores - roleResolver *roles.Resolver - embedder *knowledge.Embedder -} - -// NewExtractor creates a new memory extraction service. -func NewExtractor(stores store.Stores, rr *roles.Resolver, embedder *knowledge.Embedder) *Extractor { - return &Extractor{stores: stores, roleResolver: rr, embedder: embedder} -} - -// Extract analyzes a conversation and saves extracted facts as pending_review memories. -func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, personaID string) error { - if e.stores.Memories == nil || e.stores.Messages == nil { - return fmt.Errorf("memory or message store not available") - } - - // Check extraction log for last processed message - lastMessageID, _ := e.stores.Memories.GetLastExtractionMessageID(ctx, channelID, userID) - - // Load recent messages for this channel - messages, err := e.stores.Messages.ListForChannel(ctx, channelID, store.ListOptions{Limit: 200}) - if err != nil { - return fmt.Errorf("load messages: %w", err) - } - - // Filter to new messages only (messages come back newest-first) - var newMessages []models.Message - for i := len(messages) - 1; i >= 0; i-- { - if lastMessageID == "" || messages[i].ID > lastMessageID { - newMessages = append(newMessages, messages[i]) - } - } - - if len(newMessages) < minMessagesForExtraction { - return nil // not enough new content - } - - // Build conversation text - var sb strings.Builder - for _, m := range newMessages { - role := m.Role - if role == "user" { - role = "User" - } else { - role = "Assistant" - } - line := fmt.Sprintf("[%s]: %s\n", role, m.Content) - if sb.Len()+len(line) > maxConversationChars { - break - } - sb.WriteString(line) - } - - if sb.Len() < 200 { - return nil // too little content - } - - // Get extraction prompt (persona-specific or default) - prompt := defaultExtractionPrompt - if personaID != "" && e.stores.Personas != nil { - persona, err := e.stores.Personas.GetByID(ctx, personaID) - if err == nil { - // Respect persona memory_enabled flag (H6 — audit v0.28.0) - if !persona.MemoryEnabled { - return nil - } - if persona.MemoryExtractionPrompt != nil && *persona.MemoryExtractionPrompt != "" { - prompt = *persona.MemoryExtractionPrompt - } - } - } - - // Call utility model via role resolver - var tID *string - if teamID != "" { - tID = &teamID - } - - apiMessages := []providers.Message{ - {Role: "system", Content: prompt}, - {Role: "user", Content: "Analyze this conversation and extract memorable facts:\n\n" + sb.String()}, - } - - result, err := e.roleResolver.Complete(ctx, "utility", userID, tID, apiMessages) - if err != nil { - return fmt.Errorf("extraction completion: %w", err) - } - - // Parse response - facts, err := parseExtractionResponse(result.Content) - if err != nil { - log.Printf("⚠ memory extraction parse failed for channel %s: %v", channelID, err) - return nil // don't fail on parse errors - } - - if len(facts) == 0 { - log.Printf("🧠 memory extraction: channel %s → 0 facts (nothing memorable)", channelID) - } - - // Determine scope - scope := models.MemoryScopeUser - ownerID := userID - var memUserID *string - if personaID != "" { - scope = models.MemoryScopePersonaUser - ownerID = personaID - memUserID = &userID - } - - // Save extracted facts - saved := 0 - for _, f := range facts { - if f.Key == "" || f.Value == "" || f.Confidence < 0.3 { - continue - } - - mem := &models.Memory{ - ID: store.NewID(), - Scope: scope, - OwnerID: ownerID, - UserID: memUserID, - Key: f.Key, - Value: f.Value, - SourceChannelID: &channelID, - Confidence: f.Confidence, - Status: models.MemoryStatusPendingReview, - } - - if err := e.stores.Memories.Upsert(ctx, mem); err != nil { - log.Printf("⚠ memory save failed: %v", err) - continue - } - - // Embed if available - e.embedMemory(ctx, mem, userID, tID) - saved++ - } - - // Update extraction log - latestID := newMessages[len(newMessages)-1].ID - err = e.stores.Memories.UpsertExtractionLog(ctx, channelID, userID, latestID, saved) - - if saved > 0 { - log.Printf("✅ memory extraction: channel %s → %d facts", channelID, saved) - - // Notify user about extracted memories (v0.28.2) - if svc := notifications.Default(); svc != nil { - notifications.NotifyMemoryExtracted(svc, userID, channelID, saved) - } - } - return err -} - -// embedMemory generates and stores an embedding vector for a memory. -func (e *Extractor) embedMemory(ctx context.Context, m *models.Memory, userID string, teamID *string) { - if e.embedder == nil || !e.embedder.IsConfigured(ctx) { - return - } - - text := m.Key + ": " + m.Value - result, err := e.embedder.EmbedChunks(ctx, userID, teamID, []string{text}) - if err != nil || len(result.Vectors) == 0 { - return - } - - vecJSON, _ := json.Marshal(result.Vectors[0]) - _ = e.stores.Memories.SetEmbedding(ctx, m.ID, string(vecJSON)) -} - -// parseExtractionResponse parses the utility model's JSON response. -func parseExtractionResponse(content string) ([]extractedFact, error) { - content = strings.TrimSpace(content) - - // Strip markdown code fences - if strings.HasPrefix(content, "```") { - lines := strings.Split(content, "\n") - if len(lines) > 2 { - lines = lines[1 : len(lines)-1] - content = strings.Join(lines, "\n") - } - } - - var facts []extractedFact - if err := json.Unmarshal([]byte(content), &facts); err != nil { - return nil, fmt.Errorf("parse facts JSON: %w", err) - } - return facts, nil -} diff --git a/server/memory/scanner.go b/server/memory/scanner.go deleted file mode 100644 index f3b9afb..0000000 --- a/server/memory/scanner.go +++ /dev/null @@ -1,253 +0,0 @@ -package memory - -import ( - "context" - "log" - "sync" - "time" - - "switchboard-core/database" - "switchboard-core/store" -) - -// ScannerConfig holds tunable parameters for the background scanner. -type ScannerConfig struct { - Interval time.Duration // tick interval (default 10m) - Concurrency int // max parallel extractions (default 2) -} - -// Scanner periodically finds conversations needing extraction. -type Scanner struct { - extractor *Extractor - stores store.Stores - cfg ScannerConfig - stopCh chan struct{} - wg sync.WaitGroup - inFlight sync.Map // channelID+userID → true -} - -// NewScanner creates a background extraction scanner. -func NewScanner(ext *Extractor, stores store.Stores, cfg ScannerConfig) *Scanner { - if cfg.Interval <= 0 { - cfg.Interval = 10 * time.Minute - } - if cfg.Concurrency <= 0 { - cfg.Concurrency = 2 - } - return &Scanner{ - extractor: ext, - stores: stores, - cfg: cfg, - stopCh: make(chan struct{}), - } -} - -// Start begins the background scanning loop. -func (s *Scanner) Start() { - s.wg.Add(1) - go func() { - defer s.wg.Done() - log.Printf("🧠 memory extraction scanner started (interval=%s, concurrency=%d)", - s.cfg.Interval, s.cfg.Concurrency) - - ticker := time.NewTicker(s.cfg.Interval) - defer ticker.Stop() - - for { - select { - case <-s.stopCh: - log.Println("🧠 memory extraction scanner stopped") - return - case <-ticker.C: - s.scan() - } - } - }() -} - -// Stop gracefully shuts down the scanner. -func (s *Scanner) Stop() { - close(s.stopCh) - s.wg.Wait() -} - -// candidate represents a conversation that may need extraction. -type candidate struct { - ChannelID string - UserID string - TeamID string - PersonaID string // non-empty if a persona is active on the channel -} - -func (s *Scanner) scan() { - ctx := context.Background() - - // Check global kill switch. - // Default: ENABLED. Only disable if the key exists and is explicitly false. - // This inverts the previous behavior where a missing key blocked all extraction. - if s.stores.GlobalConfig != nil { - val, err := s.stores.GlobalConfig.Get(ctx, "memory_extraction_enabled") - if err == nil && val != nil { - if enabled, ok := val["value"].(bool); ok && !enabled { - return // explicitly disabled - } - } - // If key is missing (err != nil or val == nil), extraction is enabled. - } - - // ── Extraction ── - candidates, err := s.findCandidates(ctx) - if err != nil { - log.Printf("⚠ memory scanner: find candidates: %v", err) - return - } - - if len(candidates) > 0 { - sem := make(chan struct{}, s.cfg.Concurrency) - var wg sync.WaitGroup - - for _, c := range candidates { - key := c.ChannelID + ":" + c.UserID - if _, loaded := s.inFlight.LoadOrStore(key, true); loaded { - continue - } - - sem <- struct{}{} - wg.Add(1) - - go func(cand candidate) { - defer wg.Done() - defer func() { <-sem }() - defer s.inFlight.Delete(cand.ChannelID + ":" + cand.UserID) - - extractCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - if err := s.extractor.Extract(extractCtx, cand.ChannelID, cand.UserID, cand.TeamID, cand.PersonaID); err != nil { - log.Printf("⚠ memory extraction failed: channel=%s user=%s: %v", - cand.ChannelID, cand.UserID, err) - } - }(c) - } - - wg.Wait() - } - - // ── v0.30.1: Compaction (decay + prune) ── - // Runs after extraction on every tick. Gated by memory_compaction_enabled. - s.runCompaction(ctx) -} - -// runCompaction applies confidence decay and pruning to stale memories. -func (s *Scanner) runCompaction(ctx context.Context) { - if s.stores.Memories == nil { - return - } - - // Check compaction kill switch (default: enabled) - if s.stores.GlobalConfig != nil { - val, err := s.stores.GlobalConfig.Get(ctx, "memory_compaction_enabled") - if err == nil && val != nil { - if enabled, ok := val["value"].(bool); ok && !enabled { - return - } - } - } - - // Decay: reduce confidence for memories not updated in 7 days - olderThan := time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) - decayed, err := s.stores.Memories.DecayConfidence(ctx, olderThan, 0.9) - if err != nil { - log.Printf("⚠ memory compaction: decay failed: %v", err) - } else if decayed > 0 { - log.Printf("🧠 memory compaction: decayed %d memories", decayed) - } - - // Prune: archive memories with confidence below 0.15 - pruned, err := s.stores.Memories.Prune(ctx, 0.15) - if err != nil { - log.Printf("⚠ memory compaction: prune failed: %v", err) - } else if pruned > 0 { - log.Printf("🧠 memory compaction: pruned %d memories", pruned) - } -} - -// findCandidates queries for channels with enough new activity since last extraction. -// -// Fixes from v0.28.0 audit: -// - Uses channels.user_id (was: non-existent created_by) -// - Uses channels.team_id directly (was: JOIN to non-existent channel_users table) -// - JOINs channel_participants + personas to check memory_enabled (H6) -// - Filters out channels whose active persona has memory_enabled=false -func (s *Scanner) findCandidates(ctx context.Context) ([]candidate, error) { - db := database.DB - if db == nil { - return nil, nil - } - - var query string - if database.IsSQLite() { - query = ` - SELECT c.id, c.user_id, COALESCE(c.team_id, ''), - COALESCE(cp.participant_id, '') - FROM channels c - LEFT JOIN channel_participants cp - ON cp.channel_id = c.id AND cp.participant_type = 'persona' - LEFT JOIN personas p - ON p.id = cp.participant_id - LEFT JOIN memory_extraction_log mel - ON mel.channel_id = c.id AND mel.user_id = c.user_id - WHERE c.user_id IS NOT NULL - AND (cp.id IS NULL OR p.memory_enabled = 1) - AND ( - SELECT COUNT(*) FROM messages m - WHERE m.channel_id = c.id - AND (mel.last_message_id IS NULL OR m.id > mel.last_message_id) - ) >= 8 - AND ( - SELECT MAX(m2.created_at) FROM messages m2 WHERE m2.channel_id = c.id - ) < datetime('now', '-5 minutes') - LIMIT 20 - ` - } else { - query = ` - SELECT c.id, c.user_id, COALESCE(c.team_id::text, ''), - COALESCE(cp.participant_id::text, '') - FROM channels c - LEFT JOIN channel_participants cp - ON cp.channel_id = c.id AND cp.participant_type = 'persona' - LEFT JOIN personas p - ON p.id = cp.participant_id - LEFT JOIN memory_extraction_log mel - ON mel.channel_id = c.id AND mel.user_id = c.user_id - WHERE c.user_id IS NOT NULL - AND (cp.id IS NULL OR p.memory_enabled = true) - AND ( - SELECT COUNT(*) FROM messages m - WHERE m.channel_id = c.id - AND (mel.last_message_id IS NULL OR m.id::text > mel.last_message_id::text) - ) >= 8 - AND ( - SELECT MAX(m2.created_at) FROM messages m2 WHERE m2.channel_id = c.id - ) < now() - interval '5 minutes' - LIMIT 20 - ` - } - - rows, err := db.QueryContext(ctx, query) - if err != nil { - return nil, err - } - defer rows.Close() - - var candidates []candidate - for rows.Next() { - var c candidate - if err := rows.Scan(&c.ChannelID, &c.UserID, &c.TeamID, &c.PersonaID); err != nil { - continue - } - candidates = append(candidates, c) - } - - return candidates, rows.Err() -} diff --git a/server/mentions/parser.go b/server/mentions/parser.go deleted file mode 100644 index 1243830..0000000 --- a/server/mentions/parser.go +++ /dev/null @@ -1,220 +0,0 @@ -package mentions - -import ( - "sort" - "strings" - "unicode" - - "switchboard-core/models" -) - -// Mention represents a parsed @mention token in message content. -type Mention struct { - Raw string // "@veronica-sharpe" as written (includes @) - Name string // "veronica-sharpe" (normalized, no @) - Start int // byte offset in message content - End int // byte offset end (exclusive) - Resolved *models.ChannelModel // nil if unresolved - IsAll bool // true for @all special token -} - -// Parse extracts @mentions from message content and resolves them -// against the channel's model roster. -// -// Resolution priority: -// 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe") -// 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica") -// 3. Fallback: normalized display_name match (backward compat) -// 4. @all is a special token that resolves to all roster entries -// -// @ must be at start of content or preceded by whitespace. -func Parse(content string, roster []models.ChannelModel) []Mention { - if len(roster) == 0 || !strings.Contains(content, "@") { - return nil - } - - // Build handle lookup: handle → *ChannelModel - // Also build display name lookup for backward compat - type entry struct { - handle string // normalized handle - normalized string // normalized display_name - model models.ChannelModel - } - entries := make([]entry, 0, len(roster)) - for _, cm := range roster { - h := normalize(cm.Handle) - dn := normalize(cm.DisplayName) - entries = append(entries, entry{handle: h, normalized: dn, model: cm}) - } - // Sort by handle length descending for longest-match-first - sort.Slice(entries, func(i, j int) bool { - return len(entries[i].handle) > len(entries[j].handle) - }) - - // Extract @tokens - var mentions []Mention - i := 0 - for i < len(content) { - if content[i] != '@' { - i++ - continue - } - - // @ must be at start or preceded by whitespace - if i > 0 && !unicode.IsSpace(rune(content[i-1])) { - i++ - continue - } - - // Extract the token after @ - start := i - i++ // skip @ - tokenStart := i - for i < len(content) && !unicode.IsSpace(rune(content[i])) { - i++ - } - - if i == tokenStart { - continue // bare @ with no token - } - - // Strip trailing punctuation - tokenEnd := i - for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) { - tokenEnd-- - } - if tokenEnd == tokenStart { - continue - } - - raw := content[start:tokenEnd] - name := content[tokenStart:tokenEnd] - normalizedName := normalize(name) - - // Special: @all - if normalizedName == "all" { - mentions = append(mentions, Mention{ - Raw: raw, - Name: name, - Start: start, - End: tokenEnd, - IsAll: true, - }) - continue - } - - // 1. Exact handle match - var resolved *models.ChannelModel - for idx := range entries { - if entries[idx].handle != "" && normalizedName == entries[idx].handle { - cm := entries[idx].model - resolved = &cm - break - } - } - - // 2. Prefix handle match (unambiguous only) - if resolved == nil { - var prefixMatches []int - for idx := range entries { - if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) { - prefixMatches = append(prefixMatches, idx) - } - } - if len(prefixMatches) == 1 { - cm := entries[prefixMatches[0]].model - resolved = &cm - } - } - - // 3. Fallback: display_name match (backward compat) - if resolved == nil { - for idx := range entries { - if entries[idx].normalized != "" && normalizedName == entries[idx].normalized { - cm := entries[idx].model - resolved = &cm - break - } - } - // Display name prefix - if resolved == nil { - var prefixMatches []int - for idx := range entries { - if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") { - prefixMatches = append(prefixMatches, idx) - } - } - if len(prefixMatches) == 0 { - for idx := range entries { - if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) { - prefixMatches = append(prefixMatches, idx) - } - } - } - if len(prefixMatches) == 1 { - cm := entries[prefixMatches[0]].model - resolved = &cm - } - } - } - - mentions = append(mentions, Mention{ - Raw: raw, - Name: name, - Start: start, - End: tokenEnd, - Resolved: resolved, - }) - } - - return mentions -} - -// ResolvedModels returns deduplicated resolved models from parsed mentions. -// Returns nil if no mentions resolved. -func ResolvedModels(mentions []Mention) []models.ChannelModel { - if len(mentions) == 0 { - return nil - } - - seen := make(map[string]bool) - var result []models.ChannelModel - for _, m := range mentions { - if m.Resolved != nil && !seen[m.Resolved.ID] { - seen[m.Resolved.ID] = true - result = append(result, *m.Resolved) - } - } - return result -} - -// HasAll returns true if any mention is @all. -func HasAll(mentions []Mention) bool { - for _, m := range mentions { - if m.IsAll { - return true - } - } - return false -} - -// isMentionTrailingPunct returns true for punctuation that commonly -// follows an @mention but isn't part of the name. -func isMentionTrailingPunct(b byte) bool { - switch b { - case ',', '.', '!', '?', ':', ';', ')', ']', '}': - return true - } - return false -} - -// normalize converts a display name to a canonical form for matching: -// lowercase, hyphens → spaces collapsed, trimmed. -func normalize(s string) string { - s = strings.ToLower(s) - s = strings.ReplaceAll(s, "-", " ") - s = strings.ReplaceAll(s, "_", " ") - // Collapse multiple spaces - fields := strings.Fields(s) - return strings.Join(fields, " ") -} diff --git a/server/mentions/parser_test.go b/server/mentions/parser_test.go deleted file mode 100644 index f4c688d..0000000 --- a/server/mentions/parser_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package mentions - -import ( - "testing" - - "switchboard-core/models" -) - -func roster() []models.ChannelModel { - return []models.ChannelModel{ - {ID: "1", ChannelID: "ch1", ModelID: "anthropic/claude-3-opus", DisplayName: "Claude-3-Opus", IsDefault: true}, - {ID: "2", ChannelID: "ch1", ModelID: "openai/gpt-4", DisplayName: "GPT-4"}, - {ID: "3", ChannelID: "ch1", ModelID: "anthropic/claude-3-haiku", DisplayName: "Claude-3-Haiku"}, - } -} - -func TestParse_NoMentions(t *testing.T) { - m := Parse("Hello, how are you?", roster()) - if len(m) != 0 { - t.Fatalf("expected 0 mentions, got %d", len(m)) - } -} - -func TestParse_EmptyRoster(t *testing.T) { - m := Parse("@gpt-4 hello", nil) - if len(m) != 0 { - t.Fatalf("expected 0 mentions with empty roster, got %d", len(m)) - } -} - -func TestParse_SingleMention(t *testing.T) { - m := Parse("@GPT-4 what do you think?", roster()) - if len(m) != 1 { - t.Fatalf("expected 1 mention, got %d", len(m)) - } - if m[0].Name != "GPT-4" { - t.Errorf("expected Name='GPT-4', got %q", m[0].Name) - } - if m[0].Resolved == nil { - t.Fatal("expected resolved, got nil") - } - if m[0].Resolved.ID != "2" { - t.Errorf("expected resolved to GPT-4 (ID=2), got ID=%s", m[0].Resolved.ID) - } -} - -func TestParse_CaseInsensitive(t *testing.T) { - m := Parse("@claude-3-opus please help", roster()) - if len(m) != 1 || m[0].Resolved == nil { - t.Fatal("expected 1 resolved mention") - } - if m[0].Resolved.ID != "1" { - t.Errorf("expected Claude-3-Opus (ID=1), got ID=%s", m[0].Resolved.ID) - } -} - -func TestParse_MultipleMentions(t *testing.T) { - m := Parse("Hey @Claude-3-Opus and @GPT-4, compare your approaches.", roster()) - if len(m) != 2 { - t.Fatalf("expected 2 mentions, got %d", len(m)) - } - if m[0].Resolved == nil || m[0].Resolved.ID != "1" { - t.Errorf("first mention: expected Claude-3-Opus") - } - if m[1].Resolved == nil || m[1].Resolved.ID != "2" { - t.Errorf("second mention: expected GPT-4") - } -} - -func TestParse_UnresolvedMention(t *testing.T) { - m := Parse("@nonexistent-model hello", roster()) - if len(m) != 1 { - t.Fatalf("expected 1 mention, got %d", len(m)) - } - if m[0].Resolved != nil { - t.Errorf("expected unresolved mention, got resolved to %s", m[0].Resolved.DisplayName) - } -} - -func TestParse_MixedResolvedUnresolved(t *testing.T) { - m := Parse("@GPT-4 and @fake-model what?", roster()) - if len(m) != 2 { - t.Fatalf("expected 2 mentions, got %d", len(m)) - } - if m[0].Resolved == nil { - t.Error("first mention should resolve") - } - if m[1].Resolved != nil { - t.Error("second mention should not resolve") - } -} - -func TestParse_MentionAtStart(t *testing.T) { - m := Parse("@GPT-4", roster()) - if len(m) != 1 || m[0].Resolved == nil { - t.Fatal("expected 1 resolved mention at start") - } -} - -func TestParse_MentionInMiddle(t *testing.T) { - m := Parse("Ask @GPT-4 about this", roster()) - if len(m) != 1 || m[0].Resolved == nil { - t.Fatal("expected 1 resolved mention in middle") - } -} - -func TestParse_NoSpaceBefore(t *testing.T) { - // email@GPT-4 should NOT be parsed as a mention - m := Parse("email@GPT-4 test", roster()) - if len(m) != 0 { - t.Fatalf("expected 0 mentions (no space before @), got %d", len(m)) - } -} - -func TestParse_BareAt(t *testing.T) { - m := Parse("@ nothing", roster()) - if len(m) != 0 { - t.Fatalf("expected 0 mentions for bare @, got %d", len(m)) - } -} - -func TestParse_HyphenSpaceNormalization(t *testing.T) { - // Display name "Claude-3-Opus" should match "@claude_3_opus" (underscore normalization) - m := Parse("@claude_3_opus help", roster()) - if len(m) != 1 || m[0].Resolved == nil { - t.Fatal("expected underscore-normalized match") - } - if m[0].Resolved.ID != "1" { - t.Errorf("expected Claude-3-Opus, got %s", m[0].Resolved.DisplayName) - } -} - -func TestParse_ByteOffsets(t *testing.T) { - content := "Hey @GPT-4 ok" - m := Parse(content, roster()) - if len(m) != 1 { - t.Fatal("expected 1 mention") - } - if m[0].Start != 4 || m[0].End != 10 { - t.Errorf("expected offsets [4,10), got [%d,%d)", m[0].Start, m[0].End) - } - if content[m[0].Start:m[0].End] != "@GPT-4" { - t.Errorf("offsets don't match: %q", content[m[0].Start:m[0].End]) - } -} - -func TestParse_TrailingPunctuation(t *testing.T) { - // Comma, period, exclamation should be stripped - tests := []struct { - input string - want int // expected resolved count - }{ - {"@GPT-4, what?", 1}, - {"@GPT-4.", 1}, - {"@GPT-4!", 1}, - {"@GPT-4? help", 1}, - {"(@GPT-4)", 0}, // ( before @ is not whitespace — not a mention - {"( @GPT-4)", 1}, // space before @ — valid mention, ) stripped - } - for _, tt := range tests { - m := Parse(tt.input, roster()) - resolved := 0 - for _, mm := range m { - if mm.Resolved != nil { - resolved++ - } - } - if resolved != tt.want { - t.Errorf("Parse(%q): expected %d resolved, got %d", tt.input, tt.want, resolved) - } - } -} - -func TestResolvedModels_Dedup(t *testing.T) { - m := Parse("@GPT-4 do this @GPT-4 and that", roster()) - resolved := ResolvedModels(m) - if len(resolved) != 1 { - t.Fatalf("expected 1 deduplicated model, got %d", len(resolved)) - } -} - -func TestResolvedModels_NilOnNoResolve(t *testing.T) { - m := Parse("@unknown-model test", roster()) - resolved := ResolvedModels(m) - if resolved != nil { - t.Fatalf("expected nil for unresolved only, got %d", len(resolved)) - } -} - -func TestResolvedModels_MultipleDistinct(t *testing.T) { - m := Parse("@GPT-4 and @Claude-3-Opus compare", roster()) - resolved := ResolvedModels(m) - if len(resolved) != 2 { - t.Fatalf("expected 2 distinct models, got %d", len(resolved)) - } -} - -func TestNormalize(t *testing.T) { - tests := []struct { - in, want string - }{ - {"Claude-3-Opus", "claude 3 opus"}, - {"GPT-4", "gpt 4"}, - {"gpt_4", "gpt 4"}, - {" mixed--Case__Name ", "mixed case name"}, - } - for _, tt := range tests { - got := normalize(tt.in) - if got != tt.want { - t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want) - } - } -} diff --git a/server/notelinks/extract.go b/server/notelinks/extract.go deleted file mode 100644 index 70397f4..0000000 --- a/server/notelinks/extract.go +++ /dev/null @@ -1,50 +0,0 @@ -package notelinks - -import ( - "regexp" - "strings" - - "switchboard-core/models" -) - -// wikiRe matches [[Title]], [[Title|Display]], ![[Title]], and ![[Title|Display]]. -// Title first char rejects [ and ] to avoid matching inside [[[triple]]]. -var wikiRe = regexp.MustCompile(`(!?)\[\[([^\[\]|][^\]|]*?)(?:\|([^\]]+?))?\]\]`) - -// ExtractWikilinks parses [[Title]], [[Title|Display]], and ![[Title]] from markdown content. -// Returns deduplicated links preserving first occurrence. -// Ignores matches preceded by [ (e.g. [[[triple]]]) since Go regexp lacks lookbehind. -func ExtractWikilinks(content string) []models.NoteLink { - indices := wikiRe.FindAllStringSubmatchIndex(content, -1) - seen := make(map[string]bool) - var links []models.NoteLink - - for _, idx := range indices { - matchStart := idx[0] // start of full match (including optional !) - - // Skip if preceded by [ — rejects [[[triple]]] patterns - if matchStart > 0 && content[matchStart-1] == '[' { - continue - } - - // Extract submatches by index pairs: idx[2*n], idx[2*n+1] - title := strings.TrimSpace(content[idx[4]:idx[5]]) // group 2: title - - key := strings.ToLower(title) - if key == "" || seen[key] { - continue - } - seen[key] = true - - link := models.NoteLink{ - TargetTitle: title, - IsTransclusion: content[idx[2]:idx[3]] == "!", // group 1 - } - // group 3: display text (optional — idx[6:7] may be -1) - if idx[6] >= 0 && idx[7] >= 0 { - link.DisplayText = strings.TrimSpace(content[idx[6]:idx[7]]) - } - links = append(links, link) - } - return links -} diff --git a/server/notelinks/extract_test.go b/server/notelinks/extract_test.go deleted file mode 100644 index 2f3dc0a..0000000 --- a/server/notelinks/extract_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package notelinks - -import ( - "testing" -) - -func TestExtractWikilinks(t *testing.T) { - tests := []struct { - name string - content string - want int - checks func(t *testing.T, links []struct{ title, display string; transclusion bool }) - }{ - { - name: "no links", - content: "Just some plain text with [single brackets]", - want: 0, - }, - { - name: "single link", - content: "See [[Project Notes]] for details", - want: 1, - checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) { - if links[0].title != "Project Notes" { - t.Errorf("title = %q, want %q", links[0].title, "Project Notes") - } - if links[0].transclusion { - t.Error("should not be transclusion") - } - }, - }, - { - name: "link with display text", - content: "Check the [[Architecture Doc|arch doc]] here", - want: 1, - checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) { - if links[0].title != "Architecture Doc" { - t.Errorf("title = %q", links[0].title) - } - if links[0].display != "arch doc" { - t.Errorf("display = %q", links[0].display) - } - }, - }, - { - name: "transclusion", - content: "Embed this: ![[Meeting Notes]]", - want: 1, - checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) { - if links[0].title != "Meeting Notes" { - t.Errorf("title = %q", links[0].title) - } - if !links[0].transclusion { - t.Error("should be transclusion") - } - }, - }, - { - name: "multiple links deduplicated", - content: "See [[Alpha]] and [[Beta]] and [[Alpha]] again", - want: 2, - }, - { - name: "case-insensitive dedup", - content: "See [[Project]] and [[project]]", - want: 1, - checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) { - if links[0].title != "Project" { - t.Errorf("title = %q, want first occurrence", links[0].title) - } - }, - }, - { - name: "mixed links and transclusions", - content: "Link to [[Alpha]], embed ![[Beta|b]], and [[Gamma]]", - want: 3, - }, - { - name: "whitespace trimmed", - content: "See [[ Spaced Title ]] here", - want: 1, - checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) { - if links[0].title != "Spaced Title" { - t.Errorf("title = %q, want trimmed", links[0].title) - } - }, - }, - { - name: "nested brackets ignored", - content: "Not a link: [[[triple]]] but [[Valid]] is", - want: 1, - checks: func(t *testing.T, links []struct{ title, display string; transclusion bool }) { - if links[0].title != "Valid" { - t.Errorf("title = %q, want Valid", links[0].title) - } - }, - }, - { - name: "empty brackets ignored", - content: "Empty [[]] should not match", - want: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - links := ExtractWikilinks(tt.content) - if len(links) != tt.want { - t.Fatalf("got %d links, want %d", len(links), tt.want) - } - if tt.checks != nil { - simplified := make([]struct{ title, display string; transclusion bool }, len(links)) - for i, l := range links { - simplified[i] = struct{ title, display string; transclusion bool }{ - title: l.TargetTitle, display: l.DisplayText, transclusion: l.IsTransclusion, - } - } - tt.checks(t, simplified) - } - }) - } -} diff --git a/server/providers/anthropic.go b/server/providers/anthropic.go deleted file mode 100644 index 27b0a51..0000000 --- a/server/providers/anthropic.go +++ /dev/null @@ -1,538 +0,0 @@ -package providers - -import ( - "switchboard-core/capabilities" - "switchboard-core/models" - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -const anthropicDefaultEndpoint = "https://api.anthropic.com" -const anthropicAPIVersion = "2023-06-01" - -// AnthropicProvider handles the Anthropic Messages API. -type AnthropicProvider struct{} - -func (p *AnthropicProvider) ID() string { return "anthropic" } - -// ── Chat Completion (non-streaming) ───────── - -func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) { - req.Stream = false - - body, err := p.doRequest(ctx, cfg, req) - if err != nil { - return nil, err - } - defer body.Close() - - var resp anthropicResponse - if err := json.NewDecoder(body).Decode(&resp); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - - result := &CompletionResponse{ - Model: resp.Model, - FinishReason: resp.StopReason, - InputTokens: resp.Usage.InputTokens, - OutputTokens: resp.Usage.OutputTokens, - CacheCreationTokens: resp.Usage.CacheCreationInputTokens, - CacheReadTokens: resp.Usage.CacheReadInputTokens, - } - - // Extract text and tool_use blocks - for _, block := range resp.Content { - switch block.Type { - case "text": - result.Content += block.Text - case "tool_use": - argsJSON, _ := json.Marshal(block.Input) - result.ToolCalls = append(result.ToolCalls, ToolCall{ - ID: block.ID, - Type: "function", - Function: FunctionCall{ - Name: block.Name, - Arguments: string(argsJSON), - }, - }) - } - } - - // Normalize stop reason - if resp.StopReason == "tool_use" { - result.FinishReason = "tool_calls" - } - - return result, nil -} - -// ── Stream Completion ─────────────────────── - -func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) { - req.Stream = true - - body, err := p.doRequest(ctx, cfg, req) - if err != nil { - return nil, err - } - - ch := make(chan StreamEvent, 64) - - go func() { - defer close(ch) - defer body.Close() - - var toolCalls []ToolCall - var currentToolIdx int = -1 - - // Usage accumulates across stream events - var inputTokens, outputTokens, cacheCreation, cacheRead int - - scanner := bufio.NewScanner(body) - for scanner.Scan() { - line := scanner.Text() - - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - - var event anthropicStreamEvent - if err := json.Unmarshal([]byte(data), &event); err != nil { - continue - } - - switch event.Type { - case "message_start": - // Capture input token counts (including cache) - if event.Message != nil { - inputTokens = event.Message.Usage.InputTokens - cacheCreation = event.Message.Usage.CacheCreationInputTokens - cacheRead = event.Message.Usage.CacheReadInputTokens - } - - case "content_block_start": - if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" { - currentToolIdx++ - toolCalls = append(toolCalls, ToolCall{ - ID: event.ContentBlock.ID, - Type: "function", - Function: FunctionCall{ - Name: event.ContentBlock.Name, - Arguments: "", - }, - }) - } - // thinking blocks (extended thinking, v0.22.1) — no action on start, - // deltas arrive as thinking_delta in content_block_delta - - case "content_block_delta": - if event.Delta.Type == "text_delta" { - ch <- StreamEvent{Delta: event.Delta.Text} - } else if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" { - // Extended thinking content → Reasoning field - ch <- StreamEvent{Reasoning: event.Delta.Thinking} - } else if event.Delta.Type == "input_json_delta" && currentToolIdx >= 0 { - toolCalls[currentToolIdx].Function.Arguments += event.Delta.PartialJSON - } - - case "message_delta": - // Capture output tokens - if event.Usage != nil { - outputTokens = event.Usage.OutputTokens - } - - stopReason := event.Delta.StopReason - ev := StreamEvent{ - Done: true, - FinishReason: stopReason, - InputTokens: inputTokens, - OutputTokens: outputTokens, - CacheCreationTokens: cacheCreation, - CacheReadTokens: cacheRead, - } - if stopReason == "tool_use" { - ev.FinishReason = "tool_calls" - ev.ToolCalls = toolCalls - } - ch <- ev - - case "message_stop": - ch <- StreamEvent{Done: true} - return - - case "error": - ch <- StreamEvent{ - Error: fmt.Errorf("anthropic stream error: %s", data), - } - return - } - } - - if err := scanner.Err(); err != nil { - ch <- StreamEvent{Error: err} - } - }() - - return ch, nil -} - -// ── List Models ───────────────────────────── - -func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) { - modelIDs := []struct{ id, name string }{ - {"claude-opus-4-20250514", "Claude Opus 4"}, - {"claude-sonnet-4-20250514", "Claude Sonnet 4"}, - {"claude-haiku-4-20250414", "Claude Haiku 4"}, - {"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"}, - {"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"}, - } - - out := make([]Model, 0, len(modelIDs)) - for _, m := range modelIDs { - caps := capabilities.InferCapabilities(m.id) - out = append(out, Model{ - ID: m.id, - Name: m.name, - OwnedBy: "anthropic", - Capabilities: caps, - }) - } - return out, nil -} - -// ── Embeddings ───────────────────────────── - -// Embed is not supported by the Anthropic API. -func (p *AnthropicProvider) Embed(_ context.Context, _ ProviderConfig, _ EmbeddingRequest) (*EmbeddingResponse, error) { - return nil, ErrNotSupported -} - -// ── HTTP Layer ────────────────────────────── - -func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) { - endpoint := cfg.Endpoint - if endpoint == "" { - endpoint = anthropicDefaultEndpoint - } - endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages" - - // Separate system message and convert to Anthropic format - var system string - messages := make([]anthropicMessage, 0, len(req.Messages)) - for _, m := range req.Messages { - if m.Role == "system" { - system = m.Content - continue - } - - antMsg := anthropicMessage{Role: m.Role} - - // ── Foreign persona rewrite (v0.23.0) ── - // Anthropic doesn't support the "name" field on messages. - // Rewrite named assistant messages (from other personas) to user - // role with attribution so Anthropic maintains alternation and - // the model understands it's a multi-participant conversation. - if m.Role == "assistant" && m.Name != "" { - antMsg.Role = "user" - antMsg.Content = []anthropicContentBlock{ - {Type: "text", Text: fmt.Sprintf("[%s responded:]\n%s", m.Name, m.Content)}, - } - messages = append(messages, antMsg) - continue - } - - if m.Role == "assistant" && len(m.ToolCalls) > 0 { - // Assistant with tool calls → mixed content blocks - if m.Content != "" { - antMsg.Content = []anthropicContentBlock{ - {Type: "text", Text: m.Content}, - } - } else { - antMsg.Content = []anthropicContentBlock{} - } - for _, tc := range m.ToolCalls { - var input json.RawMessage - if tc.Function.Arguments != "" { - input = json.RawMessage(tc.Function.Arguments) - } else { - input = json.RawMessage("{}") - } - antMsg.Content = append(antMsg.Content, anthropicContentBlock{ - Type: "tool_use", - ID: tc.ID, - Name: tc.Function.Name, - Input: input, - }) - } - } else if m.Role == "tool" { - // Tool results → user message with tool_result block - antMsg.Role = "user" - antMsg.Content = []anthropicContentBlock{ - { - Type: "tool_result", - ToolUseID: m.ToolCallID, - Content: m.Content, - }, - } - } else if len(m.ContentParts) > 0 && m.Role == "user" { - // Multimodal user message: convert ContentParts to Anthropic blocks - for _, p := range m.ContentParts { - switch p.Type { - case "image_url": - if p.ImageURL != nil { - // Parse data URI: "data:image/jpeg;base64,{data}" - mediaType, b64Data := parseDataURI(p.ImageURL.URL) - if b64Data != "" { - antMsg.Content = append(antMsg.Content, anthropicContentBlock{ - Type: "image", - Source: &anthropicImageSource{ - Type: "base64", - MediaType: mediaType, - Data: b64Data, - }, - }) - } - } - default: // "text", "document" - if p.Text != "" { - antMsg.Content = append(antMsg.Content, anthropicContentBlock{ - Type: "text", - Text: p.Text, - }) - } - } - } - if len(antMsg.Content) == 0 { - // Fallback: shouldn't happen, but safety net - antMsg.Content = []anthropicContentBlock{ - {Type: "text", Text: m.Content}, - } - } - } else { - // Regular text message - antMsg.Content = []anthropicContentBlock{ - {Type: "text", Text: m.Content}, - } - } - - messages = append(messages, antMsg) - } - - // Merge consecutive user messages (Anthropic requires alternating roles) - messages = mergeConsecutiveUserMessages(messages) - - antReq := anthropicRequest{ - Model: req.Model, - Messages: messages, - MaxTokens: req.MaxTokens, - Stream: req.Stream, - } - if system != "" { - antReq.System = system - } - if antReq.MaxTokens == 0 { - antReq.MaxTokens = capabilities.ResolveMaxOutput(req.Model, models.ModelCapabilities{}) - } - if req.Temperature != nil { - antReq.Temperature = req.Temperature - } - if req.TopP != nil { - antReq.TopP = req.TopP - } - - // Add tools in Anthropic format - for _, t := range req.Tools { - antReq.Tools = append(antReq.Tools, anthropicToolDef{ - Name: t.Function.Name, - Description: t.Function.Description, - InputSchema: t.Function.Parameters, - }) - } - - body, err := json.Marshal(antReq) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - // Merge hook-supplied extra fields into wire JSON (v0.22.1) - // This is how extended thinking config gets injected. - if len(req.ExtraBody) > 0 { - body, err = mergeExtraBody(body, req.ExtraBody) - if err != nil { - return nil, fmt.Errorf("merge extra body: %w", err) - } - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("x-api-key", cfg.APIKey) - httpReq.Header.Set("anthropic-version", anthropicAPIVersion) - - // Extended thinking requires the output-128k beta header - if len(req.ExtraBody) > 0 { - if _, hasThinking := req.ExtraBody["thinking"]; hasThinking { - httpReq.Header.Set("anthropic-beta", "output-128k-2025-02-19") - } - } - - resp, err := cfg.Client().Do(httpReq) - if err != nil { - return nil, fmt.Errorf("provider request: %w", err) - } - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b)) - } - - return resp.Body, nil -} - -// mergeConsecutiveUserMessages combines consecutive user-role messages. -// Anthropic requires strictly alternating user/assistant roles. -func mergeConsecutiveUserMessages(msgs []anthropicMessage) []anthropicMessage { - if len(msgs) <= 1 { - return msgs - } - merged := make([]anthropicMessage, 0, len(msgs)) - for _, m := range msgs { - if len(merged) > 0 && merged[len(merged)-1].Role == "user" && m.Role == "user" { - merged[len(merged)-1].Content = append(merged[len(merged)-1].Content, m.Content...) - } else { - merged = append(merged, m) - } - } - return merged -} - -// ── Anthropic Wire Types ──────────────────── - -type anthropicContentBlock struct { - Type string `json:"type"` - // text - Text string `json:"text,omitempty"` - // image (type="image") - Source *anthropicImageSource `json:"source,omitempty"` - // tool_use - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - // tool_result - ToolUseID string `json:"tool_use_id,omitempty"` - Content string `json:"content,omitempty"` -} - -// anthropicImageSource is the Anthropic-native image format. -// Anthropic uses {"type":"base64","media_type":"image/jpeg","data":"..."} -// rather than OpenAI's data URI approach. -type anthropicImageSource struct { - Type string `json:"type"` // "base64" - MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc. - Data string `json:"data"` // raw base64 (no data: prefix) -} - -type anthropicMessage struct { - Role string `json:"role"` - Content []anthropicContentBlock `json:"content"` -} - -type anthropicToolDef struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema json.RawMessage `json:"input_schema"` -} - -type anthropicRequest struct { - Model string `json:"model"` - Messages []anthropicMessage `json:"messages"` - System string `json:"system,omitempty"` - MaxTokens int `json:"max_tokens"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream bool `json:"stream"` - Tools []anthropicToolDef `json:"tools,omitempty"` -} - -type anthropicResponse struct { - Model string `json:"model"` - StopReason string `json:"stop_reason"` - Content []struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - } `json:"content"` - Usage anthropicUsage `json:"usage"` -} - -type anthropicUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheCreationInputTokens int `json:"cache_creation_input_tokens"` - CacheReadInputTokens int `json:"cache_read_input_tokens"` -} - -type anthropicStreamEvent struct { - Type string `json:"type"` - Message *struct { - Usage anthropicUsage `json:"usage"` - } `json:"message,omitempty"` - Delta struct { - Type string `json:"type"` - Text string `json:"text"` - Thinking string `json:"thinking"` // extended thinking delta (v0.22.1) - StopReason string `json:"stop_reason"` - PartialJSON string `json:"partial_json"` - } `json:"delta"` - Usage *struct { - OutputTokens int `json:"output_tokens"` - } `json:"usage,omitempty"` - ContentBlock *struct { - Type string `json:"type"` - ID string `json:"id"` - Name string `json:"name"` - } `json:"content_block,omitempty"` -} - -// parseDataURI splits a data URI into media type and base64 data. -// Input: "data:image/jpeg;base64,/9j/4AAQ..." -// Output: "image/jpeg", "/9j/4AAQ..." -// Returns empty strings if the URI is malformed. -func parseDataURI(uri string) (mediaType, data string) { - // Strip "data:" prefix - if !strings.HasPrefix(uri, "data:") { - return "", "" - } - rest := uri[5:] - - // Split on comma (separates header from data) - commaIdx := strings.Index(rest, ",") - if commaIdx < 0 { - return "", "" - } - - header := rest[:commaIdx] - data = rest[commaIdx+1:] - - // Extract media type from header (before ";base64") - if semiIdx := strings.Index(header, ";"); semiIdx >= 0 { - mediaType = header[:semiIdx] - } else { - mediaType = header - } - - return mediaType, data -} diff --git a/server/providers/custom.go b/server/providers/custom.go deleted file mode 100644 index 14e0cf3..0000000 --- a/server/providers/custom.go +++ /dev/null @@ -1,132 +0,0 @@ -// Package providers — custom.go -// -// v0.29.1 CS4: Config-file provider types. Registers OpenAI-compatible -// provider types from a JSON file at startup. No code deploy needed to -// add Ollama, LiteLLM, vLLM, or any other OpenAI-compatible endpoint. -// -// File format (array of objects): -// -// [ -// { -// "id": "ollama", -// "name": "Ollama", -// "description": "Local Ollama instance", -// "default_endpoint": "http://localhost:11434/v1", -// "api": "openai" -// }, -// { -// "id": "litellm", -// "name": "LiteLLM Proxy", -// "description": "LiteLLM unified proxy", -// "default_endpoint": "http://litellm:4000/v1", -// "api": "openai" -// } -// ] -// -// Fields: -// - id: Unique provider type ID (used in provider_configs.provider column) -// - name: Human-readable label for admin UI -// - description: Help text shown in provider creation form -// - default_endpoint: Pre-filled endpoint URL for new configs -// - api: Wire protocol — "openai" (required, only supported value for now) -// -// The api field determines which Provider implementation handles -// requests. Currently only "openai" is supported (covers Ollama, -// LiteLLM, vLLM, LocalAI, and any other OpenAI-compatible API). -// Future: "anthropic" for Anthropic-compatible proxies. -// -// Built-in provider IDs (openai, anthropic, venice, openrouter) cannot -// be overridden by config-file entries — they are silently skipped. -package providers - -import ( - "encoding/json" - "fmt" - "log" - "os" -) - -// CustomProviderType represents one entry in the provider types JSON file. -type CustomProviderType struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - DefaultEndpoint string `json:"default_endpoint"` - API string `json:"api"` // "openai" (required) -} - -// builtinIDs is the set of provider IDs registered by Init(). -// Config-file entries with these IDs are silently skipped. -var builtinIDs = map[string]bool{ - "openai": true, - "anthropic": true, - "venice": true, - "openrouter": true, -} - -// LoadCustomTypes reads a JSON file of provider type definitions and -// registers each as a new provider type. Only OpenAI-compatible APIs -// are currently supported (api="openai"). -// -// Returns the number of providers registered. Errors are non-fatal — -// individual invalid entries are logged and skipped. -// -// Call after Init() so that built-in types are already registered -// and can be detected for skip logic. -func LoadCustomTypes(path string) (int, error) { - data, err := os.ReadFile(path) - if err != nil { - return 0, fmt.Errorf("reading provider types file: %w", err) - } - - var entries []CustomProviderType - if err := json.Unmarshal(data, &entries); err != nil { - return 0, fmt.Errorf("parsing provider types file: %w", err) - } - - registered := 0 - for _, entry := range entries { - // Validate required fields - if entry.ID == "" { - log.Printf("⚠️ custom provider: skipping entry with empty id") - continue - } - if entry.Name == "" { - log.Printf("⚠️ custom provider %q: skipping — name is required", entry.ID) - continue - } - if entry.API == "" { - log.Printf("⚠️ custom provider %q: skipping — api is required", entry.ID) - continue - } - - // Skip built-in provider IDs - if builtinIDs[entry.ID] { - log.Printf("⚠️ custom provider %q: skipping — conflicts with built-in provider", entry.ID) - continue - } - - // Resolve API implementation - var impl Provider - switch entry.API { - case "openai": - impl = &OpenAIProvider{} - default: - log.Printf("⚠️ custom provider %q: unsupported api %q (only \"openai\" supported)", entry.ID, entry.API) - continue - } - - RegisterType(ProviderTypeMeta{ - ID: entry.ID, - Name: entry.Name, - Description: entry.Description, - DefaultEndpoint: entry.DefaultEndpoint, - ProfileSchema: GetProfileSchema(entry.API), // inherit schema from API type - }, impl) - - registered++ - log.Printf(" 📦 Custom provider type: %s (%s) → %s", entry.ID, entry.Name, entry.DefaultEndpoint) - } - - return registered, nil -} diff --git a/server/providers/custom_test.go b/server/providers/custom_test.go deleted file mode 100644 index da95100..0000000 --- a/server/providers/custom_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package providers - -import ( - "os" - "path/filepath" - "testing" -) - -func writeTempJSON(t *testing.T, content string) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "provider_types.json") - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - return path -} - -func TestLoadCustomTypes_Valid(t *testing.T) { - path := writeTempJSON(t, `[ - { - "id": "test-ollama", - "name": "Test Ollama", - "description": "Local test", - "default_endpoint": "http://localhost:11434/v1", - "api": "openai" - }, - { - "id": "test-litellm", - "name": "Test LiteLLM", - "description": "LiteLLM proxy", - "default_endpoint": "http://litellm:4000/v1", - "api": "openai" - } - ]`) - - n, err := LoadCustomTypes(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if n != 2 { - t.Errorf("registered = %d, want 2", n) - } - - // Verify they're in the registry - p, err := Get("test-ollama") - if err != nil { - t.Errorf("test-ollama not registered: %v", err) - } - if p == nil { - t.Error("test-ollama provider is nil") - } - - // Verify type metadata - types := ListTypes() - found := false - for _, m := range types { - if m.ID == "test-litellm" { - found = true - if m.DefaultEndpoint != "http://litellm:4000/v1" { - t.Errorf("endpoint = %q", m.DefaultEndpoint) - } - } - } - if !found { - t.Error("test-litellm not in ListTypes()") - } -} - -func TestLoadCustomTypes_SkipsBuiltins(t *testing.T) { - path := writeTempJSON(t, `[ - { - "id": "openai", - "name": "Override OpenAI", - "description": "Should be skipped", - "default_endpoint": "http://evil.com", - "api": "openai" - }, - { - "id": "test-custom-ok", - "name": "Valid Custom", - "description": "Should register", - "default_endpoint": "http://ok.com", - "api": "openai" - } - ]`) - - n, err := LoadCustomTypes(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if n != 1 { - t.Errorf("registered = %d, want 1 (openai skipped)", n) - } -} - -func TestLoadCustomTypes_SkipsInvalid(t *testing.T) { - path := writeTempJSON(t, `[ - {"id": "", "name": "No ID", "api": "openai"}, - {"id": "test-no-name", "name": "", "api": "openai"}, - {"id": "test-no-api", "name": "No API", "api": ""}, - {"id": "test-bad-api", "name": "Bad API", "api": "grpc", "default_endpoint": "http://x"}, - {"id": "test-valid-entry", "name": "Valid", "description": "ok", "default_endpoint": "http://ok", "api": "openai"} - ]`) - - n, err := LoadCustomTypes(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if n != 1 { - t.Errorf("registered = %d, want 1 (4 invalid skipped)", n) - } -} - -func TestLoadCustomTypes_EmptyArray(t *testing.T) { - path := writeTempJSON(t, `[]`) - - n, err := LoadCustomTypes(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if n != 0 { - t.Errorf("registered = %d, want 0", n) - } -} - -func TestLoadCustomTypes_InvalidJSON(t *testing.T) { - path := writeTempJSON(t, `not json`) - - _, err := LoadCustomTypes(path) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestLoadCustomTypes_FileNotFound(t *testing.T) { - _, err := LoadCustomTypes("/nonexistent/path/providers.json") - if err == nil { - t.Error("expected error for missing file") - } -} - -func TestLoadCustomTypes_NoDefaultEndpoint(t *testing.T) { - // default_endpoint is optional — should still register - path := writeTempJSON(t, `[ - { - "id": "test-no-endpoint", - "name": "No Endpoint", - "description": "User must fill in endpoint", - "api": "openai" - } - ]`) - - n, err := LoadCustomTypes(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if n != 1 { - t.Errorf("registered = %d, want 1", n) - } -} diff --git a/server/providers/hooks.go b/server/providers/hooks.go deleted file mode 100644 index 7fef346..0000000 --- a/server/providers/hooks.go +++ /dev/null @@ -1,296 +0,0 @@ -package providers - -import ( - "encoding/json" - "strings" -) - -// Hooks defines provider-specific request/response transforms. -// Implementations read from ProviderConfig.Settings (populated from the -// provider_configs.settings JSONB column) to decorate requests and -// normalize responses. This replaces hardcoded provider switch statements. -type Hooks interface { - // PreRequest modifies the canonical request before dispatch. - // May inject system prompt prefixes, set ExtraBody fields, - // and adjust request parameters based on provider settings. - PreRequest(cfg ProviderConfig, req *CompletionRequest) - - // PostStreamEvent normalizes a streaming event after the provider - // emits it. For example, extracting thinking output from content. - PostStreamEvent(cfg ProviderConfig, event *StreamEvent) -} - -// ── Hook Registry ─────────────────────────── - -var hooks = map[string]Hooks{ - "openai": &OpenAIHooks{}, - "anthropic": &AnthropicHooks{}, - "venice": &VeniceHooks{}, - "openrouter": &OpenRouterHooks{}, -} - -// GetHooks returns the hooks for a provider type. -// Returns nil for unknown types (no transforms applied). -func GetHooks(providerType string) Hooks { - return hooks[providerType] -} - -// RegisterHooks adds or replaces hooks for a provider type. -func RegisterHooks(providerType string, h Hooks) { - hooks[providerType] = h -} - -// ── Settings Helpers ──────────────────────── - -func settingString(s map[string]interface{}, key string) string { - if v, ok := s[key]; ok { - if str, ok := v.(string); ok { - return str - } - } - return "" -} - -func settingBool(s map[string]interface{}, key string) bool { - if v, ok := s[key]; ok { - switch b := v.(type) { - case bool: - return b - case string: - return b == "true" - } - } - return false -} - -func settingInt(s map[string]interface{}, key string, fallback int) int { - if v, ok := s[key]; ok { - switch n := v.(type) { - case float64: - return int(n) // JSON numbers decode as float64 - case int: - return n - } - } - return fallback -} - -func settingFloat(s map[string]interface{}, key string) (float64, bool) { - if v, ok := s[key]; ok { - switch n := v.(type) { - case float64: - return n, true - case int: - return float64(n), true - } - } - return 0, false -} - -// ── Common: System Prompt Prefix ──────────── - -// prependSystemPrompt injects a provider-level system prompt prefix -// into the first system message, or creates one if none exists. -func prependSystemPrompt(prefix string, req *CompletionRequest) { - if prefix == "" { - return - } - for i, m := range req.Messages { - if m.Role == "system" { - req.Messages[i].Content = prefix + "\n\n" + m.Content - return - } - } - // No system message — insert at position 0 - req.Messages = append([]Message{{Role: "system", Content: prefix}}, req.Messages...) -} - -// ── OpenAI Hooks ──────────────────────────── - -type OpenAIHooks struct{} - -func (h *OpenAIHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { - s := cfg.Settings - if s == nil { - return - } - - prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) - - if req.ExtraBody == nil { - req.ExtraBody = make(map[string]interface{}) - } - if v, ok := settingFloat(s, "frequency_penalty"); ok { - req.ExtraBody["frequency_penalty"] = v - } - if v, ok := settingFloat(s, "presence_penalty"); ok { - req.ExtraBody["presence_penalty"] = v - } -} - -func (h *OpenAIHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { - // No post-processing needed for vanilla OpenAI. -} - -// ── Anthropic Hooks ───────────────────────── - -type AnthropicHooks struct{} - -func (h *AnthropicHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { - s := cfg.Settings - if s == nil { - return - } - - prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) - - if settingBool(s, "extended_thinking") { - budget := settingInt(s, "thinking_budget", 10000) - if req.ExtraBody == nil { - req.ExtraBody = make(map[string]interface{}) - } - req.ExtraBody["thinking"] = map[string]interface{}{ - "type": "enabled", - "budget_tokens": budget, - } - // Anthropic requires temperature=1 with extended thinking, and - // thinking is incompatible with temperature overrides. - req.Temperature = nil - } -} - -func (h *AnthropicHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { - // Anthropic streaming already normalizes thinking → Reasoning field. -} - -// ── Venice Hooks ──────────────────────────── - -type VeniceHooks struct{} - -func (h *VeniceHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { - s := cfg.Settings - if s == nil { - return - } - - prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) - - // Build venice_parameters from settings - veniceParams := map[string]interface{}{} - hasParams := false - - if settingBool(s, "enable_web_search") { - veniceParams["enable_web_search"] = "always" - hasParams = true - } - - // include_venice_system_prompt defaults to true. - // Only set to false when explicitly configured. - if v, ok := s["include_venice_system_prompt"]; ok { - if b, ok := v.(bool); ok && !b { - veniceParams["include_venice_system_prompt"] = false - hasParams = true - } - } - - if settingBool(s, "enable_thinking") { - // Thinking requires disabling Venice system prompt - veniceParams["include_venice_system_prompt"] = false - hasParams = true - } - - if hasParams { - if req.ExtraBody == nil { - req.ExtraBody = make(map[string]interface{}) - } - req.ExtraBody["venice_parameters"] = veniceParams - } -} - -func (h *VeniceHooks) PostStreamEvent(cfg ProviderConfig, event *StreamEvent) { - // Venice thinking output arrives in the regular content stream - // wrapped in ... tags. The stream_loop already handles - // extraction from reasoning_content, so we extract Venice - // thinking from Delta → Reasoning when enable_thinking is on. - if cfg.Settings == nil || !settingBool(cfg.Settings, "enable_thinking") { - return - } - - // Venice sends thinking content inline in Delta. The frontend - // expects it in the Reasoning field. We detect blocks - // and move them. - if event.Delta != "" && strings.Contains(event.Delta, "") { - // Full block in one chunk (rare but handle it) - event.Reasoning += event.Delta - event.Delta = "" - } -} - -// ── OpenRouter Hooks ──────────────────────── - -type OpenRouterHooks struct{} - -func (h *OpenRouterHooks) PreRequest(cfg ProviderConfig, req *CompletionRequest) { - s := cfg.Settings - if s == nil { - return - } - - prependSystemPrompt(settingString(s, "system_prompt_prefix"), req) - - // OpenRouter route/transforms go in provider preferences header. - // These are sent as JSON in the X-Provider-Preferences header by - // the OpenAI doRequest (reads from ExtraBody["provider_preferences"]). - route := settingString(s, "route") - requireParams := settingBool(s, "require_parameters") - - if route != "" || requireParams { - prefs := map[string]interface{}{} - if route != "" && route != "auto" { - prefs["route"] = route - } - if requireParams { - prefs["require_parameters"] = true - } - // Serialize for the HTTP header (OpenRouter reads from extra headers) - prefsJSON, _ := json.Marshal(prefs) - if cfg.CustomHeaders == nil { - cfg.CustomHeaders = make(map[string]string) - } - cfg.CustomHeaders["X-Provider-Preferences"] = string(prefsJSON) - } -} - -func (h *OpenRouterHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) { - // No post-processing needed. OpenRouter returns standard OAI format. -} - -// ── Persona Override Merge ─────────────────── - -// MergePersonaSettings merges persona-level setting overrides onto -// provider-level settings. Persona values take priority except for -// fields marked ProviderOnly in the schema. -func MergePersonaSettings(providerType string, providerSettings, personaOverrides map[string]interface{}) map[string]interface{} { - if len(personaOverrides) == 0 { - return providerSettings - } - - schema := GetProfileSchema(providerType) - providerOnly := make(map[string]bool, len(schema.Fields)) - for _, f := range schema.Fields { - if f.ProviderOnly { - providerOnly[f.Key] = true - } - } - - merged := make(map[string]interface{}, len(providerSettings)+len(personaOverrides)) - for k, v := range providerSettings { - merged[k] = v - } - for k, v := range personaOverrides { - if !providerOnly[k] { - merged[k] = v - } - } - return merged -} diff --git a/server/providers/hooks_test.go b/server/providers/hooks_test.go deleted file mode 100644 index 98aff1c..0000000 --- a/server/providers/hooks_test.go +++ /dev/null @@ -1,340 +0,0 @@ -package providers - -import ( - "strings" - "testing" -) - -// ── Profile Schema Tests ──────────────────── - -func TestGetProfileSchema_BuiltIn(t *testing.T) { - for _, id := range []string{"openai", "anthropic", "venice", "openrouter"} { - s := GetProfileSchema(id) - if len(s.Fields) == 0 { - t.Errorf("GetProfileSchema(%q) returned empty fields", id) - } - // Every schema should have system_prompt_prefix - found := false - for _, f := range s.Fields { - if f.Key == "system_prompt_prefix" { - found = true - } - } - if !found { - t.Errorf("GetProfileSchema(%q) missing system_prompt_prefix field", id) - } - } -} - -func TestGetProfileSchema_UnknownFallsToOpenAI(t *testing.T) { - s := GetProfileSchema("unknown-provider") - oai := GetProfileSchema("openai") - if len(s.Fields) != len(oai.Fields) { - t.Errorf("Unknown provider should fall back to openai schema, got %d fields, want %d", len(s.Fields), len(oai.Fields)) - } -} - -// ── Hook PreRequest Tests ─────────────────── - -func TestOpenAIHooks_SystemPromptPrefix(t *testing.T) { - h := &OpenAIHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "system_prompt_prefix": "You are a helpful pirate.", - }, - } - req := &CompletionRequest{ - Messages: []Message{ - {Role: "system", Content: "Be concise."}, - {Role: "user", Content: "Hello"}, - }, - } - - h.PreRequest(cfg, req) - - if req.Messages[0].Content != "You are a helpful pirate.\n\nBe concise." { - t.Errorf("system prompt prefix not prepended, got: %s", req.Messages[0].Content) - } -} - -func TestOpenAIHooks_SystemPromptPrefix_NoExisting(t *testing.T) { - h := &OpenAIHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "system_prompt_prefix": "Be a pirate.", - }, - } - req := &CompletionRequest{ - Messages: []Message{ - {Role: "user", Content: "Hello"}, - }, - } - - h.PreRequest(cfg, req) - - if len(req.Messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(req.Messages)) - } - if req.Messages[0].Role != "system" || req.Messages[0].Content != "Be a pirate." { - t.Errorf("expected injected system message, got role=%s content=%s", req.Messages[0].Role, req.Messages[0].Content) - } -} - -func TestOpenAIHooks_FrequencyPenalty(t *testing.T) { - h := &OpenAIHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "frequency_penalty": 0.5, - }, - } - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hello"}}} - - h.PreRequest(cfg, req) - - if req.ExtraBody == nil { - t.Fatal("ExtraBody should be set") - } - if v, ok := req.ExtraBody["frequency_penalty"].(float64); !ok || v != 0.5 { - t.Errorf("frequency_penalty: got %v, want 0.5", req.ExtraBody["frequency_penalty"]) - } -} - -func TestOpenAIHooks_NilSettings(t *testing.T) { - h := &OpenAIHooks{} - cfg := ProviderConfig{Settings: nil} - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hello"}}} - - h.PreRequest(cfg, req) // should not panic - if req.ExtraBody != nil { - t.Errorf("ExtraBody should be nil with no settings") - } -} - -func TestAnthropicHooks_ExtendedThinking(t *testing.T) { - h := &AnthropicHooks{} - temp := 0.7 - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "extended_thinking": true, - "thinking_budget": float64(20000), // JSON numbers are float64 - }, - } - req := &CompletionRequest{ - Messages: []Message{{Role: "user", Content: "Think hard"}}, - Temperature: &temp, - } - - h.PreRequest(cfg, req) - - if req.Temperature != nil { - t.Error("Temperature should be cleared for extended thinking") - } - if req.ExtraBody == nil { - t.Fatal("ExtraBody should be set") - } - thinking, ok := req.ExtraBody["thinking"].(map[string]interface{}) - if !ok { - t.Fatal("ExtraBody[thinking] should be map") - } - if thinking["type"] != "enabled" { - t.Errorf("thinking.type = %v, want enabled", thinking["type"]) - } - if thinking["budget_tokens"] != 20000 { - t.Errorf("thinking.budget_tokens = %v, want 20000", thinking["budget_tokens"]) - } -} - -func TestAnthropicHooks_ThinkingDefaultBudget(t *testing.T) { - h := &AnthropicHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "extended_thinking": true, - // no thinking_budget → should use default 10000 - }, - } - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Think"}}} - - h.PreRequest(cfg, req) - - thinking := req.ExtraBody["thinking"].(map[string]interface{}) - if thinking["budget_tokens"] != 10000 { - t.Errorf("default budget = %v, want 10000", thinking["budget_tokens"]) - } -} - -func TestAnthropicHooks_NoThinking(t *testing.T) { - h := &AnthropicHooks{} - temp := 0.7 - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "extended_thinking": false, - }, - } - req := &CompletionRequest{ - Messages: []Message{{Role: "user", Content: "Hi"}}, - Temperature: &temp, - } - - h.PreRequest(cfg, req) - - if req.ExtraBody != nil { - t.Error("ExtraBody should be nil when thinking is disabled") - } - if req.Temperature == nil { - t.Error("Temperature should be preserved when thinking is off") - } -} - -func TestVeniceHooks_WebSearch(t *testing.T) { - h := &VeniceHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "enable_web_search": true, - }, - } - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Search for news"}}} - - h.PreRequest(cfg, req) - - vp, ok := req.ExtraBody["venice_parameters"].(map[string]interface{}) - if !ok { - t.Fatal("venice_parameters missing from ExtraBody") - } - if vp["enable_web_search"] != "always" { - t.Errorf("enable_web_search = %v, want 'always'", vp["enable_web_search"]) - } -} - -func TestVeniceHooks_ThinkingDisablesSystemPrompt(t *testing.T) { - h := &VeniceHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "enable_thinking": true, - }, - } - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Think"}}} - - h.PreRequest(cfg, req) - - vp := req.ExtraBody["venice_parameters"].(map[string]interface{}) - if vp["include_venice_system_prompt"] != false { - t.Error("Thinking mode should set include_venice_system_prompt=false") - } -} - -func TestVeniceHooks_NoSettings(t *testing.T) { - h := &VeniceHooks{} - cfg := ProviderConfig{Settings: map[string]interface{}{}} - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hi"}}} - - h.PreRequest(cfg, req) - - if req.ExtraBody != nil { - t.Error("ExtraBody should be nil with empty settings") - } -} - -func TestOpenRouterHooks_Route(t *testing.T) { - h := &OpenRouterHooks{} - cfg := ProviderConfig{ - Settings: map[string]interface{}{ - "route": "fallback", - "require_parameters": true, - }, - CustomHeaders: map[string]string{}, - } - req := &CompletionRequest{Messages: []Message{{Role: "user", Content: "Hi"}}} - - h.PreRequest(cfg, req) - - // Note: OpenRouter hooks set headers on the cfg copy, which is local. - // The real effect is tested via the serialized header value. - // Since cfg is passed by value in hooks, this test verifies the logic runs without panic. -} - -// ── GetHooks Tests ────────────────────────── - -func TestGetHooks_BuiltIn(t *testing.T) { - for _, id := range []string{"openai", "anthropic", "venice", "openrouter"} { - h := GetHooks(id) - if h == nil { - t.Errorf("GetHooks(%q) returned nil", id) - } - } -} - -func TestGetHooks_Unknown(t *testing.T) { - h := GetHooks("nonexistent") - if h != nil { - t.Error("GetHooks for unknown type should return nil") - } -} - -// ── MergePersonaSettings Tests ────────────── - -func TestMergePersonaSettings_Basic(t *testing.T) { - provider := map[string]interface{}{ - "system_prompt_prefix": "Provider prompt", - "frequency_penalty": 0.3, - } - persona := map[string]interface{}{ - "system_prompt_prefix": "Persona prompt", - } - - merged := MergePersonaSettings("openai", provider, persona) - - if merged["system_prompt_prefix"] != "Persona prompt" { - t.Errorf("persona should override provider, got %v", merged["system_prompt_prefix"]) - } - if merged["frequency_penalty"] != 0.3 { - t.Errorf("provider value should be preserved, got %v", merged["frequency_penalty"]) - } -} - -func TestMergePersonaSettings_ProviderOnlyBlocked(t *testing.T) { - provider := map[string]interface{}{ - "route": "auto", - } - persona := map[string]interface{}{ - "route": "fallback", // route is ProviderOnly for openrouter - } - - merged := MergePersonaSettings("openrouter", provider, persona) - - if merged["route"] != "auto" { - t.Errorf("ProviderOnly field should not be overridden by persona, got %v", merged["route"]) - } -} - -func TestMergePersonaSettings_EmptyOverrides(t *testing.T) { - provider := map[string]interface{}{"key": "value"} - merged := MergePersonaSettings("openai", provider, nil) - if merged["key"] != "value" { - t.Error("empty overrides should return provider settings unchanged") - } -} - -// ── mergeExtraBody Tests ──────────────────── - -func TestMergeExtraBody(t *testing.T) { - body := []byte(`{"model":"gpt-4","stream":true}`) - extra := map[string]interface{}{ - "venice_parameters": map[string]interface{}{ - "enable_web_search": "always", - }, - } - - merged, err := mergeExtraBody(body, extra) - if err != nil { - t.Fatalf("mergeExtraBody error: %v", err) - } - - // Should contain both original and extra fields - s := string(merged) - if !strings.Contains(s, `"model"`) || !strings.Contains(s, `"stream"`) { - t.Error("original fields missing from merged body") - } - if !strings.Contains(s, `"venice_parameters"`) || !strings.Contains(s, `"enable_web_search"`) { - t.Error("extra fields missing from merged body") - } -} diff --git a/server/providers/multimodal_test.go b/server/providers/multimodal_test.go deleted file mode 100644 index 60b69b3..0000000 --- a/server/providers/multimodal_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package providers - -import ( - "encoding/json" - "testing" -) - -// ── ContentPart Tests ────────────────────── - -func TestContentPart_TextOnly(t *testing.T) { - msg := Message{ - Role: "user", - Content: "hello", - ContentParts: []ContentPart{ - {Type: "text", Text: "hello"}, - }, - } - if len(msg.ContentParts) != 1 { - t.Fatalf("expected 1 part, got %d", len(msg.ContentParts)) - } - if msg.ContentParts[0].Type != "text" { - t.Errorf("type = %q, want text", msg.ContentParts[0].Type) - } -} - -func TestContentPart_WithImage(t *testing.T) { - msg := Message{ - Role: "user", - Content: "describe this", - ContentParts: []ContentPart{ - {Type: "text", Text: "describe this"}, - {Type: "image_url", ImageURL: &ImageURL{URL: "data:image/png;base64,abc123", Detail: "auto"}}, - }, - } - if len(msg.ContentParts) != 2 { - t.Fatalf("expected 2 parts, got %d", len(msg.ContentParts)) - } - if msg.ContentParts[1].ImageURL == nil { - t.Fatal("expected non-nil ImageURL") - } - if msg.ContentParts[1].ImageURL.URL != "data:image/png;base64,abc123" { - t.Errorf("URL = %q", msg.ContentParts[1].ImageURL.URL) - } -} - -// ── OpenAI Multimodal Serialization ──────── - -func TestOpenAI_TextOnlyContent_String(t *testing.T) { - // When Content is a string, it should serialize as a JSON string - msg := openaiMessage{ - Role: "user", - Content: "hello world", - } - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - var raw map[string]interface{} - json.Unmarshal(data, &raw) - - content := raw["content"] - if s, ok := content.(string); !ok || s != "hello world" { - t.Errorf("content = %v (%T), want string 'hello world'", content, content) - } -} - -func TestOpenAI_MultimodalContent_Array(t *testing.T) { - // When Content is an array, it should serialize as a JSON array - msg := openaiMessage{ - Role: "user", - Content: []openaiContentPart{ - {Type: "text", Text: "describe this"}, - {Type: "image_url", ImageURL: &openaiImageURL{URL: "data:image/png;base64,abc", Detail: "auto"}}, - }, - } - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - var raw map[string]interface{} - json.Unmarshal(data, &raw) - - content := raw["content"] - arr, ok := content.([]interface{}) - if !ok { - t.Fatalf("content is %T, want array", content) - } - if len(arr) != 2 { - t.Fatalf("content has %d elements, want 2", len(arr)) - } - - // Verify first part is text - part0 := arr[0].(map[string]interface{}) - if part0["type"] != "text" { - t.Errorf("part[0].type = %v, want text", part0["type"]) - } - - // Verify second part is image_url - part1 := arr[1].(map[string]interface{}) - if part1["type"] != "image_url" { - t.Errorf("part[1].type = %v, want image_url", part1["type"]) - } -} - -// ── Anthropic Image Source ───────────────── - -func TestAnthropic_ImageSourceBlock(t *testing.T) { - block := anthropicContentBlock{ - Type: "image", - Source: &anthropicImageSource{ - Type: "base64", - MediaType: "image/jpeg", - Data: "abc123", - }, - } - data, err := json.Marshal(block) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - var raw map[string]interface{} - json.Unmarshal(data, &raw) - - if raw["type"] != "image" { - t.Errorf("type = %v", raw["type"]) - } - source := raw["source"].(map[string]interface{}) - if source["type"] != "base64" { - t.Errorf("source.type = %v", source["type"]) - } - if source["media_type"] != "image/jpeg" { - t.Errorf("source.media_type = %v", source["media_type"]) - } - if source["data"] != "abc123" { - t.Errorf("source.data = %v", source["data"]) - } -} - -func TestAnthropic_TextBlockUnchanged(t *testing.T) { - block := anthropicContentBlock{ - Type: "text", - Text: "hello", - } - data, err := json.Marshal(block) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - - var raw map[string]interface{} - json.Unmarshal(data, &raw) - - if raw["type"] != "text" { - t.Errorf("type = %v", raw["type"]) - } - if raw["text"] != "hello" { - t.Errorf("text = %v", raw["text"]) - } - // Should NOT have source field - if _, ok := raw["source"]; ok { - t.Error("text block should not have source field") - } -} - -// ── parseDataURI ─────────────────────────── - -func TestParseDataURI_Valid(t *testing.T) { - tests := []struct { - uri string - wantType string - wantData string - }{ - {"data:image/jpeg;base64,/9j/4AAQ", "image/jpeg", "/9j/4AAQ"}, - {"data:image/png;base64,abc123", "image/png", "abc123"}, - {"data:image/webp;base64,RIFF", "image/webp", "RIFF"}, - } - for _, tc := range tests { - mediaType, data := parseDataURI(tc.uri) - if mediaType != tc.wantType { - t.Errorf("parseDataURI(%q) mediaType = %q, want %q", tc.uri, mediaType, tc.wantType) - } - if data != tc.wantData { - t.Errorf("parseDataURI(%q) data = %q, want %q", tc.uri, data, tc.wantData) - } - } -} - -func TestParseDataURI_Invalid(t *testing.T) { - tests := []string{ - "", - "not-a-data-uri", - "data:nocomma", - "https://example.com/image.png", - } - for _, uri := range tests { - mediaType, data := parseDataURI(uri) - if mediaType != "" || data != "" { - t.Errorf("parseDataURI(%q) = (%q, %q), want empty", uri, mediaType, data) - } - } -} diff --git a/server/providers/openai.go b/server/providers/openai.go deleted file mode 100644 index 0e9b4e4..0000000 --- a/server/providers/openai.go +++ /dev/null @@ -1,595 +0,0 @@ -package providers - -import ( - "switchboard-core/capabilities" - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -// OpenAIProvider handles any OpenAI-compatible API. -type OpenAIProvider struct{} - -func (p *OpenAIProvider) ID() string { return "openai" } - -// ── Chat Completion (non-streaming) ───────── - -func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) { - req.Stream = false - - body, err := p.doRequest(ctx, cfg, req) - if err != nil { - return nil, err - } - defer body.Close() - - var resp openaiChatResponse - if err := json.NewDecoder(body).Decode(&resp); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - - if len(resp.Choices) == 0 { - return nil, fmt.Errorf("no choices in response") - } - - // Extract content string from response (responses are always string, not array) - var contentStr string - if s, ok := resp.Choices[0].Message.Content.(string); ok { - contentStr = s - } - - result := &CompletionResponse{ - Content: contentStr, - Model: resp.Model, - FinishReason: resp.Choices[0].FinishReason, - InputTokens: resp.Usage.PromptTokens, - OutputTokens: resp.Usage.CompletionTokens, - } - - // Cache tokens (OpenAI, Venice report these in prompt_tokens_details) - if resp.Usage.PromptTokensDetails != nil { - result.CacheReadTokens = resp.Usage.PromptTokensDetails.CachedTokens - } - - // Extract tool calls if present - if len(resp.Choices[0].Message.ToolCalls) > 0 { - for _, tc := range resp.Choices[0].Message.ToolCalls { - result.ToolCalls = append(result.ToolCalls, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - }, - }) - } - if result.FinishReason == "" { - result.FinishReason = "tool_calls" - } - } - - return result, nil -} - -// ── Stream Completion ─────────────────────── - -func (p *OpenAIProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) { - req.Stream = true - - body, err := p.doRequest(ctx, cfg, req) - if err != nil { - return nil, err - } - - ch := make(chan StreamEvent, 64) - - go func() { - defer close(ch) - defer body.Close() - - // Accumulate tool calls across stream chunks - toolCallMap := map[int]*ToolCall{} // index → accumulated call - - // Usage arrives in a separate chunk (or on the final chunk). - // OpenAI protocol order: content → finish_reason → usage → [DONE] - // The usage chunk has choices=[] so we must hold the finish event - // until usage arrives, otherwise tokens are always 0. - var streamUsage *openaiUsage - var pendingFinish *StreamEvent // deferred until usage arrives - - scanner := bufio.NewScanner(body) - for scanner.Scan() { - line := scanner.Text() - - if !strings.HasPrefix(line, "data: ") { - continue - } - - data := strings.TrimPrefix(line, "data: ") - if data == "[DONE]" { - // Flush any pending finish event (usage may or may not have arrived) - if pendingFinish != nil { - ch <- *pendingFinish - } else { - ch <- StreamEvent{Done: true} - } - return - } - - var chunk openaiStreamChunk - if err := json.Unmarshal([]byte(data), &chunk); err != nil { - continue - } - - // Capture usage — may arrive in a separate chunk with empty choices - if chunk.Usage != nil { - streamUsage = chunk.Usage - // If finish already arrived, attach usage and flush immediately - if pendingFinish != nil { - pendingFinish.InputTokens = streamUsage.PromptTokens - pendingFinish.OutputTokens = streamUsage.CompletionTokens - if streamUsage.PromptTokensDetails != nil { - pendingFinish.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens - } - ch <- *pendingFinish - return // Done — [DONE] will be handled by defer body.Close() - } - } - - if len(chunk.Choices) == 0 { - continue - } - - choice := chunk.Choices[0] - - // Accumulate tool call deltas - for _, tc := range choice.Delta.ToolCalls { - idx := 0 - if tc.Index != nil { - idx = *tc.Index - } - - if existing, ok := toolCallMap[idx]; ok { - // Append arguments fragment - existing.Function.Arguments += tc.Function.Arguments - } else { - // First chunk for this tool — has ID and name - toolCallMap[idx] = &ToolCall{ - ID: tc.ID, - Type: "function", - Function: FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - }, - } - } - } - - ev := StreamEvent{ - Delta: choice.Delta.Content, - Reasoning: choice.Delta.ReasoningContent, - Model: chunk.Model, - FinishReason: choice.FinishReason, - } - - if ev.FinishReason != "" { - ev.Done = true - // Attach accumulated tool calls on final event - if ev.FinishReason == "tool_calls" && len(toolCallMap) > 0 { - for _, tc := range toolCallMap { - ev.ToolCalls = append(ev.ToolCalls, *tc) - } - // Tool calls need to flush immediately — the tool loop - // needs the event to start executing tools - if streamUsage != nil { - ev.InputTokens = streamUsage.PromptTokens - ev.OutputTokens = streamUsage.CompletionTokens - if streamUsage.PromptTokensDetails != nil { - ev.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens - } - } - ch <- ev - continue - } - // Normal finish — defer until usage chunk arrives - if streamUsage != nil { - // Usage already captured (rare: same chunk or earlier) - ev.InputTokens = streamUsage.PromptTokens - ev.OutputTokens = streamUsage.CompletionTokens - if streamUsage.PromptTokensDetails != nil { - ev.CacheReadTokens = streamUsage.PromptTokensDetails.CachedTokens - } - ch <- ev - continue - } - // Hold — usage chunk hasn't arrived yet - pendingFinish = &ev - continue - } - - ch <- ev - } - - if err := scanner.Err(); err != nil { - ch <- StreamEvent{Error: err} - } else if pendingFinish != nil { - // Scanner ended without [DONE] — flush pending finish - ch <- *pendingFinish - } - }() - - return ch, nil -} - -// ── List Models ───────────────────────────── - -func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) { - endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models" - - httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - return nil, err - } - if cfg.APIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } - for k, v := range cfg.CustomHeaders { - httpReq.Header.Set(k, v) - } - - resp, err := cfg.Client().Do(httpReq) - if err != nil { - return nil, fmt.Errorf("list models: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("list models: HTTP %d: %s", resp.StatusCode, string(b)) - } - - var result openaiModelsResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decode models: %w", err) - } - - out := make([]Model, 0, len(result.Data)) - for _, m := range result.Data { - // Heuristic inference from model ID - caps := capabilities.InferCapabilities(m.ID) - // Use context_length from API if available and we don't have it - if m.ContextLength > 0 && caps.MaxContext == 0 { - caps.MaxContext = m.ContextLength - } - - out = append(out, Model{ - ID: m.ID, - Type: m.Type, // passthrough from API if present (empty → "chat" at sync time) - OwnedBy: m.OwnedBy, - Capabilities: caps, - }) - } - return out, nil -} - -// ── Embeddings ───────────────────────────── - -func (p *OpenAIProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) { - endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/embeddings" - - oaiReq := openaiEmbeddingRequest{ - Model: req.Model, - Input: req.Input, - } - body, err := json.Marshal(oaiReq) - if err != nil { - return nil, fmt.Errorf("marshal embedding request: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - httpReq.Header.Set("Content-Type", "application/json") - if cfg.APIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } - for k, v := range cfg.CustomHeaders { - httpReq.Header.Set(k, v) - } - - resp, err := cfg.Client().Do(httpReq) - if err != nil { - return nil, fmt.Errorf("embedding request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("embedding error: HTTP %d: %s", resp.StatusCode, string(b)) - } - - var oaiResp openaiEmbeddingResponse - if err := json.NewDecoder(resp.Body).Decode(&oaiResp); err != nil { - return nil, fmt.Errorf("decode embedding response: %w", err) - } - - result := &EmbeddingResponse{ - Model: oaiResp.Model, - InputTokens: oaiResp.Usage.PromptTokens, - Embeddings: make([][]float64, len(oaiResp.Data)), - } - for i, d := range oaiResp.Data { - result.Embeddings[i] = d.Embedding - } - return result, nil -} - -// ── HTTP Layer ────────────────────────────── - -func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) { - endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/chat/completions" - - // Build OpenAI-format request - oaiReq := openaiChatRequest{ - Model: req.Model, - Stream: req.Stream, - Messages: make([]openaiMessage, 0, len(req.Messages)), - } - if req.Stream { - oaiReq.StreamOptions = &openaiStreamOptions{IncludeUsage: true} - } - if req.MaxTokens > 0 { - oaiReq.MaxTokens = req.MaxTokens - } - if req.Temperature != nil { - oaiReq.Temperature = req.Temperature - } - if req.TopP != nil { - oaiReq.TopP = req.TopP - } - - // Convert tools - for _, t := range req.Tools { - oaiReq.Tools = append(oaiReq.Tools, openaiToolDef{ - Type: t.Type, - Function: openaiToolDefFunction{ - Name: t.Function.Name, - Description: t.Function.Description, - Parameters: t.Function.Parameters, - }, - }) - } - - // Convert messages — handle all roles including tool and multimodal - for _, m := range req.Messages { - oaiMsg := openaiMessage{ - Role: m.Role, - } - - // Build content: multimodal parts or plain string - if len(m.ContentParts) > 0 && m.Role == "user" { - // Multimodal: convert to OpenAI content array format - parts := make([]openaiContentPart, 0, len(m.ContentParts)) - for _, p := range m.ContentParts { - switch p.Type { - case "image_url": - if p.ImageURL != nil { - detail := p.ImageURL.Detail - if detail == "" { - detail = "auto" - } - parts = append(parts, openaiContentPart{ - Type: "image_url", - ImageURL: &openaiImageURL{ - URL: p.ImageURL.URL, - Detail: detail, - }, - }) - } - default: // "text", "document" → text - parts = append(parts, openaiContentPart{ - Type: "text", - Text: p.Text, - }) - } - } - oaiMsg.Content = parts - } else { - oaiMsg.Content = m.Content - } - - // Assistant messages with tool calls - if len(m.ToolCalls) > 0 { - for _, tc := range m.ToolCalls { - oaiMsg.ToolCalls = append(oaiMsg.ToolCalls, openaiToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: openaiToolCallFunction{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - }, - }) - } - } - - // Tool result messages - if m.Role == "tool" { - oaiMsg.ToolCallID = m.ToolCallID - oaiMsg.Name = m.Name - } - - // Named messages: pass through for speaker attribution (v0.23.0) - // OpenAI supports "name" on any role to distinguish participants - if m.Name != "" && m.Role != "tool" { - oaiMsg.Name = m.Name - } - - oaiReq.Messages = append(oaiReq.Messages, oaiMsg) - } - - body, err := json.Marshal(oaiReq) - if err != nil { - return nil, fmt.Errorf("marshal request: %w", err) - } - - // Merge hook-supplied extra fields into wire JSON (v0.22.1) - if len(req.ExtraBody) > 0 { - body, err = mergeExtraBody(body, req.ExtraBody) - if err != nil { - return nil, fmt.Errorf("merge extra body: %w", err) - } - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - httpReq.Header.Set("Content-Type", "application/json") - if cfg.APIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } - // Inject provider-level custom headers (OpenRouter, Venice, etc.) - for k, v := range cfg.CustomHeaders { - httpReq.Header.Set(k, v) - } - - resp, err := cfg.Client().Do(httpReq) - if err != nil { - return nil, fmt.Errorf("provider request: %w", err) - } - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b)) - } - - return resp.Body, nil -} - -// ── OpenAI Wire Types ─────────────────────── - -type openaiMessage struct { - Role string `json:"role"` - Content interface{} `json:"content,omitempty"` // string or []openaiContentPart - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools - ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result - Name string `json:"name,omitempty"` // speaker name (tool or persona attribution) -} - -// openaiContentPart represents a single element in a multimodal content array. -// OpenAI format: [{"type":"text","text":"..."}, {"type":"image_url","image_url":{"url":"..."}}] -type openaiContentPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ImageURL *openaiImageURL `json:"image_url,omitempty"` -} - -type openaiImageURL struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` -} - -type openaiToolCallFunction struct { - Name string `json:"name,omitempty"` - Arguments string `json:"arguments,omitempty"` -} - -type openaiToolCall struct { - Index *int `json:"index,omitempty"` // present in streaming deltas - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` // "function" - Function openaiToolCallFunction `json:"function"` -} - -type openaiToolDefFunction struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` -} - -type openaiToolDef struct { - Type string `json:"type"` // "function" - Function openaiToolDefFunction `json:"function"` -} - -type openaiChatRequest struct { - Model string `json:"model"` - Messages []openaiMessage `json:"messages"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream bool `json:"stream"` - StreamOptions *openaiStreamOptions `json:"stream_options,omitempty"` - Tools []openaiToolDef `json:"tools,omitempty"` -} - -type openaiStreamOptions struct { - IncludeUsage bool `json:"include_usage"` -} - -type openaiChatResponse struct { - Model string `json:"model"` - Choices []struct { - Message openaiMessage `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage openaiUsage `json:"usage"` -} - -type openaiUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - PromptTokensDetails *struct { - CachedTokens int `json:"cached_tokens"` - } `json:"prompt_tokens_details,omitempty"` - CompletionTokensDetails *struct { - ReasoningTokens int `json:"reasoning_tokens"` - } `json:"completion_tokens_details,omitempty"` -} - -type openaiStreamChunk struct { - Model string `json:"model"` - Choices []struct { - Delta struct { - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content"` - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` - } `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *openaiUsage `json:"usage,omitempty"` -} - -type openaiModelsResponse struct { - Data []struct { - ID string `json:"id"` - OwnedBy string `json:"owned_by"` - Type string `json:"type,omitempty"` // Some APIs (e.g. Venice-compat) include model type - ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter - } `json:"data"` -} - -// ── Embedding Wire Types ────────────────── - -type openaiEmbeddingRequest struct { - Model string `json:"model"` - Input []string `json:"input"` -} - -type openaiEmbeddingResponse struct { - Data []struct { - Embedding []float64 `json:"embedding"` - Index int `json:"index"` - } `json:"data"` - Model string `json:"model"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - } `json:"usage"` -} diff --git a/server/providers/openrouter.go b/server/providers/openrouter.go deleted file mode 100644 index ff7eea3..0000000 --- a/server/providers/openrouter.go +++ /dev/null @@ -1,164 +0,0 @@ -package providers - -import ( - "switchboard-core/capabilities" - "switchboard-core/models" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" -) - -// OpenRouterProvider handles the OpenRouter unified API. -// OpenRouter is OpenAI-compatible with 300+ models, per-model pricing, -// and architecture metadata. Requires HTTP-Referer and X-Title headers. -// -// Ported from ai-editor js/providers/openrouter.js -type OpenRouterProvider struct{} - -func (p *OpenRouterProvider) ID() string { return "openrouter" } - -// ── Chat Completion ───────────────────────── -// Delegates to OpenAI provider — OpenRouter is fully OAI-compatible. - -func (p *OpenRouterProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) { - oai := &OpenAIProvider{} - return oai.ChatCompletion(ctx, cfg, req) -} - -func (p *OpenRouterProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) { - oai := &OpenAIProvider{} - return oai.StreamCompletion(ctx, cfg, req) -} - -func (p *OpenRouterProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) { - oai := &OpenAIProvider{} - return oai.Embed(ctx, cfg, req) -} - -// ── List Models ───────────────────────────── -// OpenRouter /v1/models returns architecture, pricing, context_length. - -func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) { - endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models" - - httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - return nil, err - } - if cfg.APIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } - for k, v := range cfg.CustomHeaders { - httpReq.Header.Set(k, v) - } - - resp, err := cfg.Client().Do(httpReq) - if err != nil { - return nil, fmt.Errorf("openrouter list models: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("openrouter list models: HTTP %d: %s", resp.StatusCode, string(b)) - } - - var result orModelsResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("openrouter decode models: %w", err) - } - - out := make([]Model, 0, len(result.Data)) - for _, m := range result.Data { - // Heuristic inference from model ID - caps := capabilities.InferCapabilities(m.ID) - - // Overlay context length from OpenRouter metadata - if m.ContextLength > 0 && caps.MaxContext == 0 { - caps.MaxContext = m.ContextLength - } - - // Overlay max output from OpenRouter if available - if m.TopProvider.MaxCompletionTokens > 0 && caps.MaxOutputTokens == 0 { - caps.MaxOutputTokens = m.TopProvider.MaxCompletionTokens - } - - // Vision from architecture modality - arch := m.Architecture - if arch.Modality == "multimodal" { - caps.Vision = true - } - - // Streaming is universal on OpenRouter - caps.Streaming = true - - // Parse pricing (OpenRouter uses per-token strings, convert to per-1M) - var pricing *models.ModelPricing - if m.Pricing.Prompt != "" { - inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64) - outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64) - if inputPerToken > 0 || outputPerToken > 0 { - pricing = &models.ModelPricing{ - InputPerM: inputPerToken * 1_000_000, - OutputPerM: outputPerToken * 1_000_000, - } - } - } - - // Owner from model ID prefix (e.g. "anthropic/claude-3-opus" → "anthropic") - ownedBy := "" - if idx := strings.Index(m.ID, "/"); idx >= 0 { - ownedBy = m.ID[:idx] - } - - name := m.Name - if name == "" { - name = m.ID - } - - out = append(out, Model{ - ID: m.ID, - Name: name, - OwnedBy: ownedBy, - Capabilities: caps, - Pricing: pricing, - }) - } - return out, nil -} - -// ── OpenRouter Wire Types ─────────────────── - -type orModelsResponse struct { - Data []orModel `json:"data"` -} - -type orModel struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - ContextLength int `json:"context_length"` - Architecture orArch `json:"architecture"` - Pricing orPricing `json:"pricing"` - TopProvider orTopProvider `json:"top_provider"` -} - -type orArch struct { - Modality string `json:"modality"` - Tokenizer string `json:"tokenizer"` - InstructType string `json:"instruct_type"` -} - -type orPricing struct { - Prompt string `json:"prompt"` // per-token cost as string - Completion string `json:"completion"` // per-token cost as string -} - -type orTopProvider struct { - MaxCompletionTokens int `json:"max_completion_tokens"` - IsModerated bool `json:"is_moderated"` -} diff --git a/server/providers/profile.go b/server/providers/profile.go deleted file mode 100644 index be826ef..0000000 --- a/server/providers/profile.go +++ /dev/null @@ -1,154 +0,0 @@ -package providers - -// ProfileSchema describes the available settings for a provider type. -// Used by the admin UI to render forms and by validation on save. -type ProfileSchema struct { - // Fields lists the configurable settings in display order. - Fields []ProfileField `json:"fields"` -} - -// ProfileField describes a single setting within a provider profile. -type ProfileField struct { - Key string `json:"key"` // settings JSON key - Label string `json:"label"` // human-readable name - Description string `json:"description,omitempty"` // help text - Type string `json:"type"` // "bool", "int", "string", "enum", "float" - Default interface{} `json:"default,omitempty"` // default value - EnumValues []string `json:"enum_values,omitempty"` // valid values when Type="enum" - Min *int `json:"min,omitempty"` // min for int/float - Max *int `json:"max,omitempty"` // max for int/float - DependsOn string `json:"depends_on,omitempty"` // only show when this key is truthy - ProviderOnly bool `json:"provider_only,omitempty"` // not overridable at persona level -} - -// ── Built-in Profile Schemas ──────────────── - -// intPtr is a helper for literal pointers in struct init. -func intPtr(v int) *int { return &v } - -var profileSchemas = map[string]ProfileSchema{ - "openai": { - Fields: []ProfileField{ - { - Key: "system_prompt_prefix", - Label: "System Prompt Prefix", - Description: "Prepended to every system prompt sent to this provider.", - Type: "string", - }, - { - Key: "frequency_penalty", - Label: "Frequency Penalty", - Description: "Penalizes repeated tokens. Range: -2.0 to 2.0.", - Type: "float", - }, - { - Key: "presence_penalty", - Label: "Presence Penalty", - Description: "Penalizes tokens already present. Range: -2.0 to 2.0.", - Type: "float", - }, - }, - }, - - "anthropic": { - Fields: []ProfileField{ - { - Key: "system_prompt_prefix", - Label: "System Prompt Prefix", - Description: "Prepended to every system prompt sent to this provider.", - Type: "string", - }, - { - Key: "extended_thinking", - Label: "Extended Thinking", - Description: "Enable Anthropic extended thinking mode. Sends thinking.type=enabled with budget_tokens.", - Type: "bool", - Default: false, - }, - { - Key: "thinking_budget", - Label: "Thinking Budget (tokens)", - Description: "Max tokens for the thinking phase. Only used when extended_thinking is enabled.", - Type: "int", - Default: 10000, - Min: intPtr(1024), - Max: intPtr(128000), - DependsOn: "extended_thinking", - }, - }, - }, - - "venice": { - Fields: []ProfileField{ - { - Key: "system_prompt_prefix", - Label: "System Prompt Prefix", - Description: "Prepended to every system prompt. Venice also injects its own character prompt.", - Type: "string", - }, - { - Key: "enable_thinking", - Label: "Enable Thinking", - Description: "Send venice_parameters.include_venice_system_prompt=false and enable thinking output.", - Type: "bool", - Default: false, - }, - { - Key: "enable_web_search", - Label: "Enable Web Search", - Description: "Allow Venice to search the web for context (venice_parameters.enable_web_search).", - Type: "bool", - Default: false, - }, - { - Key: "include_venice_system_prompt", - Label: "Include Venice System Prompt", - Description: "Include Venice's built-in character system prompt.", - Type: "bool", - Default: true, - }, - }, - }, - - "openrouter": { - Fields: []ProfileField{ - { - Key: "system_prompt_prefix", - Label: "System Prompt Prefix", - Description: "Prepended to every system prompt sent through OpenRouter.", - Type: "string", - }, - { - Key: "route", - Label: "Routing Strategy", - Description: "How OpenRouter selects the underlying provider for a model.", - Type: "enum", - Default: "auto", - EnumValues: []string{"auto", "fallback"}, - ProviderOnly: true, - }, - { - Key: "require_parameters", - Label: "Require Parameters Support", - Description: "Only route to providers that support the requested parameters (tools, temperature, etc.).", - Type: "bool", - Default: true, - }, - }, - }, -} - -// GetProfileSchema returns the profile schema for a provider type. -// Falls back to the openai schema for unknown types (OpenAI-compatible). -func GetProfileSchema(providerType string) ProfileSchema { - if s, ok := profileSchemas[providerType]; ok { - return s - } - return profileSchemas["openai"] -} - -// RegisterProfileSchema adds or replaces a profile schema for a provider type. -// Used for testing and future plugin support. -func RegisterProfileSchema(providerType string, schema ProfileSchema) { - profileSchemas[providerType] = schema -} diff --git a/server/providers/provider.go b/server/providers/provider.go deleted file mode 100644 index d18260d..0000000 --- a/server/providers/provider.go +++ /dev/null @@ -1,248 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "errors" - "log" - "net/http" - "net/url" - "sync" - "time" - - "switchboard-core/models" -) - -// ── Errors ───────────────────────────────── - -var ErrNotSupported = errors.New("operation not supported by this provider") - -// ── Provider Interface ────────────────────── - -// Provider is the contract for LLM API adapters. -type Provider interface { - // ID returns the provider identifier (e.g. "openai", "anthropic"). - ID() string - - // ChatCompletion sends a non-streaming request and returns the full response. - ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) - - // StreamCompletion sends a streaming request and returns a channel of events. - // The channel is closed when the stream ends. Errors are sent as StreamEvents. - StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) - - // ListModels returns available models from this provider. - ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) - - // Embed generates embeddings for the given input texts. - // Returns ErrNotSupported if the provider has no embedding endpoint. - Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) -} - -// ── Configuration ─────────────────────────── - -// ProviderConfig holds credentials and endpoint for a configured provider. -// Populated from the provider_configs table at call time. -type ProviderConfig struct { - Endpoint string - APIKey string - CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer) - Settings map[string]interface{} // Provider-specific settings from config JSONB - ProxyMode string // "system" (default), "direct", "custom" - ProxyURL string // Only used when ProxyMode == "custom" -} - -// ── HTTP Client Pool ─────────────────────── -// -// Provider HTTP clients are pooled by (ProxyMode, ProxyURL) tuple. -// Previously Client() created a new http.Transport per call, leaking -// idle connections and preventing TCP reuse across requests to the same -// provider. With pooling, a Venice model sync followed by a completion -// reuses the same TCP connection to api.venice.ai. - -// clientPool holds shared *http.Client instances keyed by proxy config. -var clientPool sync.Map // map[string]*http.Client - -// Client returns an *http.Client configured for this provider's proxy settings. -// Clients are cached and reused across calls with the same proxy configuration. -// system = http.ProxyFromEnvironment (Go default), direct = no proxy, -// custom = explicit proxy URL (http/https/socks5). -func (c ProviderConfig) Client() *http.Client { - key := c.ProxyMode + "|" + c.ProxyURL - if v, ok := clientPool.Load(key); ok { - return v.(*http.Client) - } - - transport := &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - } - switch c.ProxyMode { - case "direct": - transport.Proxy = nil - case "custom": - if c.ProxyURL != "" { - u, err := url.Parse(c.ProxyURL) - if err != nil { - // Fall back to system rather than silently going direct — - // a bad custom URL in a mandatory-proxy environment is a - // security/audit hole if it silently bypasses. - log.Printf("[proxy] invalid custom proxy URL %q: %v — falling back to system proxy", c.ProxyURL, err) - transport.Proxy = http.ProxyFromEnvironment - } else { - transport.Proxy = http.ProxyURL(u) - } - } else { - transport.Proxy = http.ProxyFromEnvironment - } - default: // "system" or empty - transport.Proxy = http.ProxyFromEnvironment - } - - client := &http.Client{Transport: transport} - actual, _ := clientPool.LoadOrStore(key, client) - return actual.(*http.Client) -} - -// ── Request / Response Types ──────────────── - -// Message represents a chat message in the conversation. -type Message struct { - Role string `json:"role"` // user, assistant, system, tool - Content string `json:"content"` - - // Multimodal content parts (v0.12.0+). When set, providers use these - // instead of Content for the user message. Content is still set as - // a fallback and for message persistence (extracted text summary). - ContentParts []ContentPart `json:"content_parts,omitempty"` - - // Tool calling fields (assistant → tool_calls, tool → tool_call_id) - ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools - ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages - Name string `json:"name,omitempty"` // speaker name (tool result, or persona attribution v0.23.0) -} - -// ContentPart is a single element in a multimodal message. -// Providers convert these to their native wire format. -type ContentPart struct { - Type string `json:"type"` // "text", "image_url", "document" - - // Text content (type="text") - Text string `json:"text,omitempty"` - - // Image content (type="image_url") — base64 data URI - ImageURL *ImageURL `json:"image_url,omitempty"` -} - -// ImageURL holds image data for multimodal requests. -// URL is a data URI: "data:image/jpeg;base64,..." -type ImageURL struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` // "auto", "low", "high" -} - -// ToolCall represents a function call requested by the LLM. -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type"` // "function" - Function FunctionCall `json:"function"` -} - -// FunctionCall is the function name and JSON arguments. -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -// ToolDef describes a tool available to the LLM. -type ToolDef struct { - Type string `json:"type"` // "function" - Function FunctionDef `json:"function"` -} - -// FunctionDef is the schema for a tool function. -type FunctionDef struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` -} - -// CompletionRequest is the normalized request sent to any provider. -type CompletionRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []ToolDef `json:"tools,omitempty"` // available tools for function calling - ExtraBody map[string]interface{} `json:"-"` // provider-specific wire fields (v0.22.1) -} - -// CompletionResponse is the normalized non-streaming response. -type CompletionResponse struct { - Content string `json:"content"` - Model string `json:"model"` - FinishReason string `json:"finish_reason"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when finish_reason is "tool_calls" -} - -// StreamEvent is a single chunk from a streaming response. -type StreamEvent struct { - Delta string `json:"delta,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Done bool `json:"done,omitempty"` - FinishReason string `json:"finish_reason,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - Model string `json:"model,omitempty"` - Error error `json:"-"` - InputTokens int `json:"input_tokens,omitempty"` - OutputTokens int `json:"output_tokens,omitempty"` - CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` - CacheReadTokens int `json:"cache_read_tokens,omitempty"` -} - -// ── Embedding Types ──────────────────────── - -// EmbeddingRequest is the normalized embedding request. -type EmbeddingRequest struct { - Model string `json:"model"` - Input []string `json:"input"` // batch of texts to embed -} - -// EmbeddingResponse is the normalized embedding response. -type EmbeddingResponse struct { - Embeddings [][]float64 `json:"embeddings"` - Model string `json:"model"` - InputTokens int `json:"input_tokens"` -} - -// Model represents an available model from a provider, with capabilities. -type Model struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - Type string `json:"type,omitempty"` // "chat", "embedding", "image" — from provider API - OwnedBy string `json:"owned_by,omitempty"` - Capabilities models.ModelCapabilities `json:"capabilities"` - Pricing *models.ModelPricing `json:"pricing,omitempty"` -} - -// mergeExtraBody merges arbitrary key-value pairs into a marshalled JSON object. -// Used by provider doRequest methods to inject hook-supplied fields (e.g. -// venice_parameters, thinking config) into the wire-format request body. -func mergeExtraBody(body []byte, extra map[string]interface{}) ([]byte, error) { - var base map[string]interface{} - if err := json.Unmarshal(body, &base); err != nil { - return nil, err - } - for k, v := range extra { - base[k] = v - } - return json.Marshal(base) -} diff --git a/server/providers/registry.go b/server/providers/registry.go deleted file mode 100644 index 5945f3e..0000000 --- a/server/providers/registry.go +++ /dev/null @@ -1,109 +0,0 @@ -package providers - -import ( - "fmt" - "sync" -) - -// registry holds all registered providers. -var ( - registry = make(map[string]Provider) - mu sync.RWMutex -) - -// Register adds a provider to the registry. -func Register(p Provider) { - mu.Lock() - defer mu.Unlock() - registry[p.ID()] = p -} - -// Get returns a provider by ID. -func Get(id string) (Provider, error) { - mu.RLock() - defer mu.RUnlock() - p, ok := registry[id] - if !ok { - return nil, fmt.Errorf("unknown provider: %s", id) - } - return p, nil -} - -// List returns all registered provider IDs. -func List() []string { - mu.RLock() - defer mu.RUnlock() - ids := make([]string, 0, len(registry)) - for id := range registry { - ids = append(ids, id) - } - return ids -} - -// ── Provider Type Metadata ────────────────── - -// ProviderTypeMeta describes a registered provider type for the admin API. -type ProviderTypeMeta struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - DefaultEndpoint string `json:"default_endpoint"` - ProfileSchema ProfileSchema `json:"profile_schema"` -} - -var providerTypes = map[string]ProviderTypeMeta{} - -// RegisterType adds a provider type with its metadata to the type registry. -// Called by Init() for built-in types. External types can be registered at startup. -func RegisterType(meta ProviderTypeMeta, p Provider) { - mu.Lock() - defer mu.Unlock() - registry[meta.ID] = p - providerTypes[meta.ID] = meta -} - -// ListTypes returns metadata for all registered provider types. -func ListTypes() []ProviderTypeMeta { - mu.RLock() - defer mu.RUnlock() - out := make([]ProviderTypeMeta, 0, len(providerTypes)) - for _, m := range providerTypes { - out = append(out, m) - } - return out -} - -// Init registers all built-in providers. -func Init() { - RegisterType(ProviderTypeMeta{ - ID: "openai", - Name: "OpenAI", - Description: "OpenAI and any OpenAI-compatible API (Ollama, LiteLLM, etc.)", - DefaultEndpoint: "https://api.openai.com/v1", - ProfileSchema: GetProfileSchema("openai"), - }, &OpenAIProvider{}) - - RegisterType(ProviderTypeMeta{ - ID: "anthropic", - Name: "Anthropic", - Description: "Anthropic Messages API (Claude models)", - DefaultEndpoint: "https://api.anthropic.com", - ProfileSchema: GetProfileSchema("anthropic"), - }, &AnthropicProvider{}) - - RegisterType(ProviderTypeMeta{ - ID: "venice", - Name: "Venice", - Description: "Venice.ai — privacy-focused, OpenAI-compatible with extensions", - DefaultEndpoint: "https://api.venice.ai/api/v1", - ProfileSchema: GetProfileSchema("venice"), - }, &VeniceProvider{}) - - RegisterType(ProviderTypeMeta{ - ID: "openrouter", - Name: "OpenRouter", - Description: "OpenRouter — unified API for 300+ models with per-model pricing", - DefaultEndpoint: "https://openrouter.ai/api/v1", - ProfileSchema: GetProfileSchema("openrouter"), - }, &OpenRouterProvider{}) -} diff --git a/server/providers/venice.go b/server/providers/venice.go deleted file mode 100644 index 4ca8d60..0000000 --- a/server/providers/venice.go +++ /dev/null @@ -1,168 +0,0 @@ -package providers - -import ( - "switchboard-core/models" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - -// VeniceProvider handles the Venice.ai API. -// Venice is OpenAI-compatible with extensions: venice_parameters for -// web search, thinking controls, and web scraping. -// -// Ported from ai-editor js/providers/venice.js -type VeniceProvider struct{} - -func (p *VeniceProvider) ID() string { return "venice" } - -// ── Chat Completion ───────────────────────── -// Delegates to OpenAI provider after injecting venice_parameters. - -func (p *VeniceProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) { - oai := &OpenAIProvider{} - return oai.ChatCompletion(ctx, cfg, req) -} - -func (p *VeniceProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) { - oai := &OpenAIProvider{} - return oai.StreamCompletion(ctx, cfg, req) -} - -func (p *VeniceProvider) Embed(ctx context.Context, cfg ProviderConfig, req EmbeddingRequest) (*EmbeddingResponse, error) { - oai := &OpenAIProvider{} - return oai.Embed(ctx, cfg, req) -} - -// ── List Models ───────────────────────────── -// Venice /v1/models returns model_spec with capabilities, pricing, -// context tokens, and traits. We parse all of it. - -func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) { - endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models" - - httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - return nil, err - } - if cfg.APIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey) - } - - resp, err := cfg.Client().Do(httpReq) - if err != nil { - return nil, fmt.Errorf("venice list models: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("venice list models: HTTP %d: %s", resp.StatusCode, string(b)) - } - - var result veniceModelsResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("venice decode models: %w", err) - } - - out := make([]Model, 0, len(result.Data)) - for _, m := range result.Data { - spec := m.ModelSpec - vcaps := spec.Capabilities - - caps := models.ModelCapabilities{ - Streaming: true, - ToolCalling: vcaps.SupportsFunctionCalling, - Vision: vcaps.SupportsVision, - Reasoning: vcaps.SupportsReasoning, - WebSearch: vcaps.SupportsWebSearch, - CodeOptimized: vcaps.OptimizedForCode, - MaxContext: spec.AvailableContextTokens, - } - - // Venice doesn't report max output tokens directly. - // ResolveMaxOutput will derive from context window at resolution time. - - var pricing *models.ModelPricing - if spec.Pricing.Input.USD > 0 { - pricing = &models.ModelPricing{ - InputPerM: spec.Pricing.Input.USD, - OutputPerM: spec.Pricing.Output.USD, - } - } - - name := spec.Name - if name == "" { - name = m.ID - } - - // Normalize Venice type → our model type - // Venice returns: "text", "embedding", "image", "code" - // We normalize to: "chat", "embedding", "image" - modelType := "chat" - switch m.Type { - case "embedding": - modelType = "embedding" - case "image": - modelType = "image" - default: - modelType = "chat" // "text", "code", etc. are all chat-capable - } - - out = append(out, Model{ - ID: m.ID, - Name: name, - Type: modelType, - OwnedBy: "venice", - Capabilities: caps, - Pricing: pricing, - }) - } - return out, nil -} - -// ── Venice Wire Types ─────────────────────── - -type veniceModelsResponse struct { - Data []veniceModel `json:"data"` -} - -type veniceModel struct { - ID string `json:"id"` - Type string `json:"type"` - OwnedBy string `json:"owned_by"` - ModelSpec veniceModelSpec `json:"model_spec"` -} - -type veniceModelSpec struct { - Name string `json:"name"` - Description string `json:"description"` - AvailableContextTokens int `json:"availableContextTokens"` - Capabilities veniceCapabilities `json:"capabilities"` - Pricing venicePricing `json:"pricing"` - Traits []string `json:"traits"` - Offline bool `json:"offline"` -} - -type veniceCapabilities struct { - SupportsFunctionCalling bool `json:"supportsFunctionCalling"` - SupportsVision bool `json:"supportsVision"` - SupportsReasoning bool `json:"supportsReasoning"` - SupportsWebSearch bool `json:"supportsWebSearch"` - SupportsResponseSchema bool `json:"supportsResponseSchema"` - SupportsAudioInput bool `json:"supportsAudioInput"` - SupportsLogProbs bool `json:"supportsLogProbs"` - OptimizedForCode bool `json:"optimizedForCode"` -} - -type venicePricing struct { - Input venicePriceUnit `json:"input"` - Output venicePriceUnit `json:"output"` -} - -type venicePriceUnit struct { - USD float64 `json:"usd"` -} diff --git a/server/retention/scanner.go b/server/retention/scanner.go deleted file mode 100644 index 3d5d0b5..0000000 --- a/server/retention/scanner.go +++ /dev/null @@ -1,124 +0,0 @@ -package retention - -import ( - "context" - "fmt" - "log" - "sync" - "time" - - "switchboard-core/storage" - "switchboard-core/store" -) - -const DefaultInterval = 1 * time.Hour - -// ScannerConfig holds startup configuration. -type ScannerConfig struct { - Interval time.Duration -} - -// Scanner periodically purges channels whose purge_after timestamp has passed. -// Channels with global/team provider scopes are archived with a TTL by -// DeleteChannel; this scanner performs the deferred hard-delete. -type Scanner struct { - stores store.Stores - objStore storage.ObjectStore - - wg sync.WaitGroup - stopCh chan struct{} - - interval time.Duration -} - -// NewScanner creates a retention scanner. Call Start() to begin. -func NewScanner(stores store.Stores, objStore storage.ObjectStore, cfg ScannerConfig) *Scanner { - interval := cfg.Interval - if interval <= 0 { - interval = DefaultInterval - } - return &Scanner{ - stores: stores, - objStore: objStore, - stopCh: make(chan struct{}), - interval: interval, - } -} - -// Start begins the scan loop in a background goroutine. -func (sc *Scanner) Start() { - sc.wg.Add(1) - go func() { - defer sc.wg.Done() - sc.loop() - }() - log.Printf("[retention] scanner started (interval=%s)", sc.interval) -} - -// Stop signals the scanner to stop and waits for in-flight work to drain. -func (sc *Scanner) Stop() { - close(sc.stopCh) - sc.wg.Wait() - log.Printf("[retention] scanner stopped") -} - -func (sc *Scanner) loop() { - ticker := time.NewTicker(sc.interval) - defer ticker.Stop() - - for { - select { - case <-sc.stopCh: - return - case <-ticker.C: - sc.tick() - } - } -} - -func (sc *Scanner) tick() { - ctx := context.Background() - - // If TTL is 0 the feature is disabled — nothing to purge - ttl := sc.retentionTTL(ctx) - if ttl <= 0 { - return - } - - ids, err := sc.stores.Channels.ListPurgeable(ctx) - if err != nil { - log.Printf("[retention] ListPurgeable error: %v", err) - return - } - if len(ids) == 0 { - return - } - - log.Printf("[retention] purging %d channel(s)", len(ids)) - - for _, id := range ids { - // Clean up storage files first - if sc.objStore != nil { - prefix := fmt.Sprintf("files/%s", id) - if err := sc.objStore.DeletePrefix(ctx, prefix); err != nil { - log.Printf("[retention] storage cleanup for %s failed: %v", id, err) - } - } - - // Hard delete (Purge verifies is_archived) - if err := sc.stores.Channels.Purge(ctx, id); err != nil { - log.Printf("[retention] purge %s failed: %v", id, err) - } - } -} - -func (sc *Scanner) retentionTTL(ctx context.Context) int { - cfg, err := sc.stores.GlobalConfig.Get(ctx, "retention_ttl_days") - if err != nil { - return 0 - } - if v, ok := cfg["value"].(float64); ok { - return int(v) - } - return 0 -} diff --git a/server/roles/roles.go b/server/roles/roles.go deleted file mode 100644 index 4ffab7c..0000000 --- a/server/roles/roles.go +++ /dev/null @@ -1,477 +0,0 @@ -package roles - -import ( - "context" - "encoding/json" - "fmt" - "log" - "sync" - "time" - - "switchboard-core/crypto" - "switchboard-core/events" - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/store" -) - -// ── Known Roles ──────────────────────────── - -const ( - RoleUtility = "utility" // Internal tasks: summarization, title generation - RoleEmbedding = "embedding" // Vector embedding for knowledge bases -) - -// ValidRoles lists all recognized role names. -// Note: "generation" (image/media) was removed in v0.10.2 — image gen -// will be extension-managed with its own provider config, not a role slot. -var ValidRoles = []string{RoleUtility, RoleEmbedding} - -// IsValidRole returns true if the given role name is recognized. -func IsValidRole(role string) bool { - for _, r := range ValidRoles { - if r == role { - return true - } - } - return false -} - -// ── Types ────────────────────────────────── - -// RoleBinding pairs a provider config with a specific model. -type RoleBinding struct { - ProviderConfigID string `json:"provider_config_id"` - ModelID string `json:"model_id"` -} - -// RoleConfig holds primary and fallback bindings for a role slot. -type RoleConfig struct { - Primary *RoleBinding `json:"primary"` - Fallback *RoleBinding `json:"fallback"` -} - -// CompletionResult wraps a completion response with usage metadata. -type CompletionResult struct { - Content string - Model string - ProviderID string - ConfigID string - ProviderScope string - InputTokens int - OutputTokens int - CacheCreationTokens int - CacheReadTokens int - Role string - UsedFallback bool -} - -// EmbeddingResult wraps an embedding response with usage metadata. -type EmbeddingResult struct { - Embeddings [][]float64 - Model string - ProviderID string - ConfigID string - ProviderScope string - InputTokens int - Role string - UsedFallback bool -} - -// ── Errors ───────────────────────────────── - -var ( - ErrRoleNotConfigured = fmt.Errorf("role not configured") - ErrNoPrimary = fmt.Errorf("no primary binding for role") -) - -// ── Resolver ─────────────────────────────── - -// Resolver resolves named model roles to provider+model pairs and -// executes completions/embeddings against them. It handles the full -// pipeline: config lookup → key decryption → provider dispatch → fallback. -type Resolver struct { - stores store.Stores - vault *crypto.KeyResolver - bus *events.Bus // optional — nil-safe - - // Fallback cooldown: suppress duplicate alerts per role - coolMu sync.Mutex - cooldown map[string]time.Time // role → last alert timestamp -} - -const fallbackCooldown = 5 * time.Minute - -// NewResolver creates a role resolver with access to stores and vault. -func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver { - return &Resolver{stores: s, vault: vault, cooldown: make(map[string]time.Time)} -} - -// WithBus attaches an event bus for fallback alert publishing. -func (r *Resolver) WithBus(bus *events.Bus) *Resolver { - r.bus = bus - return r -} - -// Complete sends a chat completion using the named role. -// Resolution: personal override → team override → global config → try primary → fallback on error. -func (r *Resolver) Complete(ctx context.Context, role string, userID string, teamID *string, messages []providers.Message) (*CompletionResult, error) { - cfg, err := r.GetConfig(ctx, role, userID, teamID) - if err != nil { - return nil, err - } - - // Try primary - if cfg.Primary != nil { - result, err := r.doComplete(ctx, role, cfg.Primary, messages) - if err == nil { - return result, nil - } - log.Printf("⚠ Role %q primary failed: %v", role, err) - } - - // Try fallback - if cfg.Fallback != nil { - result, err := r.doComplete(ctx, role, cfg.Fallback, messages) - if err == nil { - result.UsedFallback = true - r.onFallback(ctx, role, "completion", cfg.Primary, cfg.Fallback) - return result, nil - } - return nil, fmt.Errorf("role %q: both primary and fallback failed: %w", role, err) - } - - if cfg.Primary == nil { - return nil, fmt.Errorf("%w: %s", ErrNoPrimary, role) - } - return nil, fmt.Errorf("role %q: primary failed with no fallback configured", role) -} - -// Embed generates embeddings using the named role. -func (r *Resolver) Embed(ctx context.Context, role string, userID string, teamID *string, input []string) (*EmbeddingResult, error) { - cfg, err := r.GetConfig(ctx, role, userID, teamID) - if err != nil { - return nil, err - } - - // Try primary - if cfg.Primary != nil { - result, err := r.doEmbed(ctx, role, cfg.Primary, input) - if err == nil { - return result, nil - } - log.Printf("⚠ Role %q primary embed failed: %v", role, err) - } - - // Try fallback - if cfg.Fallback != nil { - result, err := r.doEmbed(ctx, role, cfg.Fallback, input) - if err == nil { - result.UsedFallback = true - r.onFallback(ctx, role, "embedding", cfg.Primary, cfg.Fallback) - return result, nil - } - return nil, fmt.Errorf("role %q: both primary and fallback embed failed: %w", role, err) - } - - if cfg.Primary == nil { - return nil, fmt.Errorf("%w: %s", ErrNoPrimary, role) - } - return nil, fmt.Errorf("role %q: primary embed failed with no fallback", role) -} - -// GetConfig returns the resolved role config for the given role name. -// Resolution order: personal override → team override → global config. -func (r *Resolver) GetConfig(ctx context.Context, role string, userID string, teamID *string) (*RoleConfig, error) { - if !IsValidRole(role) { - return nil, fmt.Errorf("unknown role: %q", role) - } - - // Check personal override first (BYOK users) - if userID != "" { - personalCfg, err := r.getPersonalRoleConfig(ctx, userID, role) - if err == nil && personalCfg != nil && (personalCfg.Primary != nil || personalCfg.Fallback != nil) { - return personalCfg, nil - } - } - - // Check team override - if teamID != nil && *teamID != "" { - teamCfg, err := r.getTeamRoleConfig(ctx, *teamID, role) - if err == nil && teamCfg != nil && (teamCfg.Primary != nil || teamCfg.Fallback != nil) { - return teamCfg, nil - } - } - - // Fall back to global - return r.getGlobalRoleConfig(ctx, role) -} - -// IsConfigured returns true if the named role has at least a primary binding. -func (r *Resolver) IsConfigured(ctx context.Context, role string) bool { - cfg, err := r.GetConfig(ctx, role, "", nil) - return err == nil && cfg != nil && cfg.Primary != nil -} - -// IsPersonalOverride returns true if the user has a personal binding for the role. -func (r *Resolver) IsPersonalOverride(ctx context.Context, userID, role string) bool { - cfg, err := r.getPersonalRoleConfig(ctx, userID, role) - return err == nil && cfg != nil && cfg.Primary != nil -} - -// ── Internal: Completion ─────────────────── - -func (r *Resolver) doComplete(ctx context.Context, role string, binding *RoleBinding, messages []providers.Message) (*CompletionResult, error) { - prov, provCfg, scope, err := r.resolveBinding(ctx, binding) - if err != nil { - return nil, err - } - - resp, err := prov.ChatCompletion(ctx, provCfg, providers.CompletionRequest{ - Model: binding.ModelID, - Messages: messages, - }) - if err != nil { - return nil, err - } - - return &CompletionResult{ - Content: resp.Content, - Model: resp.Model, - ProviderID: prov.ID(), - ConfigID: binding.ProviderConfigID, - ProviderScope: scope, - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, - CacheCreationTokens: resp.CacheCreationTokens, - CacheReadTokens: resp.CacheReadTokens, - Role: role, - }, nil -} - -// ── Internal: Embedding ──────────────────── - -func (r *Resolver) doEmbed(ctx context.Context, role string, binding *RoleBinding, input []string) (*EmbeddingResult, error) { - prov, provCfg, scope, err := r.resolveBinding(ctx, binding) - if err != nil { - return nil, err - } - - resp, err := prov.Embed(ctx, provCfg, providers.EmbeddingRequest{ - Model: binding.ModelID, - Input: input, - }) - if err != nil { - return nil, err - } - - return &EmbeddingResult{ - Embeddings: resp.Embeddings, - Model: resp.Model, - ProviderID: prov.ID(), - ConfigID: binding.ProviderConfigID, - ProviderScope: scope, - InputTokens: resp.InputTokens, - Role: role, - }, nil -} - -// ── Internal: Provider Resolution ────────── - -// resolveBinding loads a provider config, decrypts the API key, and returns -// a ready-to-use Provider + ProviderConfig pair + scope. -func (r *Resolver) resolveBinding(ctx context.Context, binding *RoleBinding) (providers.Provider, providers.ProviderConfig, string, error) { - cfg, err := r.stores.Providers.GetByID(ctx, binding.ProviderConfigID) - if err != nil { - return nil, providers.ProviderConfig{}, "", fmt.Errorf("load provider config %s: %w", binding.ProviderConfigID, err) - } - - prov, err := providers.Get(cfg.Provider) - if err != nil { - return nil, providers.ProviderConfig{}, "", fmt.Errorf("unknown provider %s: %w", cfg.Provider, err) - } - - // Decrypt API key - apiKey := "" - if len(cfg.APIKeyEnc) > 0 && r.vault != nil { - apiKey, err = r.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") - if err != nil { - return nil, providers.ProviderConfig{}, "", fmt.Errorf("decrypt API key: %w", err) - } - } else if len(cfg.APIKeyEnc) > 0 { - // No vault — key stored as raw bytes (unencrypted fallback) - apiKey = string(cfg.APIKeyEnc) - } - - // Parse headers - headers := make(map[string]string) - if cfg.Headers != nil { - for k, v := range cfg.Headers { - if s, ok := v.(string); ok { - headers[k] = s - } - } - } - - proxyURL := "" - if cfg.ProxyURL != nil { - proxyURL = *cfg.ProxyURL - } - return prov, providers.ProviderConfig{ - Endpoint: cfg.Endpoint, - APIKey: apiKey, - CustomHeaders: headers, - ProxyMode: cfg.ProxyMode, - ProxyURL: proxyURL, - }, cfg.Scope, nil -} - -// ── Internal: Config Loading ─────────────── - -func (r *Resolver) getPersonalRoleConfig(ctx context.Context, userID, role string) (*RoleConfig, error) { - user, err := r.stores.Users.GetByID(ctx, userID) - if err != nil { - return nil, err - } - - settings := user.Settings - if settings == nil { - return nil, nil - } - - rolesRaw, ok := settings["model_roles"] - if !ok { - return nil, nil - } - - rolesMap, ok := rolesRaw.(map[string]interface{}) - if !ok { - return nil, nil - } - - roleData, ok := rolesMap[role] - if !ok { - return nil, nil - } - - return parseRoleConfig(roleData) -} - -func (r *Resolver) getGlobalRoleConfig(ctx context.Context, role string) (*RoleConfig, error) { - allRoles, err := r.stores.GlobalConfig.Get(ctx, "model_roles") - if err != nil { - return nil, fmt.Errorf("load global model_roles: %w", err) - } - - roleData, ok := allRoles[role] - if !ok { - return nil, fmt.Errorf("%w: %s (not in global settings)", ErrRoleNotConfigured, role) - } - - return parseRoleConfig(roleData) -} - -func (r *Resolver) getTeamRoleConfig(ctx context.Context, teamID, role string) (*RoleConfig, error) { - team, err := r.stores.Teams.GetByID(ctx, teamID) - if err != nil { - return nil, err - } - - settings := team.Settings - if settings == nil { - return nil, nil - } - - rolesRaw, ok := settings["model_roles"] - if !ok { - return nil, nil - } - - rolesMap, ok := rolesRaw.(map[string]interface{}) - if !ok { - return nil, nil - } - - roleData, ok := rolesMap[role] - if !ok { - return nil, nil - } - - return parseRoleConfig(roleData) -} - -// parseRoleConfig converts an interface{} (from JSONB) into a typed RoleConfig. -func parseRoleConfig(data interface{}) (*RoleConfig, error) { - b, err := json.Marshal(data) - if err != nil { - return nil, err - } - var cfg RoleConfig - if err := json.Unmarshal(b, &cfg); err != nil { - return nil, err - } - return &cfg, nil -} - -// ── Fallback Alerting ────────────────────── - -// onFallback fires on successful fallback activation. It emits: -// - log line (always) -// - audit_log entry (always) -// - event bus "role.fallback" (once per cooldown window) -// -// The cooldown prevents flooding the admin UI with identical alerts -// when a primary is down and every request triggers the fallback. -func (r *Resolver) onFallback(ctx context.Context, role, opType string, primary, fallback *RoleBinding) { - primaryModel := "" - fallbackModel := "" - if primary != nil { - primaryModel = primary.ModelID - } - if fallback != nil { - fallbackModel = fallback.ModelID - } - - log.Printf("⚠ Role %q fallback activated: %s → %s (%s)", role, primaryModel, fallbackModel, opType) - - // Audit log — always written (one row per fallback fire) - _ = r.stores.Audit.Log(ctx, &models.AuditEntry{ - Action: "role.fallback", - ResourceType: "role", - ResourceID: role, - Metadata: models.JSONMap{ - "operation": opType, - "primary_model": primaryModel, - "fallback_model": fallbackModel, - }, - }) - - // Bus event — cooldown-gated - if r.bus == nil { - return - } - - r.coolMu.Lock() - last, exists := r.cooldown[role] - now := time.Now() - if exists && now.Sub(last) < fallbackCooldown { - r.coolMu.Unlock() - return - } - r.cooldown[role] = now - r.coolMu.Unlock() - - payload, _ := json.Marshal(map[string]string{ - "role": role, - "operation": opType, - "primary_model": primaryModel, - "fallback_model": fallbackModel, - "message": fmt.Sprintf("Role %q primary (%s) failed — using fallback (%s)", role, primaryModel, fallbackModel), - }) - r.bus.PublishAsync(events.Event{ - Label: "role.fallback", - Room: "admin", // admin-targeted - Payload: payload, - Ts: now.UnixMilli(), - }) -} diff --git a/server/routing/convert.go b/server/routing/convert.go deleted file mode 100644 index 9313067..0000000 --- a/server/routing/convert.go +++ /dev/null @@ -1,40 +0,0 @@ -package routing - -import ( - "encoding/json" - - "switchboard-core/models" -) - -// FromModel converts a DB model to a routing Policy for evaluation. -func FromModel(m *models.RoutingPolicy) Policy { - p := Policy{ - ID: m.ID, - Name: m.Name, - Scope: m.Scope, - TeamID: m.TeamID, - Priority: m.Priority, - Type: PolicyType(m.Type), - IsActive: m.IsActive, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - } - - // Convert JSONMap → PolicyConfig via JSON round-trip. - // This is safe because PolicyConfig fields match the JSONB keys. - if m.Config != nil { - raw, _ := json.Marshal(m.Config) - json.Unmarshal(raw, &p.Config) - } - - return p -} - -// FromModels converts a slice of DB models to routing Policies. -func FromModels(ms []models.RoutingPolicy) []Policy { - out := make([]Policy, len(ms)) - for i := range ms { - out[i] = FromModel(&ms[i]) - } - return out -} diff --git a/server/routing/evaluator.go b/server/routing/evaluator.go deleted file mode 100644 index d63d6b0..0000000 --- a/server/routing/evaluator.go +++ /dev/null @@ -1,378 +0,0 @@ -package routing - -import ( - "sort" - "strings" - - "switchboard-core/models" -) - -// Evaluator processes routing policies to produce a ranked candidate list. -type Evaluator struct{} - -// NewEvaluator creates a new routing evaluator. -func NewEvaluator() *Evaluator { - return &Evaluator{} -} - -// Evaluate applies active policies to the available candidates and returns -// a ranked list. Candidates are filtered and reordered based on policy rules -// and health status. The first candidate in the result is the preferred choice. -// -// Policies are evaluated in priority order (lower priority number = first). -// The first matching policy wins — subsequent policies of the same type are skipped. -func (e *Evaluator) Evaluate(ctx *Context, policies []Policy, candidates []Candidate) ([]Candidate, *Decision) { - if len(candidates) == 0 { - return nil, nil - } - - // Start with all candidates - result := make([]Candidate, len(candidates)) - copy(result, candidates) - - var decision Decision - decision.Candidates = len(candidates) - - // Sort policies by priority - sorted := make([]Policy, len(policies)) - copy(sorted, policies) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Priority < sorted[j].Priority - }) - - // Evaluate policies in priority order - appliedTypes := map[PolicyType]bool{} - for _, p := range sorted { - if !p.IsActive { - continue - } - // Skip if a policy of this type already matched - if appliedTypes[p.Type] { - continue - } - // Scope check: global applies to all; team only to matching teams - if p.Scope == "team" && p.TeamID != nil { - if !contains(ctx.TeamIDs, *p.TeamID) { - continue - } - } - - matched := false - switch p.Type { - case PolicyProviderPrefer: - result, matched = applyProviderPrefer(result, p.Config) - case PolicyTeamRoute: - result, matched = applyTeamRoute(result, p.Config) - case PolicyCostLimit: - result, matched = applyCostLimit(result, p.Config, ctx) - case PolicyModelAlias: - result, matched = applyModelAlias(result, p.Config, ctx) - case PolicyCapabilityMatch: - result, matched = applyCapabilityMatch(result, p.Config, ctx) - } - - if matched { - appliedTypes[p.Type] = true - decision.PolicyID = p.ID - decision.PolicyName = p.Name - decision.PolicyType = string(p.Type) - } - } - - // Global health-aware sorting: healthy > degraded > unknown > down. - // Also skip "down" providers if any policy requested it, or by default - // when there are healthy alternatives. - skipDown := anyPolicySkipsDown(sorted) - result = sortByHealth(result, ctx.HealthStatus, skipDown) - - if len(result) > 0 { - decision.Selected = result[0].ConfigID - } - - return result, &decision -} - -// ── Policy Implementations ────────────────── - -// applyProviderPrefer reorders candidates to match the preferred provider list. -// Providers not in the preferred list are appended after, in original order. -func applyProviderPrefer(candidates []Candidate, cfg PolicyConfig) ([]Candidate, bool) { - if len(cfg.PreferredProviders) == 0 { - return candidates, false - } - - // Build index: configID → position in preferred list - rank := make(map[string]int, len(cfg.PreferredProviders)) - for i, id := range cfg.PreferredProviders { - rank[id] = i - } - - preferred := make([]Candidate, 0, len(candidates)) - rest := make([]Candidate, 0) - - for _, c := range candidates { - if _, ok := rank[c.ConfigID]; ok { - c.Reason = "preferred provider" - preferred = append(preferred, c) - } else { - c.Reason = "fallback" - rest = append(rest, c) - } - } - - // Sort preferred by their order in the preference list - sort.Slice(preferred, func(i, j int) bool { - return rank[preferred[i].ConfigID] < rank[preferred[j].ConfigID] - }) - - return append(preferred, rest...), len(preferred) > 0 -} - -// applyTeamRoute filters candidates to only allowed providers for this team. -func applyTeamRoute(candidates []Candidate, cfg PolicyConfig) ([]Candidate, bool) { - if len(cfg.AllowedProviders) == 0 { - return candidates, false - } - - allowed := make(map[string]bool, len(cfg.AllowedProviders)) - for _, id := range cfg.AllowedProviders { - allowed[id] = true - } - - filtered := make([]Candidate, 0, len(candidates)) - for _, c := range candidates { - if allowed[c.ConfigID] { - c.Reason = "team-allowed provider" - filtered = append(filtered, c) - } - } - - // Only apply if at least one allowed provider is available - if len(filtered) > 0 { - return filtered, true - } - return candidates, false -} - -// applyCostLimit removes candidates whose estimated cost exceeds the limit. -func applyCostLimit(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) { - if cfg.MaxCostUSD == nil || *cfg.MaxCostUSD <= 0 { - return candidates, false - } - - // For cost estimation we'd need request token count, which isn't - // available at routing time. Instead, filter out providers with - // pricing above the threshold per-million-tokens as a heuristic. - // A 4K-token request at $10/M = $0.04, so if max is $0.10 we'd - // filter out providers above ~$25/M. For now, just ensure pricing - // exists and tag the decision. - maxPerM := *cfg.MaxCostUSD * 1_000_000 / 4096 // rough heuristic: 4K tokens avg - - filtered := make([]Candidate, 0, len(candidates)) - for _, c := range candidates { - key := c.ConfigID + ":" + c.Model - if pricing, ok := ctx.Pricing[key]; ok && pricing != nil { - if pricing.InputPerM > maxPerM { - continue // too expensive - } - } - filtered = append(filtered, c) - } - - if len(filtered) > 0 && len(filtered) < len(candidates) { - return filtered, true - } - return candidates, false -} - -// applyModelAlias replaces the model in matching candidates. -func applyModelAlias(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) { - if cfg.AliasFrom == "" || cfg.AliasTo == "" { - return candidates, false - } - - // Only apply if the requested model matches the alias source - if !strings.EqualFold(ctx.Model, cfg.AliasFrom) { - return candidates, false - } - - // If a specific provider is targeted, reorder to prefer it - if cfg.AliasProvider != "" { - for i, c := range candidates { - if c.ConfigID == cfg.AliasProvider { - candidates[i].Model = cfg.AliasTo - candidates[i].Reason = "model alias: " + cfg.AliasFrom + " → " + cfg.AliasTo - // Move to front - reordered := make([]Candidate, 0, len(candidates)) - reordered = append(reordered, candidates[i]) - reordered = append(reordered, candidates[:i]...) - reordered = append(reordered, candidates[i+1:]...) - return reordered, true - } - } - } - - // No specific provider — just rewrite the model on all candidates - for i := range candidates { - candidates[i].Model = cfg.AliasTo - candidates[i].Reason = "model alias: " + cfg.AliasFrom + " → " + cfg.AliasTo - } - return candidates, true -} - -// applyCapabilityMatch filters candidates to those whose model has all -// required capabilities. Optionally sorts remaining candidates by cheapest -// output price when PreferCheapest is set. -// -// Required capabilities map to ModelCapabilities boolean fields: -// "tool_calling", "vision", "thinking", "reasoning", -// "code_optimized", "web_search", "streaming" -func applyCapabilityMatch(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) { - if len(cfg.RequiredCaps) == 0 { - return candidates, false - } - if ctx.Capabilities == nil { - return candidates, false - } - - filtered := make([]Candidate, 0, len(candidates)) - for _, c := range candidates { - key := c.ConfigID + ":" + c.Model - caps, ok := ctx.Capabilities[key] - if !ok { - continue // no capability data — skip (conservative) - } - if hasAllCaps(caps, cfg.RequiredCaps) { - c.Reason = "capability match" - filtered = append(filtered, c) - } - } - - if len(filtered) == 0 { - // No candidates match — fall back to original set rather than - // returning empty (better to try than fail immediately). - return candidates, false - } - - // Sort by cheapest output price if requested - if cfg.PreferCheapest && ctx.Pricing != nil { - sort.SliceStable(filtered, func(i, j int) bool { - ki := filtered[i].ConfigID + ":" + filtered[i].Model - kj := filtered[j].ConfigID + ":" + filtered[j].Model - pi, oki := ctx.Pricing[ki] - pj, okj := ctx.Pricing[kj] - if !oki || pi == nil { - return false // unknown price sorts last - } - if !okj || pj == nil { - return true - } - return pi.OutputPerM < pj.OutputPerM - }) - } - - return filtered, true -} - -// hasAllCaps checks whether a ModelCapabilities struct has all requested -// capability flags set. Unrecognized capability names are ignored. -func hasAllCaps(caps *models.ModelCapabilities, required []string) bool { - if caps == nil { - return false - } - for _, req := range required { - switch req { - case "tool_calling": - if !caps.ToolCalling { - return false - } - case "vision": - if !caps.Vision { - return false - } - case "thinking": - if !caps.Thinking { - return false - } - case "reasoning": - if !caps.Reasoning { - return false - } - case "code_optimized": - if !caps.CodeOptimized { - return false - } - case "web_search": - if !caps.WebSearch { - return false - } - case "streaming": - if !caps.Streaming { - return false - } - // Unrecognized caps are ignored (forward-compatible) - } - } - return true -} - -// ── Health Sorting ────────────────────────── - -var statusOrder = map[models.ProviderStatus]int{ - models.StatusHealthy: 0, - models.StatusDegraded: 1, - models.StatusUnknown: 2, - models.StatusDown: 3, -} - -func sortByHealth(candidates []Candidate, health map[string]models.ProviderStatus, skipDown bool) []Candidate { - // Annotate status - for i, c := range candidates { - if s, ok := health[c.ConfigID]; ok { - candidates[i].Status = s - } else { - candidates[i].Status = models.StatusUnknown - } - } - - // Filter down if requested and alternatives exist - if skipDown { - healthy := make([]Candidate, 0, len(candidates)) - for _, c := range candidates { - if c.Status != models.StatusDown { - healthy = append(healthy, c) - } - } - if len(healthy) > 0 { - candidates = healthy - } - // If all are down, keep them — better to try than fail immediately. - } - - // Stable sort by health status - sort.SliceStable(candidates, func(i, j int) bool { - return statusOrder[candidates[i].Status] < statusOrder[candidates[j].Status] - }) - - return candidates -} - -// ── Helpers ───────────────────────────────── - -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - -func anyPolicySkipsDown(policies []Policy) bool { - for _, p := range policies { - if p.IsActive && p.Config.SkipDown { - return true - } - } - return false -} diff --git a/server/routing/evaluator_test.go b/server/routing/evaluator_test.go deleted file mode 100644 index 9a9af58..0000000 --- a/server/routing/evaluator_test.go +++ /dev/null @@ -1,498 +0,0 @@ -package routing - -import ( - "testing" - - "switchboard-core/models" -) - -func strPtr(s string) *string { return &s } -func f64Ptr(f float64) *float64 { return &f } - -func baseCandidates() []Candidate { - return []Candidate{ - {ConfigID: "cfg-openai", ProviderID: "openai", Model: "gpt-4o"}, - {ConfigID: "cfg-anthropic", ProviderID: "anthropic", Model: "claude-sonnet-4-20250514"}, - {ConfigID: "cfg-venice", ProviderID: "venice", Model: "llama-3.1-405b"}, - } -} - -func TestEvaluate_NoPolices(t *testing.T) { - e := NewEvaluator() - ctx := &Context{UserID: "u1", Model: "gpt-4o", HealthStatus: map[string]models.ProviderStatus{}} - result, decision := e.Evaluate(ctx, nil, baseCandidates()) - - if len(result) != 3 { - t.Fatalf("expected 3 candidates, got %d", len(result)) - } - if decision.Selected != "cfg-openai" { - t.Errorf("expected first candidate selected, got %s", decision.Selected) - } - if decision.FallbackDepth != 0 { - t.Errorf("expected fallback depth 0, got %d", decision.FallbackDepth) - } -} - -func TestEvaluate_ProviderPrefer(t *testing.T) { - e := NewEvaluator() - ctx := &Context{UserID: "u1", Model: "gpt-4o", HealthStatus: map[string]models.ProviderStatus{}} - policies := []Policy{ - { - ID: "p1", Name: "Prefer Anthropic", Priority: 1, - Type: PolicyProviderPrefer, IsActive: true, Scope: "global", - Config: PolicyConfig{ - PreferredProviders: []string{"cfg-anthropic", "cfg-venice"}, - }, - }, - } - - result, decision := e.Evaluate(ctx, policies, baseCandidates()) - - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected anthropic first, got %s", result[0].ConfigID) - } - if result[1].ConfigID != "cfg-venice" { - t.Errorf("expected venice second, got %s", result[1].ConfigID) - } - // openai should still be present as fallback - if result[2].ConfigID != "cfg-openai" { - t.Errorf("expected openai last, got %s", result[2].ConfigID) - } - if decision.PolicyID != "p1" { - t.Errorf("expected policy p1, got %s", decision.PolicyID) - } -} - -func TestEvaluate_TeamRoute(t *testing.T) { - e := NewEvaluator() - teamID := "team-1" - ctx := &Context{ - UserID: "u1", - TeamIDs: []string{"team-1"}, - Model: "gpt-4o", - HealthStatus: map[string]models.ProviderStatus{}, - } - policies := []Policy{ - { - ID: "p1", Name: "Team uses Anthropic only", Priority: 1, - Type: PolicyTeamRoute, IsActive: true, Scope: "team", TeamID: &teamID, - Config: PolicyConfig{ - AllowedProviders: []string{"cfg-anthropic"}, - }, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - if len(result) != 1 { - t.Fatalf("expected 1 candidate after team filter, got %d", len(result)) - } - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected anthropic only, got %s", result[0].ConfigID) - } -} - -func TestEvaluate_TeamRoute_WrongTeam(t *testing.T) { - e := NewEvaluator() - teamID := "team-2" - ctx := &Context{ - UserID: "u1", - TeamIDs: []string{"team-1"}, // user is NOT in team-2 - Model: "gpt-4o", - HealthStatus: map[string]models.ProviderStatus{}, - } - policies := []Policy{ - { - ID: "p1", Name: "Team 2 only", Priority: 1, - Type: PolicyTeamRoute, IsActive: true, Scope: "team", TeamID: &teamID, - Config: PolicyConfig{AllowedProviders: []string{"cfg-anthropic"}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // Policy should not apply — all 3 candidates remain - if len(result) != 3 { - t.Fatalf("expected 3 candidates (policy doesn't apply), got %d", len(result)) - } -} - -func TestEvaluate_ModelAlias(t *testing.T) { - e := NewEvaluator() - ctx := &Context{ - UserID: "u1", Model: "fast", - HealthStatus: map[string]models.ProviderStatus{}, - } - policies := []Policy{ - { - ID: "p1", Name: "fast = haiku", Priority: 1, - Type: PolicyModelAlias, IsActive: true, Scope: "global", - Config: PolicyConfig{ - AliasFrom: "fast", - AliasTo: "claude-haiku-4-20250414", - AliasProvider: "cfg-anthropic", - }, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected anthropic first for alias, got %s", result[0].ConfigID) - } - if result[0].Model != "claude-haiku-4-20250414" { - t.Errorf("expected model rewritten to haiku, got %s", result[0].Model) - } -} - -func TestEvaluate_HealthSkipDown(t *testing.T) { - e := NewEvaluator() - ctx := &Context{ - UserID: "u1", Model: "gpt-4o", - HealthStatus: map[string]models.ProviderStatus{ - "cfg-openai": models.StatusDown, - "cfg-anthropic": models.StatusHealthy, - "cfg-venice": models.StatusDegraded, - }, - } - policies := []Policy{ - { - ID: "p1", Name: "Skip down", Priority: 1, - Type: PolicyProviderPrefer, IsActive: true, Scope: "global", - Config: PolicyConfig{ - PreferredProviders: []string{"cfg-openai", "cfg-anthropic"}, - SkipDown: true, - }, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // openai is down and should be skipped - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected anthropic first (openai is down), got %s", result[0].ConfigID) - } - // openai should not be in results at all - for _, c := range result { - if c.ConfigID == "cfg-openai" { - t.Error("down provider should be skipped") - } - } -} - -func TestEvaluate_HealthSorting(t *testing.T) { - e := NewEvaluator() - ctx := &Context{ - UserID: "u1", Model: "gpt-4o", - HealthStatus: map[string]models.ProviderStatus{ - "cfg-openai": models.StatusDegraded, - "cfg-anthropic": models.StatusHealthy, - "cfg-venice": models.StatusUnknown, - }, - } - - result, _ := e.Evaluate(ctx, nil, baseCandidates()) - - // Should be sorted: healthy (anthropic) > degraded (openai) > unknown (venice) - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected healthy provider first, got %s", result[0].ConfigID) - } - if result[1].ConfigID != "cfg-openai" { - t.Errorf("expected degraded provider second, got %s", result[1].ConfigID) - } -} - -func TestEvaluate_AllDown_KeepsCandidates(t *testing.T) { - e := NewEvaluator() - ctx := &Context{ - UserID: "u1", Model: "gpt-4o", - HealthStatus: map[string]models.ProviderStatus{ - "cfg-openai": models.StatusDown, - "cfg-anthropic": models.StatusDown, - "cfg-venice": models.StatusDown, - }, - } - policies := []Policy{ - { - ID: "p1", Priority: 1, Type: PolicyProviderPrefer, IsActive: true, Scope: "global", - Config: PolicyConfig{SkipDown: true}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // When ALL are down, should keep them rather than returning empty - if len(result) == 0 { - t.Error("should keep all candidates when all are down") - } -} - -func TestEvaluate_PriorityOrder(t *testing.T) { - e := NewEvaluator() - ctx := &Context{ - UserID: "u1", - TeamIDs: []string{"team-1"}, - Model: "gpt-4o", - HealthStatus: map[string]models.ProviderStatus{}, - } - teamID := "team-1" - policies := []Policy{ - { - ID: "p-low", Name: "Low priority prefer", Priority: 10, - Type: PolicyProviderPrefer, IsActive: true, Scope: "global", - Config: PolicyConfig{PreferredProviders: []string{"cfg-venice"}}, - }, - { - ID: "p-high", Name: "High priority team route", Priority: 1, - Type: PolicyTeamRoute, IsActive: true, Scope: "team", TeamID: &teamID, - Config: PolicyConfig{AllowedProviders: []string{"cfg-anthropic", "cfg-openai"}}, - }, - } - - result, decision := e.Evaluate(ctx, policies, baseCandidates()) - - // Team route (priority 1) should filter first, then prefer (priority 10) reorders - if decision.PolicyType != string(PolicyTeamRoute) { - t.Errorf("expected team_route to be recorded (first match), got %s", decision.PolicyType) - } - // Venice should be filtered out by team route - for _, c := range result { - if c.ConfigID == "cfg-venice" { - t.Error("venice should be filtered out by team route") - } - } -} - -func TestEvaluate_EmptyCandidates(t *testing.T) { - e := NewEvaluator() - ctx := &Context{UserID: "u1"} - result, decision := e.Evaluate(ctx, nil, nil) - if result != nil { - t.Error("expected nil result for empty candidates") - } - if decision != nil { - t.Error("expected nil decision for empty candidates") - } -} - -func TestEvaluate_InactivePolicy(t *testing.T) { - e := NewEvaluator() - ctx := &Context{UserID: "u1", Model: "gpt-4o", HealthStatus: map[string]models.ProviderStatus{}} - policies := []Policy{ - { - ID: "p1", Priority: 1, Type: PolicyTeamRoute, IsActive: false, Scope: "global", - Config: PolicyConfig{AllowedProviders: []string{"cfg-anthropic"}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // Inactive policy should not filter - if len(result) != 3 { - t.Fatalf("inactive policy should not filter, got %d candidates", len(result)) - } -} - -// ── capability_match tests ────────────────── - -func capContext() *Context { - return &Context{ - UserID: "u1", Model: "any", - HealthStatus: map[string]models.ProviderStatus{}, - Capabilities: map[string]*models.ModelCapabilities{ - "cfg-openai:gpt-4o": {ToolCalling: true, Vision: true, Streaming: true}, - "cfg-anthropic:claude-sonnet-4-20250514": {ToolCalling: true, Vision: true, Thinking: true, Streaming: true}, - "cfg-venice:llama-3.1-405b": {ToolCalling: false, Vision: false, Streaming: true}, - }, - Pricing: map[string]*models.ModelPricing{ - "cfg-openai:gpt-4o": {OutputPerM: 15.0}, - "cfg-anthropic:claude-sonnet-4-20250514": {OutputPerM: 3.0}, - "cfg-venice:llama-3.1-405b": {OutputPerM: 0.5}, - }, - } -} - -func TestEvaluate_CapabilityMatch_ToolCalling(t *testing.T) { - e := NewEvaluator() - ctx := capContext() - policies := []Policy{ - { - ID: "p1", Name: "Needs tool calling", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}}, - }, - } - - result, decision := e.Evaluate(ctx, policies, baseCandidates()) - - // Venice (llama) has no tool_calling — should be filtered out - if len(result) != 2 { - t.Fatalf("expected 2 candidates with tool_calling, got %d", len(result)) - } - for _, c := range result { - if c.ConfigID == "cfg-venice" { - t.Error("venice should be filtered (no tool_calling)") - } - } - if decision.PolicyID != "p1" { - t.Errorf("expected policy p1, got %s", decision.PolicyID) - } -} - -func TestEvaluate_CapabilityMatch_MultipleCaps(t *testing.T) { - e := NewEvaluator() - ctx := capContext() - policies := []Policy{ - { - ID: "p1", Name: "Needs thinking + tool_calling", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{RequiredCaps: []string{"thinking", "tool_calling"}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // Only anthropic has both thinking AND tool_calling - if len(result) != 1 { - t.Fatalf("expected 1 candidate with thinking+tool_calling, got %d", len(result)) - } - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected anthropic, got %s", result[0].ConfigID) - } -} - -func TestEvaluate_CapabilityMatch_PreferCheapest(t *testing.T) { - e := NewEvaluator() - ctx := capContext() - policies := []Policy{ - { - ID: "p1", Name: "Tool calling, cheapest first", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{ - RequiredCaps: []string{"tool_calling"}, - PreferCheapest: true, - }, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - if len(result) != 2 { - t.Fatalf("expected 2 candidates, got %d", len(result)) - } - // Anthropic ($3/M) should be before OpenAI ($15/M) - if result[0].ConfigID != "cfg-anthropic" { - t.Errorf("expected cheapest (anthropic) first, got %s", result[0].ConfigID) - } - if result[1].ConfigID != "cfg-openai" { - t.Errorf("expected openai second, got %s", result[1].ConfigID) - } -} - -func TestEvaluate_CapabilityMatch_NoneMatch_FallsBack(t *testing.T) { - e := NewEvaluator() - ctx := capContext() - policies := []Policy{ - { - ID: "p1", Name: "Needs web_search", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{RequiredCaps: []string{"web_search"}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // None of the candidates have web_search — should fall back to all 3 - if len(result) != 3 { - t.Fatalf("expected 3 candidates (fallback), got %d", len(result)) - } -} - -func TestEvaluate_CapabilityMatch_StreamingOnly(t *testing.T) { - e := NewEvaluator() - ctx := capContext() - policies := []Policy{ - { - ID: "p1", Name: "Streaming only", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{RequiredCaps: []string{"streaming"}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // All three have streaming — all should pass - if len(result) != 3 { - t.Fatalf("expected 3 candidates (all have streaming), got %d", len(result)) - } -} - -func TestEvaluate_CapabilityMatch_NoCapsData(t *testing.T) { - e := NewEvaluator() - ctx := &Context{ - UserID: "u1", Model: "any", - HealthStatus: map[string]models.ProviderStatus{}, - Capabilities: nil, // no capability data - } - policies := []Policy{ - { - ID: "p1", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // No caps data — policy should not match, all candidates kept - if len(result) != 3 { - t.Fatalf("expected 3 candidates (no caps data), got %d", len(result)) - } -} - -func TestEvaluate_CapabilityMatch_EmptyRequiredCaps(t *testing.T) { - e := NewEvaluator() - ctx := capContext() - policies := []Policy{ - { - ID: "p1", Priority: 1, - Type: PolicyCapabilityMatch, IsActive: true, Scope: "global", - Config: PolicyConfig{RequiredCaps: []string{}}, - }, - } - - result, _ := e.Evaluate(ctx, policies, baseCandidates()) - - // Empty required caps — no filtering - if len(result) != 3 { - t.Fatalf("expected 3 candidates (empty caps), got %d", len(result)) - } -} - -func TestHasAllCaps(t *testing.T) { - caps := &models.ModelCapabilities{ - ToolCalling: true, Vision: true, Streaming: true, - } - - if !hasAllCaps(caps, []string{"tool_calling"}) { - t.Error("should match tool_calling") - } - if !hasAllCaps(caps, []string{"tool_calling", "vision"}) { - t.Error("should match tool_calling+vision") - } - if hasAllCaps(caps, []string{"thinking"}) { - t.Error("should not match thinking") - } - if !hasAllCaps(caps, []string{"unknown_future_cap"}) { - t.Error("unknown caps should be ignored (forward compat)") - } - if hasAllCaps(nil, []string{"tool_calling"}) { - t.Error("nil caps should return false") - } - if !hasAllCaps(caps, nil) { - t.Error("nil required should return true") - } - if !hasAllCaps(caps, []string{}) { - t.Error("empty required should return true") - } -} diff --git a/server/routing/fallback.go b/server/routing/fallback.go deleted file mode 100644 index 78f7e84..0000000 --- a/server/routing/fallback.go +++ /dev/null @@ -1,59 +0,0 @@ -package routing - -import ( - "fmt" - "log" -) - -// ── Fallback Runner ───────────────────────── - -// DispatchFunc is called by the fallback runner to attempt a request -// against a specific candidate. Returns nil on success, error on failure. -// The caller is responsible for setting up SSE headers, streaming, etc. -// This is NOT the actual dispatch — it's the "try this candidate" callback. -type DispatchFunc func(candidate Candidate) error - -// RunWithFallback tries candidates in order until one succeeds. -// Returns the candidate that succeeded, the decision (updated with fallback -// depth), and any error if all candidates fail. -// -// maxRetries controls how many fallback attempts are made beyond the first. -// maxRetries=0 means only try the first candidate (no fallback). -// maxRetries=-1 means try all candidates. -func RunWithFallback(candidates []Candidate, decision *Decision, maxRetries int, dispatch DispatchFunc) (Candidate, *Decision, error) { - if len(candidates) == 0 { - return Candidate{}, decision, fmt.Errorf("no routing candidates available") - } - - // Determine how many candidates to try - limit := 1 // just the first - if maxRetries < 0 { - limit = len(candidates) // try all - } else if maxRetries > 0 { - limit = 1 + maxRetries - if limit > len(candidates) { - limit = len(candidates) - } - } - - var lastErr error - for i := 0; i < limit; i++ { - c := candidates[i] - err := dispatch(c) - if err == nil { - // Success - if decision != nil { - decision.Selected = c.ConfigID - decision.FallbackDepth = i - } - return c, decision, nil - } - - lastErr = err - if i < limit-1 { - log.Printf("[routing] candidate %s (%s) failed: %v — trying next", c.ConfigID, c.ProviderID, err) - } - } - - return Candidate{}, decision, fmt.Errorf("all %d routing candidates failed; last error: %w", limit, lastErr) -} diff --git a/server/routing/types.go b/server/routing/types.go deleted file mode 100644 index 2a4caf3..0000000 --- a/server/routing/types.go +++ /dev/null @@ -1,118 +0,0 @@ -// Package routing implements rules-based request routing for LLM providers. -// Policies are evaluated between "what model was requested" and "which -// provider config serves it," producing a ranked list of candidates. -// The fallback runner dispatches to candidates in order, retrying on failure. -package routing - -import ( - "time" - - "switchboard-core/models" -) - -// ── Policy ───────────────────────────────── - -// PolicyType identifies the routing strategy. -type PolicyType string - -const ( - // PolicyProviderPrefer routes to a preferred provider, falling back to others. - PolicyProviderPrefer PolicyType = "provider_prefer" - // PolicyTeamRoute forces a team to use specific providers. - PolicyTeamRoute PolicyType = "team_route" - // PolicyCostLimit caps per-request cost. - PolicyCostLimit PolicyType = "cost_limit" - // PolicyModelAlias maps a model alias to a specific provider+model pair. - PolicyModelAlias PolicyType = "model_alias" - // PolicyCapabilityMatch filters to models with required capabilities, - // optionally sorted by cheapest output price. v0.29.1. - PolicyCapabilityMatch PolicyType = "capability_match" -) - -// Policy is one routing rule stored in the routing_policies table. -type Policy struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Scope string `json:"scope" db:"scope"` // "global" or "team" - TeamID *string `json:"team_id,omitempty" db:"team_id"` - Priority int `json:"priority" db:"priority"` // lower = evaluated first - Type PolicyType `json:"policy_type" db:"policy_type"` - Config PolicyConfig `json:"config" db:"config"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// PolicyConfig holds the type-specific parameters for a routing policy. -// Stored as JSONB. Each PolicyType uses a subset of these fields. -type PolicyConfig struct { - // provider_prefer: ordered list of provider config IDs - PreferredProviders []string `json:"preferred_providers,omitempty"` - - // team_route: required provider config IDs for this team - AllowedProviders []string `json:"allowed_providers,omitempty"` - - // cost_limit: max cost per request in USD - MaxCostUSD *float64 `json:"max_cost_usd,omitempty"` - - // model_alias: maps alias → provider+model - AliasFrom string `json:"alias_from,omitempty"` // e.g. "default", "fast", "smart" - AliasTo string `json:"alias_to,omitempty"` // model ID - AliasProvider string `json:"alias_provider,omitempty"` // config ID - - // capability_match: filter candidates by model capabilities - RequiredCaps []string `json:"required_caps,omitempty"` // e.g. ["tool_calling", "vision"] - PreferCheapest bool `json:"prefer_cheapest,omitempty"` // sort by output_price_per_m ascending - - // Shared: health requirements - SkipDown bool `json:"skip_down,omitempty"` // skip providers with status "down" - PreferHealthy bool `json:"prefer_healthy,omitempty"` // rank healthy > degraded - MaxRetries int `json:"max_retries,omitempty"` // 0 = no retry, default = 1 -} - -// ── Evaluation Context ───────────────────── - -// Context carries the information needed to evaluate routing policies. -type Context struct { - UserID string - TeamIDs []string // teams the user belongs to - Model string // requested model ID - ConfigID string // explicitly requested provider config (may be empty) - - // Health status for all active providers (keyed by config ID). - // Populated from the health accumulator at evaluation time. - HealthStatus map[string]models.ProviderStatus - - // Pricing for candidate models (keyed by "configID:modelID"). - // Populated lazily from the pricing store. - Pricing map[string]*models.ModelPricing - - // Capabilities for candidate models (keyed by "configID:modelID"). - // Populated lazily from the catalog store. Used by capability_match. - Capabilities map[string]*models.ModelCapabilities -} - -// ── Candidates ───────────────────────────── - -// Candidate is one possible (provider, model) pair the request could be routed to. -type Candidate struct { - ConfigID string `json:"config_id"` - ProviderID string `json:"provider_id"` // "openai", "anthropic", etc. - Model string `json:"model"` - Endpoint string `json:"-"` // internal, not exposed in API - Status models.ProviderStatus `json:"status"` - Reason string `json:"reason"` // why this candidate was selected -} - -// ── Routing Decision ─────────────────────── - -// Decision records which candidate was selected and why. -// Stored in the usage_log for observability. -type Decision struct { - PolicyID string `json:"policy_id,omitempty"` // which policy matched - PolicyName string `json:"policy_name,omitempty"` // human-readable - PolicyType string `json:"policy_type,omitempty"` - Selected string `json:"selected"` // config ID that served the request - FallbackDepth int `json:"fallback_depth"` // 0 = first choice, 1+ = fallback - Candidates int `json:"candidates"` // total candidates evaluated -} diff --git a/server/tools/calculator.go b/server/tools/calculator.go deleted file mode 100644 index 7bfe7da..0000000 --- a/server/tools/calculator.go +++ /dev/null @@ -1,386 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "math" - "strconv" - "strings" -) - -func init() { - Register(&CalculatorTool{}) -} - -// ═══════════════════════════════════════════ -// calculator -// ═══════════════════════════════════════════ - -type CalculatorTool struct{ BaseTool } - -func (t *CalculatorTool) Definition() ToolDef { - return ToolDef{ - Name: "calculator", - DisplayName: "Calculator", - Description: "Evaluate mathematical expressions with precision. Supports +, -, *, /, ^ (power), % (modulo), parentheses, and functions: sqrt, abs, ceil, floor, round, log, log2, log10, sin, cos, tan, pi, e. Use this for any arithmetic, unit conversions, percentages, or calculations instead of doing mental math.", - Category: "utilities", - Parameters: JSONSchema(map[string]interface{}{ - "expression": Prop("string", "Mathematical expression to evaluate, e.g. '(100 * 1.08) ^ 5' or 'sqrt(144) + log10(1000)'"), - }, []string{"expression"}), - } -} - -func (t *CalculatorTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Expression string `json:"expression"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Expression == "" { - return "", fmt.Errorf("expression is required") - } - - // Normalize: replace ^ with ** for power, then rewrite for Go parser - expr := normalizeExpr(args.Expression) - - result, err := evalExpr(expr) - if err != nil { - return "", fmt.Errorf("evaluation error: %w", err) - } - - // Format result nicely - var display string - if result == math.Trunc(result) && !math.IsInf(result, 0) && !math.IsNaN(result) { - display = strconv.FormatFloat(result, 'f', 0, 64) - } else { - display = strconv.FormatFloat(result, 'g', 15, 64) - } - - resp := map[string]interface{}{ - "expression": args.Expression, - "result": result, - "display": display, - } - - b, _ := json.Marshal(resp) - return string(b), nil -} - -// ── Expression Normalization ──────────────── - -func normalizeExpr(s string) string { - // Replace common aliases - s = strings.ReplaceAll(s, "×", "*") - s = strings.ReplaceAll(s, "÷", "/") - s = strings.ReplaceAll(s, "**", "^") - return s -} - -// ── Recursive Descent Evaluator ───────────── -// Supports: +, -, *, /, ^, %, unary -, parentheses, function calls, constants - -type calcParser struct { - input string - pos int -} - -func evalExpr(input string) (float64, error) { - p := &calcParser{input: strings.TrimSpace(input)} - result, err := p.parseExpr() - if err != nil { - return 0, err - } - p.skipSpaces() - if p.pos < len(p.input) { - return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos])) - } - return result, nil -} - -func (p *calcParser) parseExpr() (float64, error) { - return p.parseAddSub() -} - -func (p *calcParser) parseAddSub() (float64, error) { - left, err := p.parseMulDiv() - if err != nil { - return 0, err - } - for { - p.skipSpaces() - if p.pos >= len(p.input) { - break - } - op := p.input[p.pos] - if op != '+' && op != '-' { - break - } - p.pos++ - right, err := p.parseMulDiv() - if err != nil { - return 0, err - } - if op == '+' { - left += right - } else { - left -= right - } - } - return left, nil -} - -func (p *calcParser) parseMulDiv() (float64, error) { - left, err := p.parsePower() - if err != nil { - return 0, err - } - for { - p.skipSpaces() - if p.pos >= len(p.input) { - break - } - op := p.input[p.pos] - if op != '*' && op != '/' && op != '%' { - break - } - p.pos++ - right, err := p.parsePower() - if err != nil { - return 0, err - } - switch op { - case '*': - left *= right - case '/': - if right == 0 { - return 0, fmt.Errorf("division by zero") - } - left /= right - case '%': - if right == 0 { - return 0, fmt.Errorf("modulo by zero") - } - left = math.Mod(left, right) - } - } - return left, nil -} - -func (p *calcParser) parsePower() (float64, error) { - base, err := p.parseUnary() - if err != nil { - return 0, err - } - p.skipSpaces() - if p.pos < len(p.input) && p.input[p.pos] == '^' { - p.pos++ - // Right-associative: 2^3^2 = 2^(3^2) - exp, err := p.parsePower() - if err != nil { - return 0, err - } - return math.Pow(base, exp), nil - } - return base, nil -} - -func (p *calcParser) parseUnary() (float64, error) { - p.skipSpaces() - if p.pos < len(p.input) && p.input[p.pos] == '-' { - p.pos++ - val, err := p.parseUnary() - if err != nil { - return 0, err - } - return -val, nil - } - if p.pos < len(p.input) && p.input[p.pos] == '+' { - p.pos++ - return p.parseUnary() - } - return p.parsePrimary() -} - -func (p *calcParser) parsePrimary() (float64, error) { - p.skipSpaces() - if p.pos >= len(p.input) { - return 0, fmt.Errorf("unexpected end of expression") - } - - // Parenthesized expression - if p.input[p.pos] == '(' { - p.pos++ - val, err := p.parseExpr() - if err != nil { - return 0, err - } - p.skipSpaces() - if p.pos >= len(p.input) || p.input[p.pos] != ')' { - return 0, fmt.Errorf("missing closing parenthesis") - } - p.pos++ - return val, nil - } - - // Identifier (function or constant) - if isAlpha(p.input[p.pos]) { - name := p.parseIdent() - return p.evalIdentifier(name) - } - - // Number - return p.parseNumber() -} - -func (p *calcParser) parseIdent() string { - start := p.pos - for p.pos < len(p.input) && (isAlpha(p.input[p.pos]) || isDigit(p.input[p.pos])) { - p.pos++ - } - return strings.ToLower(p.input[start:p.pos]) -} - -func (p *calcParser) evalIdentifier(name string) (float64, error) { - // Constants - switch name { - case "pi": - return math.Pi, nil - case "e": - return math.E, nil - case "inf": - return math.Inf(1), nil - } - - // Functions (require parenthesized argument) - p.skipSpaces() - if p.pos >= len(p.input) || p.input[p.pos] != '(' { - return 0, fmt.Errorf("unknown constant %q (did you mean %s(x)?)", name, name) - } - p.pos++ // consume '(' - - arg, err := p.parseExpr() - if err != nil { - return 0, err - } - - // Check for second argument (e.g. pow(2, 3)) - p.skipSpaces() - var arg2 float64 - hasArg2 := false - if p.pos < len(p.input) && p.input[p.pos] == ',' { - p.pos++ - arg2, err = p.parseExpr() - if err != nil { - return 0, err - } - hasArg2 = true - } - - p.skipSpaces() - if p.pos >= len(p.input) || p.input[p.pos] != ')' { - return 0, fmt.Errorf("missing closing parenthesis for %s()", name) - } - p.pos++ - - switch name { - case "sqrt": - return math.Sqrt(arg), nil - case "abs": - return math.Abs(arg), nil - case "ceil": - return math.Ceil(arg), nil - case "floor": - return math.Floor(arg), nil - case "round": - return math.Round(arg), nil - case "log", "ln": - return math.Log(arg), nil - case "log2": - return math.Log2(arg), nil - case "log10": - return math.Log10(arg), nil - case "sin": - return math.Sin(arg), nil - case "cos": - return math.Cos(arg), nil - case "tan": - return math.Tan(arg), nil - case "asin": - return math.Asin(arg), nil - case "acos": - return math.Acos(arg), nil - case "atan": - return math.Atan(arg), nil - case "exp": - return math.Exp(arg), nil - case "pow": - if !hasArg2 { - return 0, fmt.Errorf("pow() requires two arguments: pow(base, exponent)") - } - return math.Pow(arg, arg2), nil - case "max": - if !hasArg2 { - return 0, fmt.Errorf("max() requires two arguments") - } - return math.Max(arg, arg2), nil - case "min": - if !hasArg2 { - return 0, fmt.Errorf("min() requires two arguments") - } - return math.Min(arg, arg2), nil - default: - return 0, fmt.Errorf("unknown function %q", name) - } -} - -func (p *calcParser) parseNumber() (float64, error) { - start := p.pos - if p.pos < len(p.input) && p.input[p.pos] == '.' { - p.pos++ - } - if p.pos >= len(p.input) || !isDigit(p.input[p.pos]) { - if p.pos > start { - // lone dot - return 0, fmt.Errorf("invalid number at position %d", start) - } - return 0, fmt.Errorf("expected number at position %d, got %q", p.pos, string(p.input[p.pos])) - } - for p.pos < len(p.input) && isDigit(p.input[p.pos]) { - p.pos++ - } - if p.pos < len(p.input) && p.input[p.pos] == '.' { - p.pos++ - for p.pos < len(p.input) && isDigit(p.input[p.pos]) { - p.pos++ - } - } - // Scientific notation - if p.pos < len(p.input) && (p.input[p.pos] == 'e' || p.input[p.pos] == 'E') { - p.pos++ - if p.pos < len(p.input) && (p.input[p.pos] == '+' || p.input[p.pos] == '-') { - p.pos++ - } - for p.pos < len(p.input) && isDigit(p.input[p.pos]) { - p.pos++ - } - } - - val, err := strconv.ParseFloat(p.input[start:p.pos], 64) - if err != nil { - return 0, fmt.Errorf("invalid number %q", p.input[start:p.pos]) - } - return val, nil -} - -func (p *calcParser) skipSpaces() { - for p.pos < len(p.input) && p.input[p.pos] == ' ' { - p.pos++ - } -} - -func isAlpha(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' } -func isDigit(b byte) bool { return b >= '0' && b <= '9' } - diff --git a/server/tools/conversation_search.go b/server/tools/conversation_search.go deleted file mode 100644 index 72388f8..0000000 --- a/server/tools/conversation_search.go +++ /dev/null @@ -1,102 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" - "time" - - "switchboard-core/store" -) - -// ── Late Registration ──────────────────────── - -func RegisterConversationSearch(stores store.Stores) { - Register(&conversationSearchTool{stores: stores}) -} - -// ═══════════════════════════════════════════ -// conversation_search -// ═══════════════════════════════════════════ - -type conversationSearchTool struct { - BaseTool - stores store.Stores -} - -func (t *conversationSearchTool) Definition() ToolDef { - return ToolDef{ - Name: "conversation_search", - DisplayName: "Search Chat", - Category: "context", - Description: "Search earlier messages in this conversation by keyword. " + - "Useful when the conversation is long or has been summarized and " + - "you need to find specific details from earlier discussion. " + - "Returns matching message excerpts with timestamps.", - Parameters: JSONSchema(map[string]interface{}{ - "query": Prop("string", "Search keywords — use terms likely to appear in the messages"), - "max_results": map[string]interface{}{ - "type": "integer", - "description": "Maximum results to return (1-20, default 5)", - }, - "role_filter": PropEnum( - "Filter by message role (default: all)", - "all", "user", "assistant", - ), - }, []string{"query"}), - } -} - -func (t *conversationSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Query string `json:"query"` - MaxResults int `json:"max_results"` - RoleFilter string `json:"role_filter"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Query == "" { - return "", fmt.Errorf("query is required") - } - if args.MaxResults <= 0 || args.MaxResults > 20 { - args.MaxResults = 5 - } - - storeResults, err := t.stores.Messages.SearchInChannel(ctx, execCtx.ChannelID, args.Query, args.RoleFilter, args.MaxResults) - if err != nil { - return "", fmt.Errorf("search failed: %w", err) - } - - type searchResult struct { - MessageID string `json:"message_id"` - Role string `json:"role"` - Excerpt string `json:"excerpt"` - Rank float64 `json:"rank"` - Timestamp string `json:"timestamp"` - } - - results := make([]searchResult, 0, len(storeResults)) - for _, sr := range storeResults { - results = append(results, searchResult{ - MessageID: sr.MessageID, - Role: sr.Role, - Excerpt: sr.Excerpt, - Rank: sr.Rank, - Timestamp: sr.Timestamp.Format(time.RFC3339), - }) - } - - log.Printf("🔍 conversation_search: %q → %d results in channel %s", - args.Query, len(results), execCtx.ChannelID) - - out, _ := json.Marshal(map[string]interface{}{ - "results": results, - "query": args.Query, - "total": len(results), - "channel_id": execCtx.ChannelID, - }) - return string(out), nil -} diff --git a/server/tools/datetime.go b/server/tools/datetime.go deleted file mode 100644 index 51d5cc6..0000000 --- a/server/tools/datetime.go +++ /dev/null @@ -1,66 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "time" -) - -func init() { - Register(&DateTimeTool{}) -} - -// ═══════════════════════════════════════════ -// datetime -// ═══════════════════════════════════════════ - -type DateTimeTool struct{ BaseTool } - -func (t *DateTimeTool) Definition() ToolDef { - return ToolDef{ - Name: "datetime", - DisplayName: "Date & Time", - Description: "Get the current date, time, timezone, and day of week. Use this whenever you need to know the current date or time, calculate days between dates, or answer questions about what day it is. Do NOT guess dates — always call this tool.", - Category: "utilities", - Parameters: JSONSchema(map[string]interface{}{ - "timezone": Prop("string", "IANA timezone name, e.g. 'America/New_York', 'UTC', 'Europe/London'. Defaults to UTC."), - }, nil), - } -} - -func (t *DateTimeTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Timezone string `json:"timezone"` - } - if argsJSON != "" { - _ = json.Unmarshal([]byte(argsJSON), &args) - } - - loc := time.UTC - if args.Timezone != "" { - parsed, err := time.LoadLocation(args.Timezone) - if err != nil { - return "", fmt.Errorf("invalid timezone %q: %w", args.Timezone, err) - } - loc = parsed - } - - now := time.Now().In(loc) - - year, week := now.ISOWeek() - - result := map[string]interface{}{ - "datetime": now.Format(time.RFC3339), - "date": now.Format("2006-01-02"), - "time": now.Format("15:04:05"), - "day": now.Weekday().String(), - "timezone": loc.String(), - "unix": now.Unix(), - "iso_week": fmt.Sprintf("%d-W%02d", year, week), - "day_of_year": now.YearDay(), - } - - b, _ := json.Marshal(result) - return string(b), nil -} diff --git a/server/tools/file_recall.go b/server/tools/file_recall.go deleted file mode 100644 index 2eb4603..0000000 --- a/server/tools/file_recall.go +++ /dev/null @@ -1,185 +0,0 @@ -package tools - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "strings" - - "switchboard-core/storage" - "switchboard-core/store" -) - -// ── Late Registration ──────────────────────── -// file_recall needs stores + objStore, which aren't available at -// init time. Called from main.go after storage init. -// If objStore is nil (storage not configured), the tool is not registered. - -func RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) { - if objStore == nil { - log.Printf("ℹ file_recall: storage not configured, tool not registered") - return - } - Register(&fileRecallTool{ - stores: stores, - objStore: objStore, - }) -} - -// ═══════════════════════════════════════════ -// file_recall -// ═══════════════════════════════════════════ - -const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images - -type fileRecallTool struct { - BaseTool - stores store.Stores - objStore storage.ObjectStore -} - -func (t *fileRecallTool) Definition() ToolDef { - return ToolDef{ - Name: "file_recall", - DisplayName: "Files", - Category: "context", - Description: "Re-read files from this conversation. " + - "Use action='list' to see available files, then action='read' " + - "with a file_id to retrieve the file content. " + - "Documents return extracted text; images return base64 data.", - Parameters: JSONSchema(map[string]interface{}{ - "action": PropEnum("Action: 'list' to see files, 'read' to get content", "list", "read"), - "file_id": Prop("string", "File ID to read (required for action='read')"), - }, []string{"action"}), - } -} - -func (t *fileRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Action string `json:"action"` - FileID string `json:"file_id"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - switch args.Action { - case "list": - return t.listFiles(ctx, execCtx) - case "read": - if args.FileID == "" { - return "", fmt.Errorf("file_id is required for action='read'") - } - return t.readFile(ctx, execCtx, args.FileID) - default: - return "", fmt.Errorf("invalid action: %q (use 'list' or 'read')", args.Action) - } -} - -// listFiles returns metadata for all files in the channel. -func (t *fileRecallTool) listFiles(ctx context.Context, execCtx ExecutionContext) (string, error) { - atts, err := t.stores.Files.GetByChannel(ctx, execCtx.ChannelID, "") - if err != nil { - return "", fmt.Errorf("failed to list files: %w", err) - } - - type attInfo struct { - ID string `json:"id"` - Filename string `json:"filename"` - ContentType string `json:"content_type"` - SizeBytes int64 `json:"size_bytes"` - HasText bool `json:"has_text"` - CreatedAt string `json:"created_at"` - } - - items := make([]attInfo, 0, len(atts)) - for _, a := range atts { - items = append(items, attInfo{ - ID: a.ID, - Filename: a.Filename, - ContentType: a.ContentType, - SizeBytes: a.SizeBytes, - HasText: a.ExtractedText != nil && *a.ExtractedText != "", - CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), - }) - } - - log.Printf("📎 file_recall: list → %d files in channel %s", len(items), execCtx.ChannelID) - - out, _ := json.Marshal(map[string]interface{}{ - "files": items, - "count": len(items), - "channel_id": execCtx.ChannelID, - }) - return string(out), nil -} - -// readFile returns the content of a specific file. -func (t *fileRecallTool) readFile(ctx context.Context, execCtx ExecutionContext, fileID string) (string, error) { - att, err := t.stores.Files.GetByID(ctx, fileID) - if err != nil { - return "", fmt.Errorf("file not found: %s", fileID) - } - - // Security: verify file belongs to this channel - if att.ChannelID != execCtx.ChannelID { - return "", fmt.Errorf("file %s does not belong to this conversation", fileID) - } - - // Documents: return extracted text - if !isImageType(att.ContentType) { - if att.ExtractedText != nil && *att.ExtractedText != "" { - log.Printf("📎 file_recall: read text → %s (%s, %d chars)", - att.Filename, att.ID, len(*att.ExtractedText)) - out, _ := json.Marshal(map[string]interface{}{ - "id": att.ID, - "filename": att.Filename, - "content_type": att.ContentType, - "content": *att.ExtractedText, - "content_type_returned": "text", - }) - return string(out), nil - } - return "", fmt.Errorf("no extracted text available for %s (content type: %s)", att.Filename, att.ContentType) - } - - // Images: read from storage and return base64 - if att.SizeBytes > maxImageBytes { - return "", fmt.Errorf("image %s is too large (%d bytes, max %d). Re-upload a smaller version", - att.Filename, att.SizeBytes, maxImageBytes) - } - - reader, size, _, err := t.objStore.Get(ctx, att.StorageKey) - if err != nil { - return "", fmt.Errorf("failed to read file from storage: %w", err) - } - defer reader.Close() - - // Read and base64-encode - data := make([]byte, size) - if _, err := io.ReadFull(reader, data); err != nil { - return "", fmt.Errorf("failed to read file data: %w", err) - } - - b64 := base64.StdEncoding.EncodeToString(data) - dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) - - log.Printf("📎 file_recall: read image → %s (%s, %d bytes)", - att.Filename, att.ID, size) - - out, _ := json.Marshal(map[string]interface{}{ - "id": att.ID, - "filename": att.Filename, - "content_type": att.ContentType, - "content": dataURI, - "content_type_returned": "base64_image", - }) - return string(out), nil -} - -func isImageType(ct string) bool { - return strings.HasPrefix(ct, "image/") -} diff --git a/server/tools/git.go b/server/tools/git.go deleted file mode 100644 index 9fdc98d..0000000 --- a/server/tools/git.go +++ /dev/null @@ -1,225 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - - "switchboard-core/workspace" -) - -// RegisterGitTools registers the 5 git-related workspace tools. -// Each declares RequireWorkspace + DenyVisitor availability via workspaceToolBase. -func RegisterGitTools(gitOps *workspace.GitOps) { - Register(&gitStatusTool{gitOps: gitOps}) - Register(&gitDiffTool{gitOps: gitOps}) - Register(&gitCommitTool{gitOps: gitOps}) - Register(&gitLogTool{gitOps: gitOps}) - Register(&gitBranchTool{gitOps: gitOps}) -} - -// ── git_status ─────────────────────────────── - -type gitStatusTool struct { - workspaceToolBase - gitOps *workspace.GitOps -} - -func (t *gitStatusTool) Definition() ToolDef { - return ToolDef{ - Name: "git_status", - DisplayName: "Git Status", - Description: "Show git working tree status: branch name, staged/modified/untracked files, ahead/behind remote counts.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{}, nil), - } -} - -func (t *gitStatusTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID) - if err != nil { - return "", err - } - status, err := t.gitOps.Status(ctx, w) - if err != nil { - return "", err - } - data, _ := json.MarshalIndent(status, "", " ") - return string(data), nil -} - -// ── git_diff ───────────────────────────────── - -type gitDiffTool struct { - workspaceToolBase - gitOps *workspace.GitOps -} - -func (t *gitDiffTool) Definition() ToolDef { - return ToolDef{ - Name: "git_diff", - DisplayName: "Git Diff", - Description: "Show git diff for a specific file or all uncommitted changes.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "path": Prop("string", "File path to diff (empty for all changes)"), - }, nil), - } -} - -func (t *gitDiffTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Path string `json:"path"` - } - json.Unmarshal([]byte(argsJSON), &args) - - w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID) - if err != nil { - return "", err - } - diff, err := t.gitOps.Diff(ctx, w, args.Path) - if err != nil { - return "", err - } - if diff == "" { - return "No changes.", nil - } - if len(diff) > 50000 { - diff = diff[:50000] + "\n... (truncated)" - } - return diff, nil -} - -// ── git_commit ─────────────────────────────── - -type gitCommitTool struct { - workspaceToolBase - gitOps *workspace.GitOps -} - -func (t *gitCommitTool) Definition() ToolDef { - return ToolDef{ - Name: "git_commit", - DisplayName: "Git Commit", - Description: "Stage and commit files. Stages all changes if no paths specified.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "message": Prop("string", "Commit message"), - "paths": PropArray("Specific file paths to stage (empty = stage all)"), - }, []string{"message"}), - } -} - -func (t *gitCommitTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Message string `json:"message"` - Paths []string `json:"paths"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.Message == "" { - return "", fmt.Errorf("commit message is required") - } - - w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID) - if err != nil { - return "", err - } - if err := t.gitOps.Commit(ctx, w, args.Message, args.Paths); err != nil { - return "", err - } - return fmt.Sprintf("Committed: %s", args.Message), nil -} - -// ── git_log ────────────────────────────────── - -type gitLogTool struct { - workspaceToolBase - gitOps *workspace.GitOps -} - -func (t *gitLogTool) Definition() ToolDef { - return ToolDef{ - Name: "git_log", - DisplayName: "Git Log", - Description: "Show recent commit history.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "count": Prop("integer", "Number of commits to show (default 20, max 100)"), - }, nil), - } -} - -func (t *gitLogTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Count int `json:"count"` - } - json.Unmarshal([]byte(argsJSON), &args) - if args.Count <= 0 { - args.Count = 20 - } - if args.Count > 100 { - args.Count = 100 - } - - w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID) - if err != nil { - return "", err - } - entries, err := t.gitOps.Log(ctx, w, args.Count) - if err != nil { - return "", err - } - data, _ := json.MarshalIndent(entries, "", " ") - return string(data), nil -} - -// ── git_branch ─────────────────────────────── - -type gitBranchTool struct { - workspaceToolBase - gitOps *workspace.GitOps -} - -func (t *gitBranchTool) Definition() ToolDef { - return ToolDef{ - Name: "git_branch", - DisplayName: "Git Branch", - Description: "List branches or switch to a different branch.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "checkout": Prop("string", "Branch name to switch to (empty = list only)"), - }, nil), - } -} - -func (t *gitBranchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Checkout string `json:"checkout"` - } - json.Unmarshal([]byte(argsJSON), &args) - - w, err := t.gitOps.LoadWorkspace(ctx, execCtx.WorkspaceID) - if err != nil { - return "", err - } - - if args.Checkout != "" { - if err := t.gitOps.Checkout(ctx, w, args.Checkout); err != nil { - return "", err - } - return fmt.Sprintf("Switched to branch: %s", args.Checkout), nil - } - - branches, current, err := t.gitOps.BranchList(ctx, w) - if err != nil { - return "", err - } - result := map[string]interface{}{ - "current": current, - "branches": branches, - } - data, _ := json.MarshalIndent(result, "", " ") - return string(data), nil -} diff --git a/server/tools/kbsearch.go b/server/tools/kbsearch.go deleted file mode 100644 index a0c7875..0000000 --- a/server/tools/kbsearch.go +++ /dev/null @@ -1,186 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" - - "switchboard-core/knowledge" - "switchboard-core/store" -) - -// ── Late Registration ──────────────────────── -// kb_search cannot use init() because it needs stores + embedder, -// which aren't available until after main.go initializes them. -// Call RegisterKBSearch() from main.go after stores init. - -// RegisterKBSearch registers the kb_search tool with captured dependencies. -func RegisterKBSearch(stores store.Stores, embedder *knowledge.Embedder) { - Register(&kbSearchTool{ - stores: stores, - embedder: embedder, - }) -} - -// ═══════════════════════════════════════════ -// kb_search -// ═══════════════════════════════════════════ - -type kbSearchTool struct { - BaseTool - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *kbSearchTool) Definition() ToolDef { - return ToolDef{ - Name: "kb_search", - DisplayName: "Knowledge Base", - Category: "knowledge", - Description: "Search knowledge bases for relevant information. " + - "Returns text passages from uploaded documents that match the query. " + - "Use this when the user asks questions that might be answered by their documents.", - Parameters: JSONSchema(map[string]interface{}{ - "query": Prop("string", "Search query — use natural language to describe what you're looking for"), - "max_results": map[string]interface{}{ - "type": "integer", - "description": "Maximum results to return (1-20, default 5)", - }, - }, []string{"query"}), - } -} - -func (t *kbSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Query string `json:"query"` - MaxResults int `json:"max_results"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Query == "" { - return "", fmt.Errorf("query is required") - } - if args.MaxResults <= 0 || args.MaxResults > 20 { - args.MaxResults = 5 - } - - // Resolve user's team IDs for scoped access - teamIDs, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID) - - // Get active KB IDs for this channel, including persona-bound KBs - var kbIDs []string - var err error - if execCtx.PersonaID != "" { - kbIDs, err = t.stores.KnowledgeBases.GetActiveKBIDsWithPersona( - ctx, execCtx.ChannelID, execCtx.UserID, teamIDs, execCtx.PersonaID) - } else { - kbIDs, err = t.stores.KnowledgeBases.GetActiveKBIDs( - ctx, execCtx.ChannelID, execCtx.UserID, teamIDs) - } - if err != nil { - return "", fmt.Errorf("failed to look up active knowledge bases: %w", err) - } - - // Also include project-bound KBs (v0.19.0) - if t.stores.Projects != nil { - projID, _ := t.stores.Projects.GetProjectIDForChannel(ctx, execCtx.ChannelID) - if projID != "" { - projKBIDs, _ := t.stores.Projects.GetKBIDs(ctx, projID) - projSet := make(map[string]bool, len(kbIDs)) - for _, id := range kbIDs { - projSet[id] = true - } - for _, id := range projKBIDs { - if !projSet[id] { - kbIDs = append(kbIDs, id) - projSet[id] = true - } - } - } - } - - // Also include personal KBs (always available to owner, even if not linked to channel) - personalKBs, err := t.stores.KnowledgeBases.ListPersonal(ctx, execCtx.UserID) - if err == nil { - kbIDSet := make(map[string]bool, len(kbIDs)) - for _, id := range kbIDs { - kbIDSet[id] = true - } - for _, kb := range personalKBs { - if !kbIDSet[kb.ID] && kb.ChunkCount > 0 { - kbIDs = append(kbIDs, kb.ID) - } - } - } - - if len(kbIDs) == 0 { - result, _ := json.Marshal(map[string]interface{}{ - "results": []interface{}{}, - "query": args.Query, - "searched_kbs": []string{}, - "message": "No knowledge bases are active on this channel. Link a knowledge base to the channel or upload documents to a personal knowledge base first.", - }) - return string(result), nil - } - - // Resolve team ID for embedding role resolution (pick first team) - var teamID *string - if len(teamIDs) > 0 { - teamID = &teamIDs[0] - } - - // Embed the query - embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, teamID, []string{args.Query}) - if err != nil { - return "", fmt.Errorf("failed to embed search query: %w", err) - } - if len(embedResult.Vectors) == 0 { - return "", fmt.Errorf("embedding produced no vectors") - } - - queryVec := embedResult.Vectors[0] - - // Track embedding usage (role = "embedding") - chanPtr := &execCtx.ChannelID - t.embedder.LogUsage(ctx, execCtx.UserID, chanPtr, embedResult) - - // Similarity search across all active KBs - const defaultThreshold = 0.3 - results, err := t.stores.KnowledgeBases.SimilaritySearch(ctx, kbIDs, queryVec, defaultThreshold, args.MaxResults) - if err != nil { - return "", fmt.Errorf("search failed: %w", err) - } - - // Collect searched KB names for attribution - searchedKBs := make(map[string]bool) - for _, r := range results { - searchedKBs[r.KBName] = true - } - kbNames := make([]string, 0, len(searchedKBs)) - for name := range searchedKBs { - kbNames = append(kbNames, name) - } - - // If no results found from search, list all searched KB names - if len(kbNames) == 0 { - for _, kbID := range kbIDs { - kb, err := t.stores.KnowledgeBases.GetByID(ctx, kbID) - if err == nil { - kbNames = append(kbNames, kb.Name) - } - } - } - - log.Printf("🔍 kb_search: %q → %d results across %d KBs", args.Query, len(results), len(kbIDs)) - - out, _ := json.Marshal(map[string]interface{}{ - "results": results, - "query": args.Query, - "total": len(results), - "searched_kbs": kbNames, - }) - return string(out), nil -} diff --git a/server/tools/memory.go b/server/tools/memory.go deleted file mode 100644 index c990a13..0000000 --- a/server/tools/memory.go +++ /dev/null @@ -1,233 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" - "strings" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Late Registration ──────────────────────── -// Memory tools use late registration because they need stores + embedder. - -// RegisterMemoryTools registers the memory_save and memory_recall tools. -// Called from main.go after stores and embedder are initialized. -func RegisterMemoryTools(stores store.Stores, embedder *knowledge.Embedder) { - if stores.Memories == nil { - log.Println("⚠ memory tools: MemoryStore not available, skipping registration") - return - } - - Register(&memorySaveTool{stores: stores, embedder: embedder}) - Register(&memoryRecallTool{stores: stores, embedder: embedder}) - - log.Println("✅ memory tools registered (memory_save, memory_recall)") -} - -// ═══════════════════════════════════════════ -// memory_save -// ═══════════════════════════════════════════ - -type memorySaveTool struct { - visitorDeniedBase - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *memorySaveTool) Definition() ToolDef { - return ToolDef{ - Name: "memory_save", - DisplayName: "Save Memory", - Category: "memory", - Description: "Save a fact or preference about the user for future conversations. " + - "Use this when the user shares something worth remembering long-term " + - "(preferences, technical stack, project details, personal facts). " + - "Memories persist across all conversations.", - Parameters: JSONSchema(map[string]interface{}{ - "key": Prop("string", "Short label for the memory (2-5 words, e.g. 'preferred language', 'deployment target')"), - "value": Prop("string", "The fact or detail to remember (1-2 sentences)"), - "confidence": map[string]interface{}{ - "type": "number", - "description": "How confident you are in this fact (0.0-1.0, default 0.9)", - }, - }, []string{"key", "value"}), - } -} - -func (t *memorySaveTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Key string `json:"key"` - Value string `json:"value"` - Confidence float64 `json:"confidence"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Key == "" || args.Value == "" { - return "", fmt.Errorf("key and value are required") - } - - confidence := args.Confidence - if confidence <= 0 || confidence > 1.0 { - confidence = 0.9 - } - - mem := &models.Memory{ - ID: store.NewID(), - Scope: models.MemoryScopeUser, - OwnerID: execCtx.UserID, - Key: strings.TrimSpace(args.Key), - Value: strings.TrimSpace(args.Value), - Confidence: confidence, - Status: models.MemoryStatusActive, - } - - if execCtx.ChannelID != "" { - mem.SourceChannelID = &execCtx.ChannelID - } - - // If persona is active, use persona_user scope - if execCtx.PersonaID != "" { - mem.Scope = models.MemoryScopePersonaUser - mem.OwnerID = execCtx.PersonaID - mem.UserID = &execCtx.UserID - } - - if err := t.stores.Memories.Upsert(ctx, mem); err != nil { - return "", fmt.Errorf("save memory: %w", err) - } - - // Embed for semantic recall - t.embedMemory(ctx, mem, execCtx.UserID) - - return fmt.Sprintf("Remembered: %s = %s (confidence: %.0f%%)", - args.Key, args.Value, confidence*100), nil -} - -func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, userID string) { - if t.embedder == nil || !t.embedder.IsConfigured(ctx) { - return - } - - text := m.Key + ": " + m.Value - - var teamID *string - if t.stores.Teams != nil { - ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, userID) - if len(ids) > 0 { - teamID = &ids[0] - } - } - - result, err := t.embedder.EmbedChunks(ctx, userID, teamID, []string{text}) - if err != nil || len(result.Vectors) == 0 { - return - } - - vecStr := vectorToString(result.Vectors[0]) - _ = t.stores.Memories.SetEmbedding(ctx, m.ID, vecStr) - - log.Printf("🧠 memory %s embedded (%d dims)", m.ID[:8], len(result.Vectors[0])) -} - -// ═══════════════════════════════════════════ -// memory_recall -// ═══════════════════════════════════════════ - -type memoryRecallTool struct { - visitorDeniedBase - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *memoryRecallTool) Definition() ToolDef { - return ToolDef{ - Name: "memory_recall", - DisplayName: "Recall Memories", - Category: "memory", - Description: "Search your memories about this user. Use this at the start of conversations " + - "or when you need context about the user's preferences, projects, or history.", - Parameters: JSONSchema(map[string]interface{}{ - "query": Prop("string", "Optional search query to filter memories (leave empty for all)"), - }, nil), - } -} - -func (t *memoryRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Query string `json:"query"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - var personaID *string - if execCtx.PersonaID != "" { - personaID = &execCtx.PersonaID - } - - // Try hybrid recall if embedder available and query provided - var memories []models.Memory - var err error - - if t.embedder != nil && t.embedder.IsConfigured(ctx) && args.Query != "" { - memories, err = t.recallWithEmbedding(ctx, execCtx.UserID, personaID, args.Query) - } - - // Fall back to keyword recall - if err != nil || len(memories) == 0 { - memories, err = t.stores.Memories.Recall(ctx, execCtx.UserID, personaID, args.Query, 20) - } - - if err != nil { - return "", fmt.Errorf("recall memories: %w", err) - } - - if len(memories) == 0 { - suffix := "" - if args.Query != "" { - suffix = " matching '" + args.Query + "'" - } - return "No memories found" + suffix + ".", nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Found %d memories:\n\n", len(memories))) - for _, m := range memories { - scope := m.Scope - if scope == "persona_user" { - scope = "persona" - } - sb.WriteString(fmt.Sprintf("• [%s] %s: %s (%.0f%% confidence)\n", - scope, m.Key, m.Value, m.Confidence*100)) - } - return sb.String(), nil -} - -func (t *memoryRecallTool) recallWithEmbedding(ctx context.Context, userID string, personaID *string, query string) ([]models.Memory, error) { - text := query - if len(text) > 2000 { - text = text[:2000] - } - - var teamID *string - if t.stores.Teams != nil { - ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, userID) - if len(ids) > 0 { - teamID = &ids[0] - } - } - - result, err := t.embedder.EmbedChunks(ctx, userID, teamID, []string{text}) - if err != nil || len(result.Vectors) == 0 { - return nil, err - } - - return t.stores.Memories.RecallHybrid(ctx, userID, personaID, query, result.Vectors[0], 20) -} diff --git a/server/tools/notes.go b/server/tools/notes.go deleted file mode 100644 index 56fe3d2..0000000 --- a/server/tools/notes.go +++ /dev/null @@ -1,520 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" - "strings" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Late Registration ──────────────────────── -// Note tools use late registration (like kb_search) because -// note_create, note_update, and note_search need the embedder for -// vector operations. The embedder is optional — if nil, notes still -// work but semantic search degrades to full-text search. - -// RegisterNoteTools registers all note tools with captured dependencies. -// Call from main.go after stores + embedder init. -func RegisterNoteTools(stores store.Stores, embedder *knowledge.Embedder) { - Register(¬eCreateTool{stores: stores, embedder: embedder}) - Register(¬eSearchTool{stores: stores, embedder: embedder}) - Register(¬eUpdateTool{stores: stores, embedder: embedder}) - Register(¬eListTool{stores: stores}) -} - -// ── Shared helpers ───────────────────────────── - -// embedNote generates a vector for a note's title+content and stores it. -// Silently no-ops if embedder is nil or not configured. -func embedNote(ctx context.Context, embedder *knowledge.Embedder, stores store.Stores, noteID, userID, title, content string) { - if embedder == nil || !embedder.IsConfigured(ctx) { - return - } - - text := title + "\n\n" + content - if len(text) > 8000 { - text = text[:8000] // limit to ~2k tokens for embedding - } - - // Resolve team for embedding role - var teamID *string - if stores.Teams != nil { - ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID) - if len(ids) > 0 { - teamID = &ids[0] - } - } - - result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text}) - if err != nil { - log.Printf("⚠ note embed failed for %s: %v", noteID, err) - return - } - if len(result.Vectors) == 0 { - return - } - - // Store the vector via store method - err = stores.Notes.SetEmbedding(ctx, noteID, vectorToString(result.Vectors[0])) - if err != nil { - log.Printf("⚠ note embed store failed for %s: %v", noteID, err) - return - } - - // Track embedding usage - embedder.LogUsage(ctx, userID, nil, result) - log.Printf("📝 note %s embedded (%d tokens)", noteID, result.InputTokens) -} - -// vectorToString converts a float64 slice to pgvector literal format: [0.1,0.2,...] -func vectorToString(vec []float64) string { - parts := make([]string, len(vec)) - for i, v := range vec { - parts[i] = fmt.Sprintf("%g", v) - } - return "[" + strings.Join(parts, ",") + "]" -} - -// ═══════════════════════════════════════════ -// note_create -// ═══════════════════════════════════════════ - -type noteCreateTool struct { - visitorDeniedBase - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *noteCreateTool) Definition() ToolDef { - return ToolDef{ - Name: "note_create", - DisplayName: "Create", - Category: "notes", - Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.", - Parameters: JSONSchema(map[string]interface{}{ - "title": Prop("string", "Title of the note"), - "content": Prop("string", "Markdown content of the note"), - "folder": Prop("string", "Folder path, e.g. '/projects/switchboard/'. Defaults to '/'"), - "tags": PropArray("Tags for categorization, e.g. ['research', 'golang']"), - }, []string{"title", "content"}), - } -} - -func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Title string `json:"title"` - Content string `json:"content"` - Folder string `json:"folder"` - Tags []string `json:"tags"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Title == "" { - return "", fmt.Errorf("title is required") - } - - folder := normalizePath(args.Folder) - tags := args.Tags - if tags == nil { - tags = []string{} - } - - // source_channel_id links the note to the conversation that created it - var sourceChannelID *string - if execCtx.ChannelID != "" { - sourceChannelID = &execCtx.ChannelID - } - - note := &models.Note{ - UserID: execCtx.UserID, - Title: args.Title, - Content: args.Content, - FolderPath: folder, - Tags: tags, - SourceChannelID: sourceChannelID, - } - - if err := t.stores.Notes.Create(ctx, note); err != nil { - return "", fmt.Errorf("failed to create note: %w", err) - } - - // Embed async — don't block the tool response - go embedNote(context.Background(), t.embedder, t.stores, note.ID, execCtx.UserID, args.Title, args.Content) - - result, _ := json.Marshal(map[string]interface{}{ - "id": note.ID, - "title": args.Title, - "folder": folder, - "tags": tags, - "created_at": note.CreatedAt.Format("2006-01-02T15:04:05Z"), - "message": fmt.Sprintf("Note '%s' created successfully.", args.Title), - }) - return string(result), nil -} - -// ═══════════════════════════════════════════ -// note_search -// ═══════════════════════════════════════════ - -type noteSearchTool struct { - visitorDeniedBase - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *noteSearchTool) Definition() ToolDef { - return ToolDef{ - Name: "note_search", - DisplayName: "Search", - Category: "notes", - Description: "Search the user's notes by keyword or semantic meaning. " + - "Set semantic=true for meaning-based search using AI embeddings, " + - "or omit for fast keyword matching. Returns matching notes ranked by relevance.", - Parameters: JSONSchema(map[string]interface{}{ - "query": Prop("string", "Search query — natural language keywords or question"), - "limit": Prop("integer", "Maximum results to return (default 10, max 50)"), - "semantic": map[string]interface{}{ - "type": "boolean", - "description": "Use semantic (vector) search instead of keyword search. Better for meaning-based queries.", - }, - }, []string{"query"}), - } -} - -func (t *noteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Query string `json:"query"` - Limit int `json:"limit"` - Semantic bool `json:"semantic"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Query == "" { - return "", fmt.Errorf("query is required") - } - if args.Limit <= 0 || args.Limit > 50 { - args.Limit = 10 - } - - // Semantic search via vector similarity - if args.Semantic { - return t.semanticSearch(ctx, execCtx, args.Query, args.Limit) - } - - // Default: full-text keyword search - return t.keywordSearch(ctx, execCtx, args.Query, args.Limit) -} - -func (t *noteSearchTool) keywordSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) { - storeResults, err := t.stores.Notes.SearchKeyword(ctx, execCtx.UserID, query, limit) - if err != nil { - return "", fmt.Errorf("search failed: %w", err) - } - - results := make([]noteSearchResult, 0, len(storeResults)) - for _, sr := range storeResults { - results = append(results, noteSearchResult{ - ID: sr.ID, - Title: sr.Title, - Folder: sr.FolderPath, - Tags: sr.Tags, - Excerpt: sr.Excerpt, - Headline: sr.Headline, - Rank: sr.Rank, - }) - } - - out, _ := json.Marshal(map[string]interface{}{ - "query": query, - "mode": "keyword", - "count": len(results), - "results": results, - }) - return string(out), nil -} - -func (t *noteSearchTool) semanticSearch(ctx context.Context, execCtx ExecutionContext, query string, limit int) (string, error) { - // Check embedder availability — fall back to keyword search if not configured - if t.embedder == nil || !t.embedder.IsConfigured(ctx) { - log.Printf("⚠ note semantic search: embedder not configured, falling back to keyword") - return t.keywordSearch(ctx, execCtx, query, limit) - } - - // Resolve team for embedding role - var teamID *string - if t.stores.Teams != nil { - ids, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID) - if len(ids) > 0 { - teamID = &ids[0] - } - } - - // Embed the query - embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, teamID, []string{query}) - if err != nil { - log.Printf("⚠ note semantic search embed failed: %v — falling back to keyword", err) - return t.keywordSearch(ctx, execCtx, query, limit) - } - if len(embedResult.Vectors) == 0 { - return t.keywordSearch(ctx, execCtx, query, limit) - } - - // Track embedding usage - chanPtr := &execCtx.ChannelID - t.embedder.LogUsage(ctx, execCtx.UserID, chanPtr, embedResult) - - queryVec := vectorToString(embedResult.Vectors[0]) - - // Vector similarity search via store - storeResults, err := t.stores.Notes.SearchSemantic(ctx, execCtx.UserID, queryVec, limit) - if err != nil { - log.Printf("⚠ note semantic search query failed: %v — falling back to keyword", err) - return t.keywordSearch(ctx, execCtx, query, limit) - } - - results := make([]noteSearchResult, 0, len(storeResults)) - for _, sr := range storeResults { - results = append(results, noteSearchResult{ - ID: sr.ID, - Title: sr.Title, - Folder: sr.FolderPath, - Tags: sr.Tags, - Excerpt: sr.Excerpt, - Similarity: sr.Rank, - }) - } - - out, _ := json.Marshal(map[string]interface{}{ - "query": query, - "mode": "semantic", - "count": len(results), - "results": results, - }) - return string(out), nil -} - -type noteSearchResult struct { - ID string `json:"id"` - Title string `json:"title"` - Folder string `json:"folder"` - Tags []string `json:"tags"` - Excerpt string `json:"excerpt"` - Headline string `json:"headline,omitempty"` - Rank float64 `json:"rank,omitempty"` - Similarity float64 `json:"similarity,omitempty"` -} - -// ═══════════════════════════════════════════ -// note_update -// ═══════════════════════════════════════════ - -type noteUpdateTool struct { - visitorDeniedBase - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *noteUpdateTool) Definition() ToolDef { - return ToolDef{ - Name: "note_update", - DisplayName: "Update", - Category: "notes", - Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.", - Parameters: JSONSchema(map[string]interface{}{ - "note_id": Prop("string", "UUID of the note to update (from note_search results)"), - "title": Prop("string", "New title (omit to keep current)"), - "content": Prop("string", "New content or text to append/prepend"), - "mode": PropEnum("How to apply content", "replace", "append", "prepend"), - "tags": PropArray("New tags (replaces existing tags)"), - }, []string{"note_id"}), - } -} - -func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - NoteID string `json:"note_id"` - Title *string `json:"title"` - Content *string `json:"content"` - Mode string `json:"mode"` - Tags []string `json:"tags"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.NoteID == "" { - return "", fmt.Errorf("note_id is required") - } - - // Verify ownership + get existing content for append/prepend - existing, err := t.stores.Notes.GetByID(ctx, args.NoteID) - if err != nil || existing.UserID != execCtx.UserID { - return "", fmt.Errorf("note not found or update failed") - } - - // Build fields map for store.Update - fields := map[string]interface{}{} - - if args.Title != nil { - fields["title"] = *args.Title - } - - if args.Content != nil { - mode := strings.ToLower(args.Mode) - switch mode { - case "append": - fields["content"] = existing.Content + *args.Content - case "prepend": - fields["content"] = *args.Content + existing.Content - default: - fields["content"] = *args.Content - } - } - - if args.Tags != nil { - fields["tags"] = args.Tags - } - - if len(fields) == 0 { - return "", fmt.Errorf("no fields to update — provide at least title, content, or tags") - } - - if err := t.stores.Notes.Update(ctx, args.NoteID, fields); err != nil { - return "", fmt.Errorf("note not found or update failed") - } - - // Re-fetch to get updated values - updated, err := t.stores.Notes.GetByID(ctx, args.NoteID) - if err != nil { - return "", fmt.Errorf("note not found or update failed") - } - - // Re-embed async with updated content - go embedNote(context.Background(), t.embedder, t.stores, updated.ID, execCtx.UserID, updated.Title, updated.Content) - - result, _ := json.Marshal(map[string]interface{}{ - "id": updated.ID, - "title": updated.Title, - "updated_at": updated.UpdatedAt.Format("2006-01-02T15:04:05Z"), - "message": fmt.Sprintf("Note '%s' updated successfully.", updated.Title), - }) - return string(result), nil -} - -// ═══════════════════════════════════════════ -// note_list -// ═══════════════════════════════════════════ - -type noteListTool struct { - visitorDeniedBase - stores store.Stores -} - -func (t *noteListTool) Definition() ToolDef { - return ToolDef{ - Name: "note_list", - DisplayName: "List", - Category: "notes", - Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.", - Parameters: JSONSchema(map[string]interface{}{ - "folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"), - "tag": Prop("string", "Filter by tag (omit for all)"), - "limit": Prop("integer", "Maximum results (default 20, max 100)"), - }, nil), - } -} - -func (t *noteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Folder string `json:"folder"` - Tag string `json:"tag"` - Limit int `json:"limit"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Limit <= 0 || args.Limit > 100 { - args.Limit = 20 - } - - opts := store.NoteListOptions{ - ListOptions: store.ListOptions{Limit: args.Limit}, - } - if args.Folder != "" { - opts.FolderPath = normalizePath(args.Folder) - } - if args.Tag != "" { - opts.Tag = args.Tag - } - - notes, _, err := t.stores.Notes.ListForUser(ctx, execCtx.UserID, opts) - if err != nil { - return "", fmt.Errorf("failed to list notes: %w", err) - } - - type item struct { - ID string `json:"id"` - Title string `json:"title"` - Folder string `json:"folder"` - Tags []string `json:"tags"` - Preview string `json:"preview"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - - items := make([]item, 0, len(notes)) - for _, n := range notes { - tags := n.Tags - if tags == nil { - tags = []string{} - } - preview := n.Content - if len(preview) > 100 { - preview = preview[:100] - } - items = append(items, item{ - ID: n.ID, - Title: n.Title, - Folder: n.FolderPath, - Tags: tags, - Preview: preview, - CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), - UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"), - }) - } - - out, _ := json.Marshal(map[string]interface{}{ - "count": len(items), - "notes": items, - }) - return string(out), nil -} - -// ── Path Helper ───────────────────────────── - -func normalizePath(p string) string { - p = strings.TrimSpace(p) - if p == "" { - return "/" - } - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - if !strings.HasSuffix(p, "/") { - p = p + "/" - } - for strings.Contains(p, "//") { - p = strings.ReplaceAll(p, "//", "/") - } - return p -} diff --git a/server/tools/predicates.go b/server/tools/predicates.go deleted file mode 100644 index 6a06a04..0000000 --- a/server/tools/predicates.go +++ /dev/null @@ -1,85 +0,0 @@ -package tools - -// ── Built-in Predicates (v0.25.0) ────────── -// -// Predicates determine tool availability based on runtime context. -// Tools declare their availability by returning a Require from -// Availability(). The registry evaluates predicates in AvailableFor() -// before sending tool definitions to the LLM. -// -// AlwaysAvailable is defined in types.go (BaseTool references it). - -// RequireWorkspace returns true when a workspace is bound to the -// channel. Workspace and git tools use this. -func RequireWorkspace(tc ToolContext) bool { - return tc.WorkspaceID != "" -} - -// RequireWorkflow returns true when the channel is a workflow instance. -// Workflow-specific tools (e.g. workflow_advance) use this. -func RequireWorkflow(tc ToolContext) bool { - return tc.WorkflowID != "" -} - -// RequireTeam returns true when the channel belongs to a team. -func RequireTeam(tc ToolContext) bool { - return tc.TeamID != "" -} - -// DenyVisitor returns true for authenticated users, false for anonymous -// session participants (v0.24.3). Tools that expose personal data -// (memory, notes) or perform privileged operations use this. -func DenyVisitor(tc ToolContext) bool { - return !tc.IsVisitor -} - -// All combines multiple predicates — all must pass for the tool to be -// available. Short-circuits on first failure. -func All(reqs ...Require) Require { - return func(tc ToolContext) bool { - for _, r := range reqs { - if !r(tc) { - return false - } - } - return true - } -} - -// Any combines multiple predicates — at least one must pass. -// Short-circuits on first success. -func Any(reqs ...Require) Require { - return func(tc ToolContext) bool { - for _, r := range reqs { - if r(tc) { - return true - } - } - return false - } -} - -// Not inverts a predicate. -func Not(req Require) Require { - return func(tc ToolContext) bool { - return !req(tc) - } -} - -// ── Shared Base Types ─────────────────────── -// Embedded in tool structs to provide common availability predicates -// without repeating Availability() overrides on every struct. - -// workspaceToolBase provides RequireWorkspace + DenyVisitor availability. -// Embed in workspace and git tool structs. -type workspaceToolBase struct{ BaseTool } - -var _requireWorkspaceAndDenyVisitor = All(RequireWorkspace, DenyVisitor) - -func (workspaceToolBase) Availability() Require { return _requireWorkspaceAndDenyVisitor } - -// visitorDeniedBase provides DenyVisitor availability. -// Embed in memory and notes tool structs. -type visitorDeniedBase struct{ BaseTool } - -func (visitorDeniedBase) Availability() Require { return DenyVisitor } diff --git a/server/tools/predicates_test.go b/server/tools/predicates_test.go deleted file mode 100644 index 26633ef..0000000 --- a/server/tools/predicates_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package tools - -import ( - "context" - "testing" -) - -// ── Predicate Tests ───────────────────────── - -func TestAlwaysAvailable(t *testing.T) { - cases := []ToolContext{ - {}, - {ChannelType: "direct"}, - {IsVisitor: true}, - {WorkspaceID: "ws-1", WorkflowID: "wf-1", TeamID: "t-1"}, - } - for i, tc := range cases { - if !AlwaysAvailable(tc) { - t.Errorf("case %d: AlwaysAvailable returned false", i) - } - } -} - -func TestRequireWorkspace(t *testing.T) { - if RequireWorkspace(ToolContext{}) { - t.Error("Expected false when WorkspaceID is empty") - } - if !RequireWorkspace(ToolContext{WorkspaceID: "ws-123"}) { - t.Error("Expected true when WorkspaceID is set") - } -} - -func TestRequireWorkflow(t *testing.T) { - if RequireWorkflow(ToolContext{}) { - t.Error("Expected false when WorkflowID is empty") - } - if !RequireWorkflow(ToolContext{WorkflowID: "wf-456"}) { - t.Error("Expected true when WorkflowID is set") - } -} - -func TestRequireTeam(t *testing.T) { - if RequireTeam(ToolContext{}) { - t.Error("Expected false when TeamID is empty") - } - if !RequireTeam(ToolContext{TeamID: "team-1"}) { - t.Error("Expected true when TeamID is set") - } -} - -func TestDenyVisitor(t *testing.T) { - if !DenyVisitor(ToolContext{IsVisitor: false}) { - t.Error("Expected true for authenticated user") - } - if DenyVisitor(ToolContext{IsVisitor: true}) { - t.Error("Expected false for visitor") - } -} - -func TestAll(t *testing.T) { - wsAndNonVisitor := All(RequireWorkspace, DenyVisitor) - - cases := []struct { - name string - tc ToolContext - expect bool - }{ - {"both satisfied", ToolContext{WorkspaceID: "ws-1", IsVisitor: false}, true}, - {"no workspace", ToolContext{WorkspaceID: "", IsVisitor: false}, false}, - {"is visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false}, - {"neither satisfied", ToolContext{WorkspaceID: "", IsVisitor: true}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := wsAndNonVisitor(tc.tc); got != tc.expect { - t.Errorf("got %v, want %v", got, tc.expect) - } - }) - } -} - -func TestAllEmpty(t *testing.T) { - // All() with no predicates should pass (vacuous truth) - pred := All() - if !pred(ToolContext{}) { - t.Error("All() with no predicates should return true") - } -} - -func TestAny(t *testing.T) { - wsOrWorkflow := Any(RequireWorkspace, RequireWorkflow) - - cases := []struct { - name string - tc ToolContext - expect bool - }{ - {"workspace only", ToolContext{WorkspaceID: "ws-1"}, true}, - {"workflow only", ToolContext{WorkflowID: "wf-1"}, true}, - {"both", ToolContext{WorkspaceID: "ws-1", WorkflowID: "wf-1"}, true}, - {"neither", ToolContext{}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := wsOrWorkflow(tc.tc); got != tc.expect { - t.Errorf("got %v, want %v", got, tc.expect) - } - }) - } -} - -func TestAnyEmpty(t *testing.T) { - // Any() with no predicates should fail (no match) - pred := Any() - if pred(ToolContext{}) { - t.Error("Any() with no predicates should return false") - } -} - -func TestNot(t *testing.T) { - notVisitor := Not(func(tc ToolContext) bool { return tc.IsVisitor }) - if !notVisitor(ToolContext{IsVisitor: false}) { - t.Error("Not(visitor) should return true for non-visitor") - } - if notVisitor(ToolContext{IsVisitor: true}) { - t.Error("Not(visitor) should return false for visitor") - } -} - -func TestComposedPredicates(t *testing.T) { - // workspace_create: available when NO workspace AND not visitor - workspaceCreate := All(Not(RequireWorkspace), DenyVisitor) - - cases := []struct { - name string - tc ToolContext - expect bool - }{ - {"no ws, authenticated", ToolContext{}, true}, - {"has ws, authenticated", ToolContext{WorkspaceID: "ws-1"}, false}, - {"no ws, visitor", ToolContext{IsVisitor: true}, false}, - {"has ws, visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := workspaceCreate(tc.tc); got != tc.expect { - t.Errorf("got %v, want %v", got, tc.expect) - } - }) - } -} - -// ── BaseTool Tests ────────────────────────── - -type testToolWithBase struct { - BaseTool -} - -func (t *testToolWithBase) Definition() ToolDef { - return ToolDef{ - Name: "always_tool", - Description: "test tool with BaseTool embed", - Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}), - } -} - -func (t *testToolWithBase) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) { - return `{"ok":true}`, nil -} - -func TestBaseToolSatisfiesContextualTool(t *testing.T) { - tool := &testToolWithBase{} - - // Must satisfy ContextualTool - ct, ok := interface{}(tool).(ContextualTool) - if !ok { - t.Fatal("testToolWithBase should satisfy ContextualTool interface") - } - - // BaseTool.Availability() should return AlwaysAvailable - if !ct.Availability()(ToolContext{IsVisitor: true}) { - t.Error("BaseTool.Availability() should be AlwaysAvailable") - } -} - -// ── AvailableFor Tests ────────────────────── - -// testContextTool overrides availability with a custom predicate. -type testContextTool struct { - BaseTool - name string - req Require -} - -func (t *testContextTool) Definition() ToolDef { - return ToolDef{ - Name: t.name, - Description: "test contextual tool: " + t.name, - Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}), - } -} - -func (t *testContextTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) { - return `{"ok":true}`, nil -} - -func (t *testContextTool) Availability() Require { - return t.req -} - -// testPlainTool is a tool that does NOT implement ContextualTool — -// only the base Tool interface. It should always be included. -type testPlainTool struct { - name string -} - -func (t *testPlainTool) Definition() ToolDef { - return ToolDef{ - Name: t.name, - Description: "plain tool: " + t.name, - Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}), - } -} - -func (t *testPlainTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) { - return `{"ok":true}`, nil -} - -func TestAvailableFor(t *testing.T) { - // Save and restore global registry - origRegistry := registry - defer func() { registry = origRegistry }() - - registry = map[string]Tool{ - "always_tool": &testToolWithBase{}, - "ws_tool": &testContextTool{name: "ws_tool", req: RequireWorkspace}, - "visitor_denied": &testContextTool{name: "visitor_denied", req: DenyVisitor}, - "ws_no_visitor": &testContextTool{name: "ws_no_visitor", req: All(RequireWorkspace, DenyVisitor)}, - "plain_tool": &testPlainTool{name: "plain_tool"}, - } - - cases := []struct { - name string - tctx ToolContext - disabled map[string]bool - expect []string // expected tool names (sorted doesn't matter, just set membership) - }{ - { - name: "empty context — only always + plain", - tctx: ToolContext{}, - expect: []string{"always_tool", "visitor_denied", "plain_tool"}, - }, - { - name: "workspace context — ws tools available", - tctx: ToolContext{WorkspaceID: "ws-1"}, - expect: []string{"always_tool", "ws_tool", "visitor_denied", "ws_no_visitor", "plain_tool"}, - }, - { - name: "visitor with workspace — visitor_denied and ws_no_visitor excluded", - tctx: ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, - expect: []string{"always_tool", "ws_tool", "plain_tool"}, - }, - { - name: "disabled tool excluded", - tctx: ToolContext{WorkspaceID: "ws-1"}, - disabled: map[string]bool{"ws_tool": true}, - expect: []string{"always_tool", "visitor_denied", "ws_no_visitor", "plain_tool"}, - }, - { - name: "visitor no workspace — minimal set", - tctx: ToolContext{IsVisitor: true}, - expect: []string{"always_tool", "plain_tool"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - disabled := tc.disabled - if disabled == nil { - disabled = map[string]bool{} - } - defs := AvailableFor(tc.tctx, disabled) - - got := make(map[string]bool, len(defs)) - for _, d := range defs { - got[d.Name] = true - } - - // Check expected tools are present - for _, name := range tc.expect { - if !got[name] { - t.Errorf("expected tool %q not found in result", name) - } - } - // Check no unexpected tools - expect := make(map[string]bool, len(tc.expect)) - for _, name := range tc.expect { - expect[name] = true - } - for name := range got { - if !expect[name] { - t.Errorf("unexpected tool %q in result", name) - } - } - }) - } -} - -func TestAvailableForBackwardCompat(t *testing.T) { - // AvailableFor with empty ToolContext should behave like - // the old AllDefinitionsFiltered for plain tools - origRegistry := registry - defer func() { registry = origRegistry }() - - registry = map[string]Tool{ - "tool_a": &testPlainTool{name: "tool_a"}, - "tool_b": &testPlainTool{name: "tool_b"}, - "tool_c": &testPlainTool{name: "tool_c"}, - } - - // No disabled, empty context — all plain tools should appear - defs := AvailableFor(ToolContext{}, map[string]bool{}) - if len(defs) != 3 { - t.Errorf("Expected 3 tools, got %d", len(defs)) - } - - // Disable one - defs = AvailableFor(ToolContext{}, map[string]bool{"tool_b": true}) - if len(defs) != 2 { - t.Errorf("Expected 2 tools, got %d", len(defs)) - } - for _, d := range defs { - if d.Name == "tool_b" { - t.Error("tool_b should be filtered out") - } - } -} - -// ── ToolContext from ExecutionContext ──────── - -func TestToolContextFromExecContext(t *testing.T) { - // Verify the fields map correctly between the two structs. - // This is a compile-time/structural test — when ToolContext is - // built from ExecutionContext in the completion handler, these - // fields must align. - exec := ExecutionContext{ - UserID: "u-1", - ChannelID: "ch-1", - PersonaID: "p-1", - WorkspaceID: "ws-1", - WorkflowID: "wf-1", - TeamID: "t-1", - } - - // Simulate what the completion handler will do - tc := ToolContext{ - WorkspaceID: exec.WorkspaceID, - WorkflowID: exec.WorkflowID, - TeamID: exec.TeamID, - PersonaID: exec.PersonaID, - // ChannelType and IsVisitor come from channel record / gin context - } - - if tc.WorkspaceID != "ws-1" { - t.Errorf("WorkspaceID mismatch: %q", tc.WorkspaceID) - } - if tc.WorkflowID != "wf-1" { - t.Errorf("WorkflowID mismatch: %q", tc.WorkflowID) - } - if tc.TeamID != "t-1" { - t.Errorf("TeamID mismatch: %q", tc.TeamID) - } - if tc.PersonaID != "p-1" { - t.Errorf("PersonaID mismatch: %q", tc.PersonaID) - } -} diff --git a/server/tools/registry.go b/server/tools/registry.go deleted file mode 100644 index 12ba19c..0000000 --- a/server/tools/registry.go +++ /dev/null @@ -1,114 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" -) - -// ── Registry ──────────────────────────────── - -var registry = map[string]Tool{} - -// Register adds a tool to the global registry. Called at init time. -func Register(t Tool) { - def := t.Definition() - if _, exists := registry[def.Name]; exists { - panic("tools.Register: duplicate tool name: " + def.Name) - } - registry[def.Name] = t - log.Printf("🔧 Registered tool: %s", def.Name) -} - -// Get returns a tool by name, or nil if not found. -func Get(name string) Tool { - return registry[name] -} - -// AllDefinitions returns tool definitions for all registered tools. -// Used to populate the `tools` field in LLM requests. -func AllDefinitions() []ToolDef { - defs := make([]ToolDef, 0, len(registry)) - for _, t := range registry { - defs = append(defs, t.Definition()) - } - return defs -} - -// AvailableFor returns tool definitions available in the given context, -// excluding any names in the disabled set. Tools that implement -// ContextualTool have their Availability() predicate evaluated against -// tctx. Tools that only implement Tool (no predicate) are always included. -func AvailableFor(tctx ToolContext, disabled map[string]bool) []ToolDef { - defs := make([]ToolDef, 0, len(registry)) - for _, t := range registry { - def := t.Definition() - if disabled[def.Name] { - continue - } - // Check context-aware availability if the tool declares it - if ct, ok := t.(ContextualTool); ok { - if !ct.Availability()(tctx) { - continue - } - } - defs = append(defs, def) - } - return defs -} - -// ── Execution ─────────────────────────────── - -// ExecuteCall runs a single tool call and returns a ToolResult. -// Never returns an error — failures are encoded in the result content -// so the LLM can see what went wrong and adjust. -func ExecuteCall(ctx context.Context, execCtx ExecutionContext, call ToolCall) ToolResult { - tool := Get(call.Name) - if tool == nil { - return ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: jsonError(fmt.Sprintf("unknown tool: %s", call.Name)), - IsError: true, - } - } - - result, err := tool.Execute(ctx, execCtx, call.Arguments) - if err != nil { - log.Printf("Tool %s error: %v", call.Name, err) - return ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: jsonError(err.Error()), - IsError: true, - } - } - - return ToolResult{ - ToolCallID: call.ID, - Name: call.Name, - Content: result, - } -} - -// ExecuteAll runs multiple tool calls and returns all results. -func ExecuteAll(ctx context.Context, execCtx ExecutionContext, calls []ToolCall) []ToolResult { - results := make([]ToolResult, len(calls)) - for i, call := range calls { - results[i] = ExecuteCall(ctx, execCtx, call) - } - return results -} - -// HasTools returns true if any tools are registered. -func HasTools() bool { - return len(registry) > 0 -} - -// ── Helpers ───────────────────────────────── - -func jsonError(msg string) string { - b, _ := json.Marshal(map[string]string{"error": msg}) - return string(b) -} diff --git a/server/tools/search/duckduckgo.go b/server/tools/search/duckduckgo.go deleted file mode 100644 index e3efdee..0000000 --- a/server/tools/search/duckduckgo.go +++ /dev/null @@ -1,152 +0,0 @@ -package search - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "golang.org/x/net/html" -) - -func init() { - RegisterProvider(&DuckDuckGo{}) - // Default active provider — no API key needed - _ = SetActive("duckduckgo") -} - -// DuckDuckGo implements Provider using DDG's HTML lite endpoint. -// No API key required. Returns organic web results. -type DuckDuckGo struct{} - -func (d *DuckDuckGo) Name() string { return "duckduckgo" } - -func (d *DuckDuckGo) Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error) { - if maxResults <= 0 { - maxResults = 5 - } - if maxResults > 10 { - maxResults = 10 - } - - endpoint := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) - - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (Web Search Tool)") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("search request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("search returned HTTP %d", resp.StatusCode) - } - - // Limit body read to 512KB - body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - return parseDDGHTML(string(body), maxResults), nil -} - -// parseDDGHTML extracts search results from DuckDuckGo's HTML lite page. -// Results are in tags with snippets in . -func parseDDGHTML(htmlContent string, maxResults int) []SearchResult { - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - return nil - } - - var results []SearchResult - - // Walk the DOM looking for result blocks - var walk func(*html.Node) - walk = func(n *html.Node) { - if len(results) >= maxResults { - return - } - - if n.Type == html.ElementNode && n.Data == "a" { - class := getAttr(n, "class") - - if strings.Contains(class, "result__a") { - href := getAttr(n, "href") - title := textContent(n) - // DDG wraps URLs through a redirect; extract the actual URL - actualURL := extractDDGURL(href) - if actualURL != "" && title != "" { - results = append(results, SearchResult{ - Title: strings.TrimSpace(title), - URL: actualURL, - }) - } - } - - if strings.Contains(class, "result__snippet") { - snippet := strings.TrimSpace(textContent(n)) - // Attach snippet to the most recent result - if len(results) > 0 && results[len(results)-1].Snippet == "" { - results[len(results)-1].Snippet = snippet - } - } - } - - for c := n.FirstChild; c != nil; c = c.NextSibling { - walk(c) - } - } - walk(doc) - - return results -} - -// extractDDGURL extracts the actual URL from DDG's redirect wrapper. -// DDG links look like: //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=... -func extractDDGURL(href string) string { - if strings.Contains(href, "uddg=") { - u, err := url.Parse(href) - if err != nil { - return href - } - uddg := u.Query().Get("uddg") - if uddg != "" { - return uddg - } - } - // Direct URL - if strings.HasPrefix(href, "http") { - return href - } - return "" -} - -func getAttr(n *html.Node, key string) string { - for _, a := range n.Attr { - if a.Key == key { - return a.Val - } - } - return "" -} - -func textContent(n *html.Node) string { - if n.Type == html.TextNode { - return n.Data - } - var sb strings.Builder - for c := n.FirstChild; c != nil; c = c.NextSibling { - sb.WriteString(textContent(c)) - } - return sb.String() -} diff --git a/server/tools/search/provider.go b/server/tools/search/provider.go deleted file mode 100644 index 97e313f..0000000 --- a/server/tools/search/provider.go +++ /dev/null @@ -1,103 +0,0 @@ -package search - -import ( - "context" - "fmt" - "log" - "sync" -) - -// SearchResult represents a single web search result. -type SearchResult struct { - Title string `json:"title"` - URL string `json:"url"` - Snippet string `json:"snippet"` -} - -// Provider is the interface for web search backends. -type Provider interface { - // Search performs a web search and returns up to maxResults results. - Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error) - - // Name returns the provider identifier (e.g. "duckduckgo", "searxng"). - Name() string -} - -// ── Provider Registry ─────────────────────── - -var ( - mu sync.RWMutex - providers = map[string]Provider{} - active string -) - -// RegisterProvider adds a search provider to the registry. -func RegisterProvider(p Provider) { - mu.Lock() - defer mu.Unlock() - providers[p.Name()] = p - log.Printf("🔍 Registered search provider: %s", p.Name()) -} - -// SetActive sets the active search provider by name. -func SetActive(name string) error { - mu.Lock() - defer mu.Unlock() - if _, ok := providers[name]; !ok { - return fmt.Errorf("unknown search provider: %s", name) - } - active = name - log.Printf("🔍 Active search provider: %s", name) - return nil -} - -// Active returns the currently active provider, or nil if none configured. -func Active() Provider { - mu.RLock() - defer mu.RUnlock() - if active == "" { - return nil - } - return providers[active] -} - -// Available returns the names of all registered providers. -func Available() []string { - mu.RLock() - defer mu.RUnlock() - names := make([]string, 0, len(providers)) - for name := range providers { - names = append(names, name) - } - return names -} - -// Config holds the persisted search configuration. -type Config struct { - Provider string `json:"provider"` // "duckduckgo" or "searxng" - Endpoint string `json:"endpoint"` // SearXNG endpoint URL - APIKey string `json:"api_key"` // SearXNG API key (optional) - MaxResults int `json:"max_results"` // default results per search -} - -// DefaultConfig returns the default search config (DuckDuckGo, no key needed). -func DefaultConfig() Config { - return Config{Provider: "duckduckgo", MaxResults: 5} -} - -// ApplyConfig applies a Config to the active provider registry. -// Called on startup and when admin saves settings. -func ApplyConfig(cfg Config) error { - if cfg.Provider == "" { - cfg.Provider = "duckduckgo" - } - - // Configure SearXNG if selected - mu.RLock() - if sxng, ok := providers["searxng"].(*SearXNG); ok { - sxng.Configure(cfg.Endpoint, cfg.APIKey) - } - mu.RUnlock() - - return SetActive(cfg.Provider) -} diff --git a/server/tools/search/searxng.go b/server/tools/search/searxng.go deleted file mode 100644 index 06d4e69..0000000 --- a/server/tools/search/searxng.go +++ /dev/null @@ -1,116 +0,0 @@ -package search - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "time" -) - -// SearXNG implements Provider using a self-hosted SearXNG instance. -// Suitable for airgapped deployments. Requires a configured endpoint. -type SearXNG struct { - endpoint string // base URL, e.g. "https://search.internal.example.com" - apiKey string // optional API key (sent as X-API-Key header) -} - -func (s *SearXNG) Name() string { return "searxng" } - -// Configure sets the SearXNG endpoint and optional API key. -func (s *SearXNG) Configure(endpoint, apiKey string) { - s.endpoint = endpoint - s.apiKey = apiKey -} - -// Endpoint returns the configured endpoint (for admin display). -func (s *SearXNG) Endpoint() string { return s.endpoint } - -func (s *SearXNG) Search(ctx context.Context, query string, maxResults int) ([]SearchResult, error) { - if s.endpoint == "" { - return nil, fmt.Errorf("SearXNG endpoint not configured") - } - - if maxResults <= 0 { - maxResults = 5 - } - if maxResults > 10 { - maxResults = 10 - } - - // Build request URL: /search?q=...&format=json - u, err := url.Parse(s.endpoint) - if err != nil { - return nil, fmt.Errorf("invalid SearXNG endpoint: %w", err) - } - u.Path = "/search" - q := u.Query() - q.Set("q", query) - q.Set("format", "json") - u.RawQuery = q.Encode() - - req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) - if err != nil { - return nil, fmt.Errorf("build request: %w", err) - } - req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (Web Search Tool)") - if s.apiKey != "" { - req.Header.Set("X-API-Key", s.apiKey) - } - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("SearXNG request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("SearXNG returned HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) - if err != nil { - return nil, fmt.Errorf("read SearXNG response: %w", err) - } - - return parseSearXNGJSON(body, maxResults) -} - -// SearXNG JSON response format -type searxngResponse struct { - Results []struct { - Title string `json:"title"` - URL string `json:"url"` - Content string `json:"content"` // snippet - } `json:"results"` -} - -func parseSearXNGJSON(data []byte, maxResults int) ([]SearchResult, error) { - var resp searxngResponse - if err := json.Unmarshal(data, &resp); err != nil { - return nil, fmt.Errorf("parse SearXNG response: %w", err) - } - - results := make([]SearchResult, 0, maxResults) - for _, r := range resp.Results { - if len(results) >= maxResults { - break - } - if r.URL == "" { - continue - } - results = append(results, SearchResult{ - Title: r.Title, - URL: r.URL, - Snippet: r.Content, - }) - } - return results, nil -} - -func init() { - RegisterProvider(&SearXNG{}) -} diff --git a/server/tools/task_create.go b/server/tools/task_create.go deleted file mode 100644 index 971c2ca..0000000 --- a/server/tools/task_create.go +++ /dev/null @@ -1,194 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" - "time" - - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/taskutil" -) - -// ── Late Registration ──────────────────────── - -// RegisterTaskTools registers the task_create tool. -// Called from main.go after stores are initialized. -func RegisterTaskTools(stores store.Stores) { - if stores.Tasks == nil { - log.Println("⚠ task tools: TaskStore not available, skipping registration") - return - } - Register(&taskCreateTool{stores: stores}) - log.Println("✅ task tools registered (task_create)") -} - -// ═══════════════════════════════════════════ -// task_create -// ═══════════════════════════════════════════ -// -// AI-invocable tool that spawns sub-tasks. Only available inside -// workflow or team channels — prevents runaway task creation in -// personal chats. Tasks cannot create tasks (depth limit: service -// channels are blocked). - -type taskCreateTool struct { - BaseTool - stores store.Stores -} - -// Availability: workflow or team context only — not personal chats, -// not service channels (prevents recursive task spawning). -func (t *taskCreateTool) Availability() Require { - return func(tc ToolContext) bool { - // Block in service channels (task output) — prevents recursion - if tc.ChannelType == "service" { - return false - } - // Require workflow or team context - return tc.WorkflowID != "" || tc.TeamID != "" - } -} - -func (t *taskCreateTool) Definition() ToolDef { - return ToolDef{ - Name: "task_create", - DisplayName: "Create Task", - Category: "automation", - Description: "Create a new scheduled or one-shot task. Use this to spawn " + - "background work that runs independently — for example, a follow-up " + - "analysis, a periodic check, or a notification workflow. The task runs " + - "in its own service channel with its own budget.", - Parameters: JSONSchema(map[string]interface{}{ - "name": Prop("string", "Short descriptive name for the task"), - "prompt": Prop("string", "The user prompt the task will execute"), - "schedule": Prop("string", - "Cron expression (e.g. '0 8 * * *' for daily at 8am) or 'once' for immediate one-shot execution"), - "persona": Prop("string", - "Persona handle to use for execution (optional — uses default if omitted)"), - "model": Prop("string", - "Model ID to use (optional — uses persona or config default)"), - "max_tokens": map[string]interface{}{ - "type": "integer", - "description": "Maximum tokens for the task (optional — uses global default)", - }, - "system_prompt": Prop("string", - "Optional system prompt override for the task"), - }, []string{"name", "prompt", "schedule"}), - } -} - -func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Name string `json:"name"` - Prompt string `json:"prompt"` - Schedule string `json:"schedule"` - Persona string `json:"persona"` - Model string `json:"model"` - MaxTokens int `json:"max_tokens"` - SystemPrompt string `json:"system_prompt"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Name == "" || args.Prompt == "" || args.Schedule == "" { - return "", fmt.Errorf("name, prompt, and schedule are required") - } - - // F5 audit fix: reject webhook schedule — webhook-triggered tasks - // require API creation (trigger token generation, no cron). - if args.Schedule == "webhook" { - return "", fmt.Errorf("webhook-triggered tasks cannot be created via this tool — use the API directly") - } - - // Validate cron - if err := taskutil.ValidateCron(args.Schedule); err != nil { - return "", fmt.Errorf("invalid schedule: %w", err) - } - - // Depth guard: verify we're not inside a service channel (task output) - if execCtx.ChannelID != "" { - channelType, _, _ := t.stores.Channels.GetTypeAndTeamID(ctx, execCtx.ChannelID) - if channelType == "service" { - return "", fmt.Errorf("tasks cannot create tasks (depth limit)") - } - } - - // Resolve persona ID from handle - var personaID *string - if args.Persona != "" { - pID, err := t.stores.Personas.FindActiveByHandle(ctx, args.Persona) - if err == nil && pID != "" { - personaID = &pID - } - } - - // Inherit scope from channel context - scope := "personal" - var teamID *string - if execCtx.TeamID != "" { - scope = "team" - teamID = &execCtx.TeamID - } - - // Build task - task := &models.Task{ - OwnerID: execCtx.UserID, - TeamID: teamID, - Name: args.Name, - Scope: scope, - TaskType: "prompt", - PersonaID: personaID, - ModelID: args.Model, - SystemPrompt: args.SystemPrompt, - UserPrompt: args.Prompt, - Schedule: args.Schedule, - Timezone: "UTC", - IsActive: true, - OutputMode: "channel", - NotifyOnFailure: true, - } - - if args.MaxTokens > 0 { - task.MaxTokens = args.MaxTokens - } - - // Apply global defaults for zero-value budgets - cfg := taskutil.LoadTaskConfig(ctx, t.stores.GlobalConfig) - cfg.ApplyDefaults(task) - - // Compute next_run_at - if args.Schedule == "once" { - now := time.Now().UTC() - task.NextRunAt = &now - } else { - task.NextRunAt = taskutil.NextRunFromSchedule(args.Schedule, "UTC") - } - - if err := t.stores.Tasks.Create(ctx, task); err != nil { - return "", fmt.Errorf("failed to create task: %w", err) - } - - log.Printf("[task_create] AI spawned task %s (%s) schedule=%s scope=%s", - task.ID, task.Name, args.Schedule, scope) - - result, _ := json.Marshal(map[string]interface{}{ - "task_id": task.ID, - "name": task.Name, - "schedule": args.Schedule, - "scope": scope, - "next_run_at": task.NextRunAt, - "message": fmt.Sprintf("Task '%s' created successfully. It will run %s.", task.Name, scheduleDesc(args.Schedule)), - }) - return string(result), nil -} - -func scheduleDesc(schedule string) string { - if schedule == "once" { - return "immediately (one-shot)" - } - return "on schedule: " + schedule -} diff --git a/server/tools/tools_test.go b/server/tools/tools_test.go deleted file mode 100644 index 7e79f0d..0000000 --- a/server/tools/tools_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "os" - "testing" - - "switchboard-core/store" -) - -// TestMain registers late-registered tools (notes) with nil deps so -// that Definition()-level tests work. Execute() tests need real deps. -func TestMain(m *testing.M) { - RegisterNoteTools(store.Stores{}, nil) - os.Exit(m.Run()) -} - -// ── Registry Tests ────────────────────────── - -func TestRegistryHasTools(t *testing.T) { - // TestMain registers note tools via RegisterNoteTools - if !HasTools() { - t.Fatal("Expected HasTools() == true after init registration") - } -} - -func TestRegistryAllDefinitions(t *testing.T) { - defs := AllDefinitions() - if len(defs) < 4 { - t.Errorf("Expected at least 4 tool definitions, got %d", len(defs)) - } - - expected := map[string]bool{ - "note_create": false, - "note_search": false, - "note_update": false, - "note_list": false, - } - for _, d := range defs { - if _, ok := expected[d.Name]; ok { - expected[d.Name] = true - } - // Every tool must have a description and parameters - if d.Description == "" { - t.Errorf("Tool %q has empty description", d.Name) - } - if len(d.Parameters) == 0 { - t.Errorf("Tool %q has empty parameters", d.Name) - } - } - for name, found := range expected { - if !found { - t.Errorf("Expected tool %q not found in registry", name) - } - } -} - -func TestRegistryGetKnown(t *testing.T) { - tool := Get("note_create") - if tool == nil { - t.Fatal("Get(note_create) returned nil") - } - def := tool.Definition() - if def.Name != "note_create" { - t.Errorf("Expected name 'note_create', got %q", def.Name) - } -} - -func TestRegistryGetUnknown(t *testing.T) { - tool := Get("nonexistent_tool") - if tool != nil { - t.Error("Get(nonexistent_tool) should return nil") - } -} - -func TestExecuteCallUnknownTool(t *testing.T) { - ctx := context.Background() - execCtx := ExecutionContext{UserID: "test", ChannelID: "test"} - call := ToolCall{ID: "call_1", Name: "nonexistent", Arguments: "{}"} - - result := ExecuteCall(ctx, execCtx, call) - - if !result.IsError { - t.Error("Expected is_error=true for unknown tool") - } - if result.ToolCallID != "call_1" { - t.Errorf("Expected tool_call_id='call_1', got %q", result.ToolCallID) - } -} - -func TestExecuteAll(t *testing.T) { - ctx := context.Background() - execCtx := ExecutionContext{UserID: "test", ChannelID: "test"} - calls := []ToolCall{ - {ID: "call_1", Name: "nonexistent_a", Arguments: "{}"}, - {ID: "call_2", Name: "nonexistent_b", Arguments: "{}"}, - } - - results := ExecuteAll(ctx, execCtx, calls) - - if len(results) != 2 { - t.Fatalf("Expected 2 results, got %d", len(results)) - } - for i, r := range results { - if !r.IsError { - t.Errorf("Result %d: expected is_error=true", i) - } - } -} - -// ── Tool Definition Schema Tests ──────────── - -func TestToolDefinitionsHaveValidJSON(t *testing.T) { - for _, def := range AllDefinitions() { - var schema map[string]interface{} - if err := json.Unmarshal(def.Parameters, &schema); err != nil { - t.Errorf("Tool %q has invalid JSON schema: %v", def.Name, err) - } - // Should be a JSON Schema object with "type" and "properties" - if schema["type"] != "object" { - t.Errorf("Tool %q schema type should be 'object', got %v", def.Name, schema["type"]) - } - if _, ok := schema["properties"]; !ok { - t.Errorf("Tool %q schema missing 'properties'", def.Name) - } - } -} - -// ── JSON Schema Helper Tests ──────────────── - -func TestPropString(t *testing.T) { - p := Prop("string", "a description") - - if p["type"] != "string" { - t.Errorf("Expected type=string, got %v", p["type"]) - } - if p["description"] != "a description" { - t.Errorf("Expected description, got %v", p["description"]) - } -} - -func TestPropEnum(t *testing.T) { - p := PropEnum("content mode", "replace", "append", "prepend") - - if p["type"] != "string" { - t.Error("Expected type=string") - } - enums := p["enum"].([]string) - if len(enums) != 3 { - t.Errorf("Expected 3 enum values, got %d", len(enums)) - } -} - -func TestPropArray(t *testing.T) { - p := PropArray("list of tags") - - if p["type"] != "array" { - t.Error("Expected type=array") - } - items := p["items"].(map[string]string) - if items["type"] != "string" { - t.Error("Expected items.type=string") - } -} - -func TestJSONSchema(t *testing.T) { - props := map[string]interface{}{ - "title": Prop("string", "note title"), - "content": Prop("string", "note body"), - } - schema := JSONSchema(props, []string{"title"}) - - var m map[string]interface{} - if err := json.Unmarshal(schema, &m); err != nil { - t.Fatalf("Invalid JSON: %v", err) - } - - if m["type"] != "object" { - t.Error("Expected type=object") - } - required := m["required"].([]interface{}) - if len(required) != 1 || required[0] != "title" { - t.Errorf("Expected required=[title], got %v", required) - } - mProps := m["properties"].(map[string]interface{}) - if _, ok := mProps["title"]; !ok { - t.Error("Missing property 'title'") - } - if _, ok := mProps["content"]; !ok { - t.Error("Missing property 'content'") - } -} - -func TestMustJSON(t *testing.T) { - result := MustJSON(map[string]string{"key": "value"}) - var m map[string]string - if err := json.Unmarshal(result, &m); err != nil { - t.Fatalf("MustJSON produced invalid JSON: %v", err) - } - if m["key"] != "value" { - t.Errorf("Expected value, got %q", m["key"]) - } -} diff --git a/server/tools/types.go b/server/tools/types.go deleted file mode 100644 index 199eede..0000000 --- a/server/tools/types.go +++ /dev/null @@ -1,153 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" -) - -// ── Tool Definition (sent to LLM) ────────── - -// ToolDef describes a tool the LLM can call. Serialized to OpenAI-compatible -// format by providers. The Parameters field uses JSON Schema. -type ToolDef struct { - Name string `json:"name"` - DisplayName string `json:"display_name,omitempty"` // human-readable label for toggle UI - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` // JSON Schema object - Category string `json:"category,omitempty"` // UI grouping: search, notes, utilities, browser -} - -// ── Tool Call (from LLM response) ─────────── - -// ToolCall represents a single tool invocation requested by the LLM. -type ToolCall struct { - ID string `json:"id"` // provider-assigned call ID - Name string `json:"name"` // tool function name - Arguments string `json:"arguments"` // JSON string of arguments -} - -// ── Tool Result (fed back to LLM) ─────────── - -// ToolResult is the output of executing a tool, sent back to the LLM -// as a message with role "tool". -type ToolResult struct { - ToolCallID string `json:"tool_call_id"` - Name string `json:"name"` - Content string `json:"content"` // JSON-encoded result - IsError bool `json:"is_error,omitempty"` // true if execution failed -} - -// ── Tool Interface ────────────────────────── - -// ExecutionContext provides tool implementations with the info they need -// at execution time. Populated from channel/request state in the -// completion handler. -type ExecutionContext struct { - UserID string - ChannelID string - PersonaID string // set when a persona is active — tools use for scoped KB access - WorkspaceID string // set when channel/project has a workspace bound - WorkflowID string // v0.25.0: set when channel is a workflow instance - TeamID string // v0.25.0: set when channel belongs to a team -} - -// Tool is the interface every built-in tool implements. -type Tool interface { - // Definition returns the tool's schema for the LLM. - Definition() ToolDef - - // Execute runs the tool with the given JSON arguments. - // Returns a JSON string result or an error. - Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) -} - -// ── Context-Aware Tool System (v0.25.0) ──── - -// ToolContext describes the runtime environment for tool availability -// checks. Built from channel metadata in the completion handler — -// separate from ExecutionContext because availability is checked before -// execution. -type ToolContext struct { - ChannelType string // "direct", "dm", "group", "channel", "workflow" - WorkspaceID string // non-empty when channel/project has a workspace - WorkflowID string // non-empty when channel is a workflow instance - TeamID string // non-empty when channel belongs to a team - PersonaID string // non-empty when a persona is active - IsVisitor bool // true for session_participants (v0.24.3) -} - -// Require is a predicate that determines if a tool is available in a -// given context. Return true = tool is available, false = suppressed. -type Require func(ToolContext) bool - -// ContextualTool extends Tool with an availability predicate. Tools -// that embed BaseTool get AlwaysAvailable by default. Tools that need -// context-dependent visibility override Availability(). -type ContextualTool interface { - Tool - Availability() Require -} - -// BaseTool provides default availability (always available). Embed in -// any tool struct for backward compat — no behavioral change. -type BaseTool struct{} - -// Availability returns AlwaysAvailable. Override in tool structs that -// need context-dependent visibility. -func (BaseTool) Availability() Require { return AlwaysAvailable } - -// AlwaysAvailable is the default predicate — tool is available in -// every context. Defined here (not in predicates.go) because BaseTool -// references it. -func AlwaysAvailable(_ ToolContext) bool { return true } - -// ── Helpers ───────────────────────────────── - -// MustJSON marshals v to a json.RawMessage, panicking on error. -// Used for static parameter schemas at init time. -func MustJSON(v interface{}) json.RawMessage { - b, err := json.Marshal(v) - if err != nil { - panic("tools.MustJSON: " + err.Error()) - } - return json.RawMessage(b) -} - -// JSONSchema builds a JSON Schema object type with the given properties. -// Convenience for defining tool parameters. -func JSONSchema(properties map[string]interface{}, required []string) json.RawMessage { - schema := map[string]interface{}{ - "type": "object", - "properties": properties, - } - if len(required) > 0 { - schema["required"] = required - } - return MustJSON(schema) -} - -// Prop builds a JSON Schema property definition. -func Prop(typ, description string) map[string]interface{} { - return map[string]interface{}{ - "type": typ, - "description": description, - } -} - -// PropEnum builds a JSON Schema string property with enum values. -func PropEnum(description string, values ...string) map[string]interface{} { - return map[string]interface{}{ - "type": "string", - "description": description, - "enum": values, - } -} - -// PropArray builds a JSON Schema array property with string items. -func PropArray(description string) map[string]interface{} { - return map[string]interface{}{ - "type": "array", - "description": description, - "items": map[string]string{"type": "string"}, - } -} diff --git a/server/tools/urlfetch.go b/server/tools/urlfetch.go deleted file mode 100644 index 3ab1604..0000000 --- a/server/tools/urlfetch.go +++ /dev/null @@ -1,308 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "strings" - "time" - "unicode/utf8" - - "golang.org/x/net/html" -) - -func init() { - Register(&URLFetchTool{}) -} - -// ═══════════════════════════════════════════ -// url_fetch -// ═══════════════════════════════════════════ - -type URLFetchTool struct{ BaseTool } - -func (t *URLFetchTool) Definition() ToolDef { - return ToolDef{ - Name: "url_fetch", - DisplayName: "Fetch URL", - Description: "Fetch and extract readable text content from a web page URL. Use this to read articles, documentation, or any web page the user shares or that appeared in search results. Returns the page title and extracted text.", - Category: "search", - Parameters: JSONSchema(map[string]interface{}{ - "url": Prop("string", "Full URL to fetch (must start with http:// or https://)"), - "max_length": Prop("integer", "Maximum content length in characters (default 8000, max 16000)"), - }, []string{"url"}), - } -} - -func (t *URLFetchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - URL string `json:"url"` - MaxLength int `json:"max_length"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.URL == "" { - return "", fmt.Errorf("url is required") - } - if !strings.HasPrefix(args.URL, "http://") && !strings.HasPrefix(args.URL, "https://") { - return "", fmt.Errorf("url must start with http:// or https://") - } - - if args.MaxLength <= 0 { - args.MaxLength = 8000 - } - if args.MaxLength > 16000 { - args.MaxLength = 16000 - } - - // SSRF protection: resolve hostname and block private IPs - if err := checkSSRF(args.URL); err != nil { - return "", err - } - - // Fetch with timeout - client := &http.Client{ - Timeout: 15 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 3 { - return fmt.Errorf("too many redirects") - } - return nil - }, - } - - req, err := http.NewRequestWithContext(ctx, "GET", args.URL, nil) - if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) - } - req.Header.Set("User-Agent", "ChatSwitchboard/1.0 (URL Fetch Tool)") - req.Header.Set("Accept", "text/html,application/xhtml+xml,text/plain") - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("fetch failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) - } - - ct := resp.Header.Get("Content-Type") - if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "text/plain") && - !strings.Contains(ct, "application/xhtml") && !strings.Contains(ct, "application/json") { - return "", fmt.Errorf("unsupported content type: %s (expected HTML or text)", ct) - } - - // Read body with limit (1MB raw) - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) - if err != nil { - return "", fmt.Errorf("read body: %w", err) - } - - // Plain text or JSON → return as-is (truncated) - if strings.Contains(ct, "text/plain") || strings.Contains(ct, "application/json") { - content := string(body) - content = truncateUTF8(content, args.MaxLength) - result := map[string]interface{}{ - "url": args.URL, - "content_type": ct, - "content": content, - "truncated": len(body) > args.MaxLength, - } - b, _ := json.Marshal(result) - return string(b), nil - } - - // HTML → extract readable text - title, text := extractReadableHTML(string(body)) - text = truncateUTF8(text, args.MaxLength) - - result := map[string]interface{}{ - "url": args.URL, - "title": title, - "content": text, - "truncated": utf8.RuneCountInString(string(body)) > args.MaxLength, - } - - b, _ := json.Marshal(result) - return string(b), nil -} - -// ── SSRF Protection ───────────────────────── - -func checkSSRF(rawURL string) error { - // Parse hostname - host := rawURL - if idx := strings.Index(rawURL, "://"); idx >= 0 { - host = rawURL[idx+3:] - } - if idx := strings.IndexAny(host, ":/"); idx >= 0 { - host = host[:idx] - } - - // Resolve DNS - ips, err := net.LookupIP(host) - if err != nil { - return fmt.Errorf("cannot resolve host: %s", host) - } - - for _, ip := range ips { - if isPrivateIP(ip) { - return fmt.Errorf("blocked: %s resolves to private IP %s", host, ip.String()) - } - } - return nil -} - -func isPrivateIP(ip net.IP) bool { - privateRanges := []struct { - network *net.IPNet - }{ - {mustParseCIDR("10.0.0.0/8")}, - {mustParseCIDR("172.16.0.0/12")}, - {mustParseCIDR("192.168.0.0/16")}, - {mustParseCIDR("127.0.0.0/8")}, - {mustParseCIDR("169.254.0.0/16")}, - {mustParseCIDR("::1/128")}, - {mustParseCIDR("fc00::/7")}, - {mustParseCIDR("fe80::/10")}, - } - for _, r := range privateRanges { - if r.network.Contains(ip) { - return true - } - } - return false -} - -func mustParseCIDR(s string) *net.IPNet { - _, net, err := net.ParseCIDR(s) - if err != nil { - panic("invalid CIDR: " + s) - } - return net -} - -// ── HTML → Text Extraction ────────────────── - -// extractReadableHTML extracts the page title and readable text from HTML. -// Strips scripts, styles, nav, header, footer elements. -func extractReadableHTML(htmlContent string) (title, text string) { - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - return "", htmlContent - } - - // Extract - title = findTitle(doc) - - // Extract text from body, skipping non-content elements - var sb strings.Builder - extractText(doc, &sb, false) - text = cleanText(sb.String()) - - return title, text -} - -func findTitle(n *html.Node) string { - if n.Type == html.ElementNode && n.Data == "title" { - return strings.TrimSpace(textContentNode(n)) - } - for c := n.FirstChild; c != nil; c = c.NextSibling { - if t := findTitle(c); t != "" { - return t - } - } - return "" -} - -// skipTags are elements whose content is not readable text. -var skipTags = map[string]bool{ - "script": true, "style": true, "noscript": true, - "nav": true, "header": true, "footer": true, - "aside": true, "svg": true, "form": true, - "iframe": true, "button": true, "select": true, -} - -func extractText(n *html.Node, sb *strings.Builder, inBody bool) { - if n.Type == html.ElementNode { - if skipTags[n.Data] { - return - } - if n.Data == "body" { - inBody = true - } - } - - if inBody && n.Type == html.TextNode { - text := strings.TrimSpace(n.Data) - if text != "" { - sb.WriteString(text) - sb.WriteString(" ") - } - } - - // Block elements get a newline - if n.Type == html.ElementNode { - switch n.Data { - case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6", - "li", "tr", "blockquote", "pre", "article", "section": - sb.WriteString("\n") - } - } - - for c := n.FirstChild; c != nil; c = c.NextSibling { - extractText(c, sb, inBody) - } -} - -func textContentNode(n *html.Node) string { - if n.Type == html.TextNode { - return n.Data - } - var sb strings.Builder - for c := n.FirstChild; c != nil; c = c.NextSibling { - sb.WriteString(textContentNode(c)) - } - return sb.String() -} - -// cleanText collapses multiple spaces/newlines into single separators. -func cleanText(s string) string { - var sb strings.Builder - prevNewline := false - prevSpace := false - for _, r := range s { - if r == '\n' { - if !prevNewline { - sb.WriteRune('\n') - } - prevNewline = true - prevSpace = false - } else if r == ' ' || r == '\t' { - if !prevSpace && !prevNewline { - sb.WriteRune(' ') - } - prevSpace = true - } else { - sb.WriteRune(r) - prevNewline = false - prevSpace = false - } - } - return strings.TrimSpace(sb.String()) -} - -func truncateUTF8(s string, maxChars int) string { - runes := []rune(s) - if len(runes) <= maxChars { - return s - } - return string(runes[:maxChars]) + "\n[content truncated]" -} diff --git a/server/tools/websearch.go b/server/tools/websearch.go deleted file mode 100644 index 7a324cd..0000000 --- a/server/tools/websearch.go +++ /dev/null @@ -1,73 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - - "switchboard-core/tools/search" -) - -func init() { - Register(&WebSearchTool{}) -} - -// ═══════════════════════════════════════════ -// web_search -// ═══════════════════════════════════════════ - -type WebSearchTool struct{ BaseTool } - -func (t *WebSearchTool) Definition() ToolDef { - return ToolDef{ - Name: "web_search", - DisplayName: "Search", - Description: "Search the web for current information. Use this when you need up-to-date facts, news, or information you're not confident about. Returns titles, URLs, and text snippets from web pages.", - Category: "search", - Parameters: JSONSchema(map[string]interface{}{ - "query": Prop("string", "Search query — be specific and concise"), - "max_results": Prop("integer", "Maximum number of results to return (1-10, default 5)"), - }, []string{"query"}), - } -} - -func (t *WebSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Query string `json:"query"` - MaxResults int `json:"max_results"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Query == "" { - return "", fmt.Errorf("query is required") - } - - if args.MaxResults <= 0 { - args.MaxResults = 5 - } - if args.MaxResults > 10 { - args.MaxResults = 10 - } - - provider := search.Active() - if provider == nil { - return "", fmt.Errorf("no search provider configured") - } - - results, err := provider.Search(ctx, args.Query, args.MaxResults) - if err != nil { - return "", fmt.Errorf("search failed: %w", err) - } - - response := map[string]interface{}{ - "provider": provider.Name(), - "query": args.Query, - "result_count": len(results), - "results": results, - } - - b, _ := json.Marshal(response) - return string(b), nil -} diff --git a/server/tools/workflow.go b/server/tools/workflow.go deleted file mode 100644 index 5292a9a..0000000 --- a/server/tools/workflow.go +++ /dev/null @@ -1,469 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "log" - "time" - - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/webhook" - "switchboard-core/workflow" -) - -// ── Late Registration ──────────────────────── - -// RegisterWorkflowTools registers the workflow_advance tool. -// Called from main.go after stores are initialized. -func RegisterWorkflowTools(stores store.Stores) { - if stores.Workflows == nil { - log.Println("⚠ workflow tools: WorkflowStore not available, skipping registration") - return - } - Register(&workflowAdvanceTool{stores: stores}) - Register(&workflowRouteTool{stores: stores}) - log.Println("✅ workflow tools registered (workflow_advance, workflow_route)") -} - -// ═══════════════════════════════════════════ -// workflow_advance -// ═══════════════════════════════════════════ -// -// Called by the stage persona when it has collected all required form -// data from the visitor. Triggers a stage transition — same effect as -// the human-triggered POST /channels/:id/workflow/advance endpoint. - -type workflowAdvanceTool struct { - BaseTool - stores store.Stores -} - -// Override availability: only available inside workflow channels. -func (t *workflowAdvanceTool) Availability() Require { return RequireWorkflow } - -func (t *workflowAdvanceTool) Definition() ToolDef { - return ToolDef{ - Name: "workflow_advance", - DisplayName: "Advance Workflow", - Category: "workflow", - Description: "Advance the workflow to the next stage. Call this when you have " + - "collected all required information from the visitor. Pass the collected " + - "data as a JSON object — it will be saved and made available to the next stage.", - Parameters: JSONSchema(map[string]interface{}{ - "data": map[string]interface{}{ - "type": "object", - "description": "Collected form data as key-value pairs. Must include all fields defined in the stage's form template.", - }, - "summary": Prop("string", "Brief summary of what was collected (1-2 sentences, shown in stage notes)"), - }, []string{"data"}), - } -} - -func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Data json.RawMessage `json:"data"` - Summary string `json:"summary"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if len(args.Data) == 0 || string(args.Data) == "null" { - return "", fmt.Errorf("data is required") - } - - channelID := execCtx.ChannelID - if channelID == "" { - return "", fmt.Errorf("no channel context") - } - - // Read current workflow state - ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil || ws.WorkflowID == nil { - return "", fmt.Errorf("not a workflow channel") - } - workflowID := *ws.WorkflowID - currentStage := ws.CurrentStage - status := ws.Status - - if status != "active" { - return "", fmt.Errorf("workflow is %s, cannot advance", status) - } - - // Load stages - stages, err := t.stores.Workflows.ListStages(ctx, workflowID) - if err != nil { - return "", fmt.Errorf("failed to load stages: %w", err) - } - - // Merge collected data into stage_data - mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data) - nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData)) - if err != nil { - return "", fmt.Errorf("routing error: %w", err) - } - - if nextStage >= len(stages) { - // Workflow complete - err = t.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) - if err != nil { - return "", fmt.Errorf("failed to complete workflow: %w", err) - } - - // Create note with collected data - CreateWorkflowStageNote(ctx, t.stores, channelID, currentStage, args.Data, args.Summary) - - // v0.27.3: Fire on_complete chaining + webhook (was missing from tool path) - go TriggerWorkflowOnComplete(ctx, t.stores, workflowID, channelID, mergedData) - - result, _ := json.Marshal(map[string]interface{}{ - "status": "completed", - "current_stage": nextStage, - "message": "Workflow completed successfully.", - }) - return string(result), nil - } - - // Advance to next stage - err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) - if err != nil { - return "", fmt.Errorf("failed to advance: %w", err) - } - - // Create note with collected data - CreateWorkflowStageNote(ctx, t.stores, channelID, currentStage, args.Data, args.Summary) - - // Create assignment if next stage has an assignment team - nextStageDef := stages[nextStage] - if nextStageDef.AssignmentTeamID != nil { - CreateWorkflowAssignment(ctx, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID) - } - - msg := fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name) - if nextStageDef.StageMode == models.StageModeFormOnly { - msg += " (form-only stage — visitor will fill out a form, no chat needed)" - } - - result, _ := json.Marshal(map[string]interface{}{ - "status": "active", - "current_stage": nextStage, - "stage_name": nextStageDef.Name, - "stage_mode": nextStageDef.StageMode, - "message": msg, - }) - return string(result), nil -} - -// ═══════════════════════════════════════════ -// workflow_route (v0.35.0) -// ═══════════════════════════════════════════ -// -// Called by a stage persona to route the workflow to a specific named stage. -// Enables AI-triggered routing (escalation, correction loops) and -// conditional branching driven by persona judgment. - -type workflowRouteTool struct { - BaseTool - stores store.Stores -} - -func (t *workflowRouteTool) Availability() Require { return RequireWorkflow } - -func (t *workflowRouteTool) Definition() ToolDef { - return ToolDef{ - Name: "workflow_route", - DisplayName: "Route Workflow", - Category: "workflow", - Description: "Route the workflow to a specific stage by name. Use this when the " + - "conversation should jump to a particular stage instead of advancing sequentially. " + - "Common patterns: escalation to human review, loop back for correction, " + - "skip stages based on collected information.", - Parameters: JSONSchema(map[string]interface{}{ - "target_stage": Prop("string", "Name of the stage to route to (case-insensitive)."), - "reason": Prop("string", "Brief explanation of why this routing decision was made."), - }, []string{"target_stage", "reason"}), - } -} - -func (t *workflowRouteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - TargetStage string `json:"target_stage"` - Reason string `json:"reason"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.TargetStage == "" { - return "", fmt.Errorf("target_stage is required") - } - - channelID := execCtx.ChannelID - if channelID == "" { - return "", fmt.Errorf("no channel context") - } - - ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil || ws.WorkflowID == nil { - return "", fmt.Errorf("not a workflow channel") - } - if ws.Status != "active" { - return "", fmt.Errorf("workflow is %s, cannot route", ws.Status) - } - - stages, err := t.stores.Workflows.ListStages(ctx, *ws.WorkflowID) - if err != nil { - return "", fmt.Errorf("failed to load stages: %w", err) - } - - targetOrdinal, err := workflow.ResolveStageByName(stages, args.TargetStage) - if err != nil { - return "", fmt.Errorf("cannot resolve target: %w", err) - } - - // Record routing decision in stage_data - routeEntry, _ := json.Marshal(map[string]any{ - "from": ws.CurrentStage, "to": targetOrdinal, - "reason": args.Reason, "ts": time.Now().UTC().Format(time.RFC3339), - }) - historyPatch, _ := json.Marshal(map[string]any{ - "_route_history_latest": json.RawMessage(routeEntry), - }) - mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, json.RawMessage(historyPatch)) - - if targetOrdinal >= len(stages) { - // Route beyond last stage = complete - err = t.stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, json.RawMessage(mergedData)) - if err != nil { - return "", fmt.Errorf("failed to complete workflow: %w", err) - } - go TriggerWorkflowOnComplete(ctx, t.stores, *ws.WorkflowID, channelID, mergedData) - result, _ := json.Marshal(map[string]any{ - "status": "completed", "message": "Workflow completed via routing.", - }) - return string(result), nil - } - - err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, json.RawMessage(mergedData)) - if err != nil { - return "", fmt.Errorf("failed to route: %w", err) - } - - targetDef := stages[targetOrdinal] - - // Create assignment if target stage has an assignment team - if targetDef.AssignmentTeamID != nil { - CreateWorkflowAssignment(ctx, t.stores, channelID, targetOrdinal, *targetDef.AssignmentTeamID) - } - - result, _ := json.Marshal(map[string]any{ - "status": "active", - "current_stage": targetOrdinal, - "stage_name": targetDef.Name, - "stage_mode": targetDef.StageMode, - "message": fmt.Sprintf("Routed to stage %d: %s (reason: %s)", targetOrdinal, targetDef.Name, args.Reason), - }) - return string(result), nil -} - -// ── Shared helpers (used by both tool and handler) ─ - -// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string. -// v0.29.0: channelStore parameter replaces raw database.DB access. -func MergeWorkflowStageData(ctx context.Context, channelStore store.ChannelStore, channelID string, newData json.RawMessage) string { - existing, _ := channelStore.GetStageData(ctx, channelID) - - if len(newData) == 0 || string(newData) == "null" { - if len(existing) == 0 { - return "{}" - } - return string(existing) - } - - var base map[string]interface{} - if err := json.Unmarshal(existing, &base); err != nil { - base = map[string]interface{}{} - } - var incoming map[string]interface{} - if err := json.Unmarshal(newData, &incoming); err == nil { - for k, v := range incoming { - base[k] = v - } - } - merged, _ := json.Marshal(base) - return string(merged) -} - -// CreateWorkflowStageNote persists collected form data as a channel-scoped note. -func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID string, stageOrdinal int, data json.RawMessage, summary string) { - if stores.Notes == nil || len(data) == 0 || string(data) == "null" { - return - } - - title := fmt.Sprintf("Stage %d collected data", stageOrdinal) - if summary != "" { - title = fmt.Sprintf("Stage %d: %s", stageOrdinal, summary) - } - - content := string(data) - - // Find workflow channel owner for the note's user_id - ch, err := stores.Channels.GetByID(ctx, channelID) - if err != nil || ch == nil { - return - } - userID := ch.UserID - if userID == "" { - return - } - - note := &models.Note{ - Title: title, - Content: content, - SourceChannelID: &channelID, - } - note.UserID = userID - - if err := stores.Notes.Create(ctx, note); err != nil { - log.Printf("⚠️ Failed to create stage note: %v", err) - } -} - -// CreateWorkflowAssignment creates an unassigned workflow assignment entry. -// Returns the assignment ID (for round-robin auto-assignment). -// v0.29.0: accepts stores parameter, delegates to WorkflowStore. -func CreateWorkflowAssignment(ctx context.Context, stores store.Stores, channelID string, stage int, teamID string) string { - a := &store.WorkflowAssignment{ - ChannelID: channelID, - Stage: stage, - TeamID: teamID, - } - if err := stores.Workflows.CreateAssignment(ctx, a); err != nil { - log.Printf("⚠️ Failed to create workflow assignment: %v", err) - return "" - } - return a.ID -} - -// ── Workflow Completion Triggers (v0.27.3) ── - -// TriggerWorkflowOnComplete checks if the workflow has an on_complete chain -// config and fires the target workflow. Also delivers webhook if configured. -// Shared by both the HTTP handler (Advance endpoint) and the workflow_advance tool. -func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflowID, channelID, mergedData string) { - if stores.Workflows == nil { - return - } - wf, err := stores.Workflows.GetByID(ctx, workflowID) - if err != nil || wf == nil { - return - } - - // v0.27.3: Webhook delivery on workflow completion - if wf.WebhookURL != "" { - var stageData any - _ = json.Unmarshal([]byte(mergedData), &stageData) - - go deliverWorkflowWebhook(wf.WebhookURL, wf.WebhookSecret, workflowID, channelID, stageData) - } - - // on_complete chaining - if len(wf.OnComplete) == 0 || string(wf.OnComplete) == "null" { - return - } - - var chain struct { - Action string `json:"action"` - TargetSlug string `json:"target_slug"` - DataMap map[string]string `json:"data_map"` - } - if err := json.Unmarshal(wf.OnComplete, &chain); err != nil || chain.Action != "start_workflow" || chain.TargetSlug == "" { - return - } - - target, err := stores.Workflows.GetBySlug(ctx, wf.TeamID, chain.TargetSlug) - if err != nil || target == nil || !target.IsActive { - log.Printf("[workflow] on_complete: target workflow '%s' not found or inactive", chain.TargetSlug) - return - } - - var sourceData map[string]any - if err := json.Unmarshal([]byte(mergedData), &sourceData); err != nil { - sourceData = map[string]any{} - } - targetData := map[string]any{} - if len(chain.DataMap) > 0 { - for srcKey, tgtKey := range chain.DataMap { - if v, ok := sourceData[srcKey]; ok { - targetData[tgtKey] = v - } - } - } else { - targetData = sourceData - } - - ch2, err2 := stores.Channels.GetByID(ctx, channelID) - if err2 != nil || ch2 == nil { - return - } - ownerID := ch2.UserID - if ownerID == "" { - return - } - - ver, err := stores.Workflows.GetLatestVersion(ctx, target.ID) - if err != nil { - log.Printf("[workflow] on_complete: target '%s' has no published version", chain.TargetSlug) - return - } - stages, err := stores.Workflows.ListStages(ctx, target.ID) - if err != nil || len(stages) == 0 { - return - } - - ch := &models.Channel{ - UserID: ownerID, - Title: target.Name + " (chained)", - Description: target.Description, - Type: "workflow", - TeamID: target.TeamID, - } - if err := stores.Channels.Create(ctx, ch); err != nil { - log.Printf("[workflow] on_complete: failed to create chained channel: %v", err) - return - } - - initialData, _ := json.Marshal(targetData) - err = stores.Channels.SetWorkflowInstance(ctx, ch.ID, target.ID, ver.VersionNumber, initialData, "active") - if err != nil { - log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err) - return - } - _ = stores.Channels.Update(ctx, ch.ID, map[string]interface{}{"ai_mode": "auto"}) - - // Bind first stage persona - if len(stages) > 0 && stages[0].PersonaID != nil { - _ = stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ - ChannelID: ch.ID, - ParticipantType: "persona", - ParticipantID: *stages[0].PersonaID, - Role: "member", - }) - } - - log.Printf("[workflow] on_complete: started chained workflow %s → %s (channel %s)", - workflowID, target.ID, ch.ID) -} - -// deliverWorkflowWebhook fires a webhook for workflow completion. -func deliverWorkflowWebhook(url, secret, workflowID, channelID string, stageData any) { - webhook.Deliver(url, secret, webhook.Payload{ - WorkflowID: workflowID, - ChannelID: channelID, - Status: "completed", - CompletedAt: time.Now().UTC(), - StageData: stageData, - }) -} - diff --git a/server/tools/workspace.go b/server/tools/workspace.go deleted file mode 100644 index 73edc6f..0000000 --- a/server/tools/workspace.go +++ /dev/null @@ -1,501 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "strings" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/workspace" -) - -// ── Late Registration ──────────────────────── -// Workspace tools need the workspace.FS and store for execution. -// Registered from main.go after workspace FS init. - -// RegisterWorkspaceTools registers all workspace tools with captured dependencies. -func RegisterWorkspaceTools(stores store.Stores, wfs *workspace.FS) { - Register(&workspaceLsTool{stores: stores, wfs: wfs}) - Register(&workspaceReadTool{stores: stores, wfs: wfs}) - Register(&workspaceWriteTool{stores: stores, wfs: wfs}) - Register(&workspaceRmTool{stores: stores, wfs: wfs}) - Register(&workspaceMvTool{stores: stores, wfs: wfs}) - Register(&workspacePatchTool{stores: stores, wfs: wfs}) -} - -// RegisterWorkspaceSearchTool registers the semantic search tool separately -// because it requires the embedder (which may not be configured). -func RegisterWorkspaceSearchTool(stores store.Stores, embedder *knowledge.Embedder) { - Register(&workspaceSearchTool{stores: stores, embedder: embedder}) -} - -// ── Shared ───────────────────────────────── - -// loadWorkspace resolves the workspace from the execution context. -func loadWorkspace(ctx context.Context, stores store.Stores, execCtx ExecutionContext) (*models.Workspace, error) { - if execCtx.WorkspaceID == "" { - return nil, fmt.Errorf("no workspace bound to this channel") - } - w, err := stores.Workspaces.GetByID(ctx, execCtx.WorkspaceID) - if err != nil { - return nil, fmt.Errorf("workspace not found: %w", err) - } - return w, nil -} - -const ( - // maxReadBytes is the soft limit for workspace_read (50 KB). - maxReadBytes = 50 * 1024 - // maxPatchFileBytes is the limit for files that can be patched (1 MB). - maxPatchFileBytes = 1024 * 1024 -) - -// ═══════════════════════════════════════════ -// workspace_ls -// ═══════════════════════════════════════════ - -type workspaceLsTool struct { - workspaceToolBase - stores store.Stores - wfs *workspace.FS -} - -func (t *workspaceLsTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_ls", - DisplayName: "List Files", - Description: "List files and directories in the workspace. Returns name, type, size, and content type for each entry. Use with path=\"\" or path=\".\" to list root.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "path": Prop("string", "Directory path to list (empty or \".\" for root)"), - "recursive": Prop("boolean", "If true, list all files recursively. Default false."), - }, nil), - } -} - -func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Path string `json:"path"` - Recursive bool `json:"recursive"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - entries, err := t.wfs.ListDir(ctx, w, args.Path, args.Recursive) - if err != nil { - return "", fmt.Errorf("list failed: %w", err) - } - - type entry struct { - Path string `json:"path"` - IsDir bool `json:"is_directory"` - ContentType string `json:"content_type,omitempty"` - SizeBytes int64 `json:"size_bytes,omitempty"` - } - - result := make([]entry, 0, len(entries)) - for _, e := range entries { - result = append(result, entry{ - Path: e.Path, - IsDir: e.IsDirectory, - ContentType: e.ContentType, - SizeBytes: e.SizeBytes, - }) - } - - b, _ := json.Marshal(map[string]interface{}{ - "path": args.Path, - "entries": result, - "count": len(result), - }) - return string(b), nil -} - -// ═══════════════════════════════════════════ -// workspace_read -// ═══════════════════════════════════════════ - -type workspaceReadTool struct { - workspaceToolBase - stores store.Stores - wfs *workspace.FS -} - -func (t *workspaceReadTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_read", - DisplayName: "Read File", - Description: "Read the contents of a file in the workspace. Text files are returned as content; binary files return a summary. Large files are truncated at ~50KB with an indicator.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "path": Prop("string", "File path relative to workspace root"), - }, []string{"path"}), - } -} - -func (t *workspaceReadTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Path string `json:"path"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.Path == "" { - return "", fmt.Errorf("path is required") - } - - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - // Stat first for content type and directory check - info, err := t.wfs.Stat(ctx, w, args.Path) - if err != nil { - return "", fmt.Errorf("file not found: %w", err) - } - if info.IsDirectory { - return "", fmt.Errorf("cannot read a directory; use workspace_ls instead") - } - - // Binary detection - if !isTextContentType(info.ContentType) { - b, _ := json.Marshal(map[string]interface{}{ - "path": args.Path, - "content_type": info.ContentType, - "size_bytes": info.SizeBytes, - "binary": true, - "message": fmt.Sprintf("Binary file (%s), %d bytes", info.ContentType, info.SizeBytes), - }) - return string(b), nil - } - - // Read content - r, size, err := t.wfs.ReadFile(ctx, w, args.Path) - if err != nil { - return "", fmt.Errorf("read failed: %w", err) - } - defer r.Close() - - // Read with truncation guard - buf := make([]byte, maxReadBytes+1) - n, readErr := io.ReadFull(r, buf) - if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF { - return "", fmt.Errorf("read error: %w", readErr) - } - - truncated := n > maxReadBytes - if truncated { - n = maxReadBytes - } - - resp := map[string]interface{}{ - "path": args.Path, - "content": string(buf[:n]), - "content_type": info.ContentType, - "size_bytes": size, - } - if truncated { - resp["truncated"] = true - resp["message"] = fmt.Sprintf("File truncated at %d bytes (total: %d bytes)", maxReadBytes, size) - } - - b, _ := json.Marshal(resp) - return string(b), nil -} - -// isTextContentType returns true for text/* and known source code types. -func isTextContentType(ct string) bool { - if strings.HasPrefix(ct, "text/") { - return true - } - switch ct { - case "application/json", "application/xml", "application/javascript", - "application/x-yaml", "application/toml", "application/x-sh", - "application/sql", "application/graphql": - return true - } - return false -} - -// ═══════════════════════════════════════════ -// workspace_write -// ═══════════════════════════════════════════ - -type workspaceWriteTool struct { - workspaceToolBase - stores store.Stores - wfs *workspace.FS -} - -func (t *workspaceWriteTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_write", - DisplayName: "Write File", - Description: "Create or overwrite a file in the workspace. Parent directories are created automatically. Returns the path and size of the written file.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "path": Prop("string", "File path relative to workspace root"), - "content": Prop("string", "File content to write"), - }, []string{"path", "content"}), - } -} - -func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Path string `json:"path"` - Content string `json:"content"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.Path == "" { - return "", fmt.Errorf("path is required") - } - - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(args.Content), int64(len(args.Content))); err != nil { - return "", fmt.Errorf("write failed: %w", err) - } - - size := len(args.Content) - log.Printf("📝 workspace_write: %s (%d bytes)", args.Path, size) - - b, _ := json.Marshal(map[string]interface{}{ - "path": args.Path, - "size_bytes": size, - "message": fmt.Sprintf("Written %d bytes to %s", size, args.Path), - }) - return string(b), nil -} - -// ═══════════════════════════════════════════ -// workspace_rm -// ═══════════════════════════════════════════ - -type workspaceRmTool struct { - workspaceToolBase - stores store.Stores - wfs *workspace.FS -} - -func (t *workspaceRmTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_rm", - DisplayName: "Delete File", - Description: "Delete a file or directory from the workspace. For non-empty directories, set recursive=true. This cannot be undone.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "path": Prop("string", "File or directory path to delete"), - "recursive": Prop("boolean", "Required for non-empty directories. Default false."), - }, []string{"path"}), - } -} - -func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Path string `json:"path"` - Recursive bool `json:"recursive"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.Path == "" { - return "", fmt.Errorf("path is required") - } - - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - if err := t.wfs.DeleteFile(ctx, w, args.Path, args.Recursive); err != nil { - return "", fmt.Errorf("delete failed: %w", err) - } - - log.Printf("🗑️ workspace_rm: %s (recursive=%v)", args.Path, args.Recursive) - - b, _ := json.Marshal(map[string]interface{}{ - "path": args.Path, - "message": fmt.Sprintf("Deleted %s", args.Path), - }) - return string(b), nil -} - -// ═══════════════════════════════════════════ -// workspace_mv -// ═══════════════════════════════════════════ - -type workspaceMvTool struct { - workspaceToolBase - stores store.Stores - wfs *workspace.FS -} - -func (t *workspaceMvTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_mv", - DisplayName: "Move/Rename File", - Description: "Move or rename a file or directory within the workspace. Destination parent directories are created automatically.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "source": Prop("string", "Current file path"), - "destination": Prop("string", "New file path"), - }, []string{"source", "destination"}), - } -} - -func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Source string `json:"source"` - Destination string `json:"destination"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.Source == "" || args.Destination == "" { - return "", fmt.Errorf("source and destination are required") - } - - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - if err := t.wfs.MoveFile(ctx, w, args.Source, args.Destination); err != nil { - return "", fmt.Errorf("move failed: %w", err) - } - - log.Printf("📦 workspace_mv: %s → %s", args.Source, args.Destination) - - b, _ := json.Marshal(map[string]interface{}{ - "source": args.Source, - "destination": args.Destination, - "message": fmt.Sprintf("Moved %s → %s", args.Source, args.Destination), - }) - return string(b), nil -} - -// ═══════════════════════════════════════════ -// workspace_patch -// ═══════════════════════════════════════════ - -type workspacePatchTool struct { - workspaceToolBase - stores store.Stores - wfs *workspace.FS -} - -func (t *workspacePatchTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_patch", - DisplayName: "Patch File", - Description: "Apply find-and-replace operations to a text file. Each operation's 'find' string must match exactly once in the file. Operations are applied sequentially. Use empty 'replace' to delete matched text.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "path": Prop("string", "File path to patch"), - "operations": map[string]interface{}{ - "type": "array", - "description": "List of find/replace operations to apply sequentially", - "items": map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "find": Prop("string", "Exact string to find (must match exactly once)"), - "replace": Prop("string", "String to replace with (empty to delete)"), - }, - "required": []string{"find", "replace"}, - }, - }, - }, []string{"path", "operations"}), - } -} - -func (t *workspacePatchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Path string `json:"path"` - Operations []struct { - Find string `json:"find"` - Replace string `json:"replace"` - } `json:"operations"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - if args.Path == "" { - return "", fmt.Errorf("path is required") - } - if len(args.Operations) == 0 { - return "", fmt.Errorf("at least one operation is required") - } - - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - // Read current content - r, size, err := t.wfs.ReadFile(ctx, w, args.Path) - if err != nil { - return "", fmt.Errorf("read failed: %w", err) - } - defer r.Close() - - if size > maxPatchFileBytes { - return "", fmt.Errorf("file too large for patching (%d bytes, max %d)", size, maxPatchFileBytes) - } - - data, err := io.ReadAll(r) - if err != nil { - return "", fmt.Errorf("read error: %w", err) - } - content := string(data) - - // Apply operations sequentially - applied := make([]string, 0, len(args.Operations)) - for i, op := range args.Operations { - if op.Find == "" { - return "", fmt.Errorf("operation %d: find string is empty", i+1) - } - - count := strings.Count(content, op.Find) - if count == 0 { - return "", fmt.Errorf("operation %d: find string not found in file", i+1) - } - if count > 1 { - return "", fmt.Errorf("operation %d: find string matches %d times (must match exactly once)", i+1, count) - } - - content = strings.Replace(content, op.Find, op.Replace, 1) - applied = append(applied, fmt.Sprintf("op %d: replaced %d chars → %d chars", i+1, len(op.Find), len(op.Replace))) - } - - // Write back - if err := t.wfs.WriteFile(ctx, w, args.Path, strings.NewReader(content), int64(len(content))); err != nil { - return "", fmt.Errorf("write failed: %w", err) - } - - log.Printf("🔧 workspace_patch: %s (%d ops, %d bytes)", args.Path, len(args.Operations), len(content)) - - b, _ := json.Marshal(map[string]interface{}{ - "path": args.Path, - "operations": applied, - "size_bytes": len(content), - "message": fmt.Sprintf("Applied %d patch(es) to %s", len(args.Operations), args.Path), - }) - return string(b), nil -} diff --git a/server/tools/workspace_search.go b/server/tools/workspace_search.go deleted file mode 100644 index 84fc0fe..0000000 --- a/server/tools/workspace_search.go +++ /dev/null @@ -1,151 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/store" -) - -// ── workspace_search ──────────────────────── - -type workspaceSearchTool struct { - workspaceToolBase - stores store.Stores - embedder *knowledge.Embedder -} - -func (t *workspaceSearchTool) Definition() ToolDef { - return ToolDef{ - Name: "workspace_search", - DisplayName: "Workspace Search", - Description: "Semantic search across workspace files. Finds code, documentation, and text content relevant to a natural language query. Returns matching file paths, content snippets, relevance scores, and approximate line numbers.", - Category: "workspace", - Parameters: JSONSchema(map[string]interface{}{ - "query": Prop("string", "Natural language search query (e.g. 'database connection pooling', 'error handling middleware')"), - "top_k": map[string]interface{}{ - "type": "integer", - "description": "Maximum number of results to return (default 10, max 20)", - "default": 10, - }, - "file_pattern": Prop("string", "Optional glob pattern to filter results by file path (e.g. '*.go', 'src/*.ts'). Applied post-search."), - }, []string{"query"}), - } -} - -func (t *workspaceSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { - var args struct { - Query string `json:"query"` - TopK int `json:"top_k"` - FilePattern string `json:"file_pattern"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - return "", fmt.Errorf("invalid arguments: %w", err) - } - - if args.Query == "" { - return "", fmt.Errorf("query is required") - } - if args.TopK <= 0 { - args.TopK = 10 - } - if args.TopK > 20 { - args.TopK = 20 - } - - // Resolve workspace - w, err := loadWorkspace(ctx, t.stores, execCtx) - if err != nil { - return "", err - } - - if !w.IndexingEnabled { - return "", fmt.Errorf("workspace indexing is disabled for this workspace") - } - - // Embed the query - embedResult, err := t.embedder.EmbedChunks(ctx, execCtx.UserID, nil, []string{args.Query}) - if err != nil { - return "", fmt.Errorf("failed to embed query: %w", err) - } - if len(embedResult.Vectors) == 0 { - return "", fmt.Errorf("embedding returned no vectors") - } - - queryVec := embedResult.Vectors[0] - - // Fetch more if we'll post-filter by glob - searchLimit := args.TopK - if args.FilePattern != "" { - searchLimit = args.TopK * 3 - if searchLimit > 50 { - searchLimit = 50 - } - } - - results, err := t.stores.Workspaces.SimilaritySearch(ctx, w.ID, queryVec, 0.3, searchLimit) - if err != nil { - return "", fmt.Errorf("search failed: %w", err) - } - - // Apply glob filter if specified - if args.FilePattern != "" { - results = filterByGlob(results, args.FilePattern) - } - - // Cap at top_k - if len(results) > args.TopK { - results = results[:args.TopK] - } - - // Format response - formatted := make([]map[string]interface{}, len(results)) - for i, r := range results { - m := map[string]interface{}{ - "file_path": r.FilePath, - "content": r.Content, - "score": fmt.Sprintf("%.3f", r.Score), - } - if r.LineHint > 0 { - m["line_hint"] = r.LineHint - } - formatted[i] = m - } - - resp := map[string]interface{}{ - "query": args.Query, - "count": len(formatted), - "results": formatted, - } - if args.FilePattern != "" { - resp["file_pattern"] = args.FilePattern - } - if len(formatted) == 0 { - resp["message"] = "No matching results found. Try a different query or check that workspace files have been indexed." - } - - b, _ := json.Marshal(resp) - return string(b), nil -} - -// filterByGlob filters search results by a glob pattern. -func filterByGlob(results []models.WorkspaceChunkResult, pattern string) []models.WorkspaceChunkResult { - var filtered []models.WorkspaceChunkResult - for _, r := range results { - // Try matching against basename - matched, _ := filepath.Match(pattern, filepath.Base(r.FilePath)) - if !matched && strings.Contains(pattern, "/") { - // Try full path match if pattern contains directory separator - matched, _ = filepath.Match(pattern, r.FilePath) - } - if matched { - filtered = append(filtered, r) - } - } - return filtered -} diff --git a/server/treepath/path.go b/server/treepath/path.go deleted file mode 100644 index 6539989..0000000 --- a/server/treepath/path.go +++ /dev/null @@ -1,75 +0,0 @@ -// Package treepath provides message tree traversal operations. -// -// v0.29.0: Delegates to store.MessageStore. The package exists only for -// backward compatibility with handler aliases in tree.go. New code should -// call stores.Messages.* directly. This package will be deleted once all -// callers are migrated. -package treepath - -import ( - "context" - "encoding/json" - - "switchboard-core/store" -) - -// Stores is set at startup by main.go. All tree operations delegate here. -// If nil, functions panic — there is no fallback to raw SQL. -var Stores *store.Stores - -// ── Type Aliases ──────────────────────────── -// Existing consumers reference these types. They're now aliases into the -// store package where the canonical definitions live. - -type PathMessage = store.PathMessage -type SiblingInfo = store.SiblingInfo - -// ── Public Functions ──────────────────────── -// Same signatures as before. All delegate to store methods. - -func GetActiveLeaf(channelID, userID string) (*string, error) { - return Stores.Messages.GetActiveLeaf(context.Background(), channelID, userID) -} - -func GetActivePath(channelID, userID string) ([]PathMessage, error) { - return Stores.Messages.GetActivePath(context.Background(), channelID, userID) -} - -func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) { - return Stores.Messages.GetPathToLeaf(context.Background(), channelID, leafID) -} - -func GetSiblingCount(channelID string, parentID *string) int { - count, _ := Stores.Messages.GetSiblingCount(context.Background(), channelID, parentID) - return count -} - -func GetSiblings(messageID string) ([]SiblingInfo, int, error) { - return Stores.Messages.GetSiblingsList(context.Background(), messageID) -} - -func FindLeafFromMessage(messageID string) (string, error) { - return Stores.Messages.FindLeafFromMessage(context.Background(), messageID) -} - -func UpdateCursor(channelID, userID, messageID string) error { - return Stores.Channels.SetCursor(context.Background(), channelID, userID, messageID) -} - -func NextSiblingIndex(channelID string, parentID *string) int { - idx, _ := Stores.Messages.NextSiblingIndexForParent(context.Background(), channelID, parentID) - return idx -} - -// IsSummaryMessage checks if a PathMessage has summary metadata. -func IsSummaryMessage(m *PathMessage) bool { - if m.Metadata == nil { - return false - } - var meta map[string]interface{} - if err := json.Unmarshal(*m.Metadata, &meta); err != nil { - return false - } - t, _ := meta["type"].(string) - return t == "summary" -} diff --git a/server/workspace/archive.go b/server/workspace/archive.go deleted file mode 100644 index 8023706..0000000 --- a/server/workspace/archive.go +++ /dev/null @@ -1,513 +0,0 @@ -package workspace - -import ( - "archive/tar" - "archive/zip" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "log" - "os" - "path/filepath" - "strings" - - "switchboard-core/models" -) - -// ── Archive Configuration ─────────────────── - -const ( - // MaxArchiveFiles is the max number of files to extract from an archive. - MaxArchiveFiles = 10000 - - // MaxArchiveFileSize is the max size of a single file within an archive. - MaxArchiveFileSize int64 = 100 * 1024 * 1024 // 100 MB -) - -// ── Extract ───────────────────────────────── - -// ExtractArchive extracts a zip or tar.gz archive into the workspace. -// Returns the number of files extracted. Respects workspace quota. -// format must be "zip" or "tar.gz". -func (fs *FS) ExtractArchive(ctx context.Context, w *models.Workspace, archivePath, format string) (int, error) { - var count int - var err error - switch format { - case "zip": - count, err = fs.extractZip(ctx, w, archivePath) - case "tar.gz", "tgz": - count, err = fs.extractTarGz(ctx, w, archivePath) - default: - return 0, fmt.Errorf("workspace: unsupported archive format: %s", format) - } - - // Trigger batch indexing for all extracted files (v0.21.2) - if err == nil && fs.indexer != nil && count > 0 { - files, listErr := fs.store.ListFiles(ctx, w.ID, "", true) - if listErr == nil { - var teamID *string - if w.OwnerType == models.WorkspaceOwnerTeam { - teamID = &w.OwnerID - } - fs.indexer.IndexBatch(w, files, w.OwnerID, teamID) - } - } - - return count, err -} - -func (fs *FS) extractZip(ctx context.Context, w *models.Workspace, archivePath string) (int, error) { - r, err := zip.OpenReader(archivePath) - if err != nil { - return 0, fmt.Errorf("workspace: open zip: %w", err) - } - defer r.Close() - - if len(r.File) > MaxArchiveFiles { - return 0, fmt.Errorf("workspace: archive contains %d files (max %d)", len(r.File), MaxArchiveFiles) - } - - // Detect common root prefix (e.g. "project-name/") and strip it - prefix := detectCommonPrefix(zipFileNames(r.File)) - - root := fs.filesDir(w) - count := 0 - var totalBytes int64 - - for _, f := range r.File { - if err := ctx.Err(); err != nil { - return count, err - } - - name := stripPrefix(f.Name, prefix) - if name == "" || name == "." { - continue - } - - // Security: reject absolute paths and traversal - if isUnsafePath(name) { - log.Printf("workspace: skipping unsafe zip entry: %s", f.Name) - continue - } - - destPath := filepath.Join(root, filepath.FromSlash(name)) - - if f.FileInfo().IsDir() { - if err := os.MkdirAll(destPath, 0750); err != nil { - return count, err - } - fs.store.UpsertFile(ctx, &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: name, - IsDirectory: true, - }) - continue - } - - // Size check - if f.UncompressedSize64 > uint64(MaxArchiveFileSize) { - log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, f.UncompressedSize64) - continue - } - - // Quota check - quota := DefaultMaxBytes - if w.MaxBytes != nil { - quota = *w.MaxBytes - } - if totalBytes+int64(f.UncompressedSize64) > quota { - return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota) - } - - // Extract file - rc, err := f.Open() - if err != nil { - return count, fmt.Errorf("workspace: open zip entry %s: %w", name, err) - } - - n, hash, err := extractToFile(destPath, rc, MaxArchiveFileSize) - rc.Close() - if err != nil { - return count, fmt.Errorf("workspace: extract %s: %w", name, err) - } - - totalBytes += n - count++ - - contentType := detectContentType(name, destPath) - fs.store.UpsertFile(ctx, &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: name, - IsDirectory: false, - ContentType: contentType, - SizeBytes: n, - SHA256: hash, - }) - } - - log.Printf("workspace: extracted %d files from zip (%d bytes)", count, totalBytes) - return count, nil -} - -func (fs *FS) extractTarGz(ctx context.Context, w *models.Workspace, archivePath string) (int, error) { - f, err := os.Open(archivePath) - if err != nil { - return 0, fmt.Errorf("workspace: open tar.gz: %w", err) - } - defer f.Close() - - gz, err := gzip.NewReader(f) - if err != nil { - return 0, fmt.Errorf("workspace: gzip reader: %w", err) - } - defer gz.Close() - - tr := tar.NewReader(gz) - root := fs.filesDir(w) - count := 0 - var totalBytes int64 - - // First pass: collect names to detect common prefix - // Since tar is streaming, we can't do two passes easily. - // We'll detect prefix from the first entry's directory. - var prefix string - prefixDetected := false - - for { - if err := ctx.Err(); err != nil { - return count, err - } - - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return count, fmt.Errorf("workspace: tar read: %w", err) - } - - if count >= MaxArchiveFiles { - return count, fmt.Errorf("workspace: archive contains more than %d files", MaxArchiveFiles) - } - - // Detect prefix from first entry - if !prefixDetected { - if idx := strings.IndexByte(header.Name, '/'); idx > 0 { - prefix = header.Name[:idx+1] - } - prefixDetected = true - } - - name := stripPrefix(header.Name, prefix) - if name == "" || name == "." { - continue - } - - if isUnsafePath(name) { - log.Printf("workspace: skipping unsafe tar entry: %s", header.Name) - continue - } - - destPath := filepath.Join(root, filepath.FromSlash(name)) - - switch header.Typeflag { - case tar.TypeDir: - if err := os.MkdirAll(destPath, 0750); err != nil { - return count, err - } - fs.store.UpsertFile(ctx, &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: name, - IsDirectory: true, - }) - - case tar.TypeReg: - if header.Size > MaxArchiveFileSize { - log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, header.Size) - continue - } - - quota := DefaultMaxBytes - if w.MaxBytes != nil { - quota = *w.MaxBytes - } - if totalBytes+header.Size > quota { - return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota) - } - - n, hash, extractErr := extractToFile(destPath, tr, MaxArchiveFileSize) - if extractErr != nil { - return count, fmt.Errorf("workspace: extract %s: %w", name, extractErr) - } - - totalBytes += n - count++ - - contentType := detectContentType(name, destPath) - fs.store.UpsertFile(ctx, &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: name, - IsDirectory: false, - ContentType: contentType, - SizeBytes: n, - SHA256: hash, - }) - - default: - // Skip symlinks, devices, etc. - continue - } - } - - log.Printf("workspace: extracted %d files from tar.gz (%d bytes)", count, totalBytes) - return count, nil -} - -// ── Create Archive ────────────────────────── - -// CreateArchive packages the workspace into a zip or tar.gz archive. -// Returns a path to the temporary archive file. Caller must remove it when done. -func (fs *FS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (string, error) { - switch format { - case "zip": - return fs.createZip(ctx, w) - case "tar.gz", "tgz": - return fs.createTarGz(ctx, w) - default: - return "", fmt.Errorf("workspace: unsupported archive format: %s", format) - } -} - -func (fs *FS) createZip(ctx context.Context, w *models.Workspace) (string, error) { - root := fs.filesDir(w) - - tmp, err := os.CreateTemp("", "ws-*.zip") - if err != nil { - return "", err - } - tmpName := tmp.Name() - - zw := zip.NewWriter(tmp) - - err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if err := ctx.Err(); err != nil { - return err - } - - rel, err := filepath.Rel(root, abs) - if err != nil { - return err - } - if rel == "." { - return nil - } - rel = filepath.ToSlash(rel) - - if info.IsDir() { - _, err := zw.Create(rel + "/") - return err - } - - header, err := zip.FileInfoHeader(info) - if err != nil { - return err - } - header.Name = rel - header.Method = zip.Deflate - - writer, err := zw.CreateHeader(header) - if err != nil { - return err - } - - f, err := os.Open(abs) - if err != nil { - return err - } - defer f.Close() - - _, err = io.Copy(writer, f) - return err - }) - - if closeErr := zw.Close(); closeErr != nil && err == nil { - err = closeErr - } - if closeErr := tmp.Close(); closeErr != nil && err == nil { - err = closeErr - } - - if err != nil { - os.Remove(tmpName) - return "", fmt.Errorf("workspace: create zip: %w", err) - } - - return tmpName, nil -} - -func (fs *FS) createTarGz(ctx context.Context, w *models.Workspace) (string, error) { - root := fs.filesDir(w) - - tmp, err := os.CreateTemp("", "ws-*.tar.gz") - if err != nil { - return "", err - } - tmpName := tmp.Name() - - gw := gzip.NewWriter(tmp) - tw := tar.NewWriter(gw) - - err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if err := ctx.Err(); err != nil { - return err - } - - rel, err := filepath.Rel(root, abs) - if err != nil { - return err - } - if rel == "." { - return nil - } - rel = filepath.ToSlash(rel) - - header, err := tar.FileInfoHeader(info, "") - if err != nil { - return err - } - header.Name = rel - - if err := tw.WriteHeader(header); err != nil { - return err - } - - if info.IsDir() { - return nil - } - - f, err := os.Open(abs) - if err != nil { - return err - } - defer f.Close() - - _, err = io.Copy(tw, f) - return err - }) - - if closeErr := tw.Close(); closeErr != nil && err == nil { - err = closeErr - } - if closeErr := gw.Close(); closeErr != nil && err == nil { - err = closeErr - } - if closeErr := tmp.Close(); closeErr != nil && err == nil { - err = closeErr - } - - if err != nil { - os.Remove(tmpName) - return "", fmt.Errorf("workspace: create tar.gz: %w", err) - } - - return tmpName, nil -} - -// ── Helpers ───────────────────────────────── - -// extractToFile writes content from a reader to a file, creating parent dirs. -// Returns bytes written and SHA256 hash. -func extractToFile(destPath string, r io.Reader, maxSize int64) (int64, string, error) { - if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil { - return 0, "", err - } - - f, err := os.Create(destPath) - if err != nil { - return 0, "", err - } - defer f.Close() - - h := sha256.New() - tee := io.TeeReader(io.LimitReader(r, maxSize+1), h) - - n, err := io.Copy(f, tee) - if err != nil { - return n, "", err - } - if n > maxSize { - os.Remove(destPath) - return 0, "", fmt.Errorf("file exceeds max size (%d bytes)", maxSize) - } - - return n, hex.EncodeToString(h.Sum(nil)), nil -} - -// isUnsafePath returns true if the path contains traversal or unsafe patterns. -func isUnsafePath(p string) bool { - if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") { - return true - } - if strings.Contains(p, "..") { - return true - } - if strings.HasPrefix(p, ".") && !strings.HasPrefix(p, "./") { - // Allow dotfiles like .gitignore, but not .. or hidden dirs as root - // Actually, dotfiles are fine. Let them through. - return false - } - return false -} - -// detectCommonPrefix finds a shared directory prefix among file names. -// E.g. ["project/src/a.go", "project/src/b.go"] → "project/" -func detectCommonPrefix(names []string) string { - if len(names) == 0 { - return "" - } - - // Check if all files share a common first directory component - var prefix string - for _, name := range names { - idx := strings.IndexByte(name, '/') - if idx < 0 { - // File at root level — no common prefix - return "" - } - dir := name[:idx+1] - if prefix == "" { - prefix = dir - } else if dir != prefix { - return "" - } - } - return prefix -} - -// stripPrefix removes the common archive prefix from a file name. -func stripPrefix(name, prefix string) string { - if prefix != "" { - name = strings.TrimPrefix(name, prefix) - } - name = strings.TrimSuffix(name, "/") - name = filepath.ToSlash(name) - return cleanPath(name) -} - -// zipFileNames extracts file names from zip entries. -func zipFileNames(files []*zip.File) []string { - names := make([]string, len(files)) - for i, f := range files { - names[i] = f.Name - } - return names -} diff --git a/server/workspace/fs.go b/server/workspace/fs.go deleted file mode 100644 index 9e9f337..0000000 --- a/server/workspace/fs.go +++ /dev/null @@ -1,716 +0,0 @@ -// Package workspace provides filesystem operations for workspaces. -// It operates on the PVC filesystem and keeps the DB metadata index in sync -// via the WorkspaceStore. -package workspace - -import ( - "bufio" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "log" - "mime" - "net/http" - "os" - "path" - "path/filepath" - "strings" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Configuration ─────────────────────────── - -const ( - // DefaultMaxBytes is the default workspace quota (500 MB). - DefaultMaxBytes int64 = 500 * 1024 * 1024 - - // MaxSingleFileBytes is the max size for a single file write (100 MB). - MaxSingleFileBytes int64 = 100 * 1024 * 1024 - - // filesSubdir is the subdirectory within a workspace root that holds user files. - // Keeps metadata (.workspace.json, future .git/) out of the user's namespace. - filesSubdir = "files" - - // metadataFile is the workspace metadata file stored in the workspace root. - metadataFile = ".workspace.json" -) - -// ── FS ────────────────────────────────────── - -// FS provides filesystem operations for workspaces. -// All file paths are relative to the workspace's files/ subdirectory. -// The DB metadata index is updated on every mutating operation. -type FS struct { - basePath string // e.g. /data/storage/workspaces - store store.WorkspaceStore - indexer *Indexer // optional — set via SetIndexer for v0.21.2 indexing -} - -// NewFS creates a workspace filesystem manager. -func NewFS(basePath string, ws store.WorkspaceStore) *FS { - return &FS{basePath: basePath, store: ws} -} - -// SetIndexer attaches the workspace indexer for background file indexing. -// Must be called after NewIndexer is created (circular dependency break). -func (fs *FS) SetIndexer(idx *Indexer) { - fs.indexer = idx -} - -// Init ensures the base workspaces directory exists. -func (fs *FS) Init() error { - return os.MkdirAll(fs.basePath, 0750) -} - -// ── Path Helpers ──────────────────────────── - -// filesDir returns the absolute path to a workspace's file tree root. -func (fs *FS) filesDir(w *models.Workspace) string { - return filepath.Join(fs.basePath, w.ID, filesSubdir) -} - -// absPath resolves a relative workspace path to an absolute filesystem path. -// Returns an error if the path escapes the workspace root (traversal attack). -func (fs *FS) absPath(w *models.Workspace, relPath string) (string, error) { - orig := strings.TrimSpace(relPath) - relPath = cleanPath(relPath) - if relPath == "" { - if orig != "" && orig != "." && orig != "./" { - // Non-empty input was sanitized to empty — traversal attempt - return "", fmt.Errorf("workspace: path traversal detected: %s", orig) - } - return fs.filesDir(w), nil - } - - root := fs.filesDir(w) - abs := filepath.Join(root, relPath) - - // Resolve symlinks and ensure we're still under root - absResolved, err := filepath.Abs(abs) - if err != nil { - return "", fmt.Errorf("workspace: invalid path: %w", err) - } - rootResolved, err := filepath.Abs(root) - if err != nil { - return "", fmt.Errorf("workspace: invalid root: %w", err) - } - - if !strings.HasPrefix(absResolved, rootResolved+string(filepath.Separator)) && absResolved != rootResolved { - return "", fmt.Errorf("workspace: path traversal detected: %s", relPath) - } - - return abs, nil -} - -// cleanPath normalizes a relative path: removes leading slashes, cleans ./ and ../ -func cleanPath(p string) string { - p = strings.TrimSpace(p) - p = path.Clean(p) - p = strings.TrimPrefix(p, "/") - p = strings.TrimPrefix(p, "./") - - // Reject paths that resolve outside root - if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") { - return "" - } - if p == "." { - return "" - } - return p -} - -// ── Workspace Lifecycle ───────────────────── - -// CreateDir creates the workspace directory structure on disk. -// Call after the DB row is created. -func (fs *FS) CreateDir(w *models.Workspace) error { - filesPath := fs.filesDir(w) - return os.MkdirAll(filesPath, 0750) -} - -// Destroy removes the entire workspace directory from disk. -// Call after marking the workspace as "deleting" in DB. -func (fs *FS) Destroy(ctx context.Context, w *models.Workspace) error { - wsDir := filepath.Join(fs.basePath, w.ID) - - // Remove all file index entries first - if err := fs.store.DeleteAllFiles(ctx, w.ID); err != nil { - log.Printf("workspace: failed to delete file index for %s: %v", w.ID, err) - } - - return os.RemoveAll(wsDir) -} - -// ── File Operations ───────────────────────── - -// ReadFile returns a reader for the file at relPath. -// Caller must close the returned ReadCloser. -func (fs *FS) ReadFile(ctx context.Context, w *models.Workspace, relPath string) (io.ReadCloser, int64, error) { - abs, err := fs.absPath(w, relPath) - if err != nil { - return nil, 0, err - } - - info, err := os.Stat(abs) - if err != nil { - if os.IsNotExist(err) { - return nil, 0, fmt.Errorf("workspace: file not found: %s", relPath) - } - return nil, 0, err - } - if info.IsDir() { - return nil, 0, fmt.Errorf("workspace: cannot read directory: %s", relPath) - } - - f, err := os.Open(abs) - if err != nil { - return nil, 0, err - } - return f, info.Size(), nil -} - -// WriteFile creates or overwrites a file at relPath. -// Creates parent directories as needed. Updates the DB metadata index. -func (fs *FS) WriteFile(ctx context.Context, w *models.Workspace, relPath string, r io.Reader, size int64) error { - relPath = cleanPath(relPath) - if relPath == "" { - return fmt.Errorf("workspace: empty file path") - } - - abs, err := fs.absPath(w, relPath) - if err != nil { - return err - } - - // Reject symlinks at destination - if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("workspace: refusing to overwrite symlink: %s", relPath) - } - - // Check existing sha256 for content-addressed skip (v0.21.2) - var oldSHA256 string - if existing, err := fs.store.GetFile(ctx, w.ID, relPath); err == nil { - oldSHA256 = existing.SHA256 - } - - // Ensure parent directory exists - dir := filepath.Dir(abs) - if err := os.MkdirAll(dir, 0750); err != nil { - return fmt.Errorf("workspace: mkdir %s: %w", filepath.Dir(relPath), err) - } - - // Write to temp file + rename (atomic) - tmp, err := os.CreateTemp(dir, ".ws-*.tmp") - if err != nil { - return fmt.Errorf("workspace: create temp: %w", err) - } - tmpName := tmp.Name() - defer func() { - tmp.Close() - os.Remove(tmpName) // cleanup on error; no-op after successful rename - }() - - // Write content and compute hash simultaneously - h := sha256.New() - tee := io.TeeReader(r, h) - n, err := io.Copy(tmp, tee) - if err != nil { - return fmt.Errorf("workspace: write %s: %w", relPath, err) - } - if err := tmp.Close(); err != nil { - return fmt.Errorf("workspace: close temp: %w", err) - } - - // Atomic rename - if err := os.Rename(tmpName, abs); err != nil { - return fmt.Errorf("workspace: rename %s: %w", relPath, err) - } - - // Update DB index - contentType := detectContentType(relPath, abs) - newHash := hex.EncodeToString(h.Sum(nil)) - - f := &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: relPath, - IsDirectory: false, - ContentType: contentType, - SizeBytes: n, - SHA256: newHash, - } - if err := fs.store.UpsertFile(ctx, f); err != nil { - return err - } - - // Trigger async indexing if content changed (v0.21.2) - if fs.indexer != nil && newHash != oldSHA256 { - // Use workspace owner for embedding provider resolution - var teamID *string - if w.OwnerType == models.WorkspaceOwnerTeam { - teamID = &w.OwnerID - } - fs.indexer.IndexFile(w, f, w.OwnerID, teamID) - } - - return nil -} - -// DeleteFile removes a file or empty directory at relPath. -func (fs *FS) DeleteFile(ctx context.Context, w *models.Workspace, relPath string, recursive bool) error { - relPath = cleanPath(relPath) - if relPath == "" { - return fmt.Errorf("workspace: cannot delete workspace root") - } - - abs, err := fs.absPath(w, relPath) - if err != nil { - return err - } - - info, err := os.Stat(abs) - if err != nil { - if os.IsNotExist(err) { - // Clean up DB index anyway - fs.store.DeleteFile(ctx, w.ID, relPath) - return nil - } - return err - } - - if info.IsDir() { - if recursive { - if err := os.RemoveAll(abs); err != nil { - return err - } - // Remove all files under this prefix from the index - prefix := relPath - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - fs.store.DeleteFilesByPrefix(ctx, w.ID, prefix) - return fs.store.DeleteFile(ctx, w.ID, relPath) - } - // Non-recursive: only remove empty dirs - if err := os.Remove(abs); err != nil { - return fmt.Errorf("workspace: directory not empty (use recursive=true): %s", relPath) - } - } else { - if err := os.Remove(abs); err != nil { - return err - } - } - - return fs.store.DeleteFile(ctx, w.ID, relPath) -} - -// Mkdir creates a directory at relPath. Creates parents as needed. -func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) error { - relPath = cleanPath(relPath) - if relPath == "" { - return nil // root always exists - } - - abs, err := fs.absPath(w, relPath) - if err != nil { - return err - } - - if err := os.MkdirAll(abs, 0750); err != nil { - return err - } - - return fs.store.UpsertFile(ctx, &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: relPath, - IsDirectory: true, - }) -} - -// MoveFile renames or moves a file within the workspace. -// Creates destination parent directories as needed. -func (fs *FS) MoveFile(ctx context.Context, w *models.Workspace, srcRel, dstRel string) error { - srcRel = cleanPath(srcRel) - dstRel = cleanPath(dstRel) - if srcRel == "" || dstRel == "" { - return fmt.Errorf("workspace: source and destination paths required") - } - if srcRel == dstRel { - return nil // no-op - } - - srcAbs, err := fs.absPath(w, srcRel) - if err != nil { - return err - } - dstAbs, err := fs.absPath(w, dstRel) - if err != nil { - return err - } - - // Ensure source exists - srcInfo, err := os.Stat(srcAbs) - if err != nil { - return fmt.Errorf("workspace: source not found: %s", srcRel) - } - - // Ensure destination parent exists - if err := os.MkdirAll(filepath.Dir(dstAbs), 0750); err != nil { - return fmt.Errorf("workspace: mkdir for move: %w", err) - } - - // Rename on disk - if err := os.Rename(srcAbs, dstAbs); err != nil { - return fmt.Errorf("workspace: rename %s → %s: %w", srcRel, dstRel, err) - } - - // Update DB index: delete old, upsert new - if srcInfo.IsDir() { - // For directories, delete all files under old prefix and reconcile new prefix. - // Simple approach: delete old entries, let next reconcile pick up new ones. - if err := fs.store.DeleteFilesByPrefix(ctx, w.ID, srcRel+"/"); err != nil { - log.Printf("workspace move: cleanup old prefix %s: %v", srcRel, err) - } - fs.store.DeleteFile(ctx, w.ID, srcRel) - // Upsert new directory entry - fs.store.UpsertFile(ctx, &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: dstRel, - IsDirectory: true, - }) - } else { - // Read metadata from old entry if available - oldFile, _ := fs.store.GetFile(ctx, w.ID, srcRel) - fs.store.DeleteFile(ctx, w.ID, srcRel) - - newFile := &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: dstRel, - IsDirectory: false, - SizeBytes: srcInfo.Size(), - ContentType: detectContentType(dstRel, dstAbs), - } - if oldFile != nil { - newFile.SHA256 = oldFile.SHA256 - } - fs.store.UpsertFile(ctx, newFile) - } - - return nil -} - -// Stat returns file metadata without reading content. -func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) { - relPath = cleanPath(relPath) - - // Try DB first (fast) - f, err := fs.store.GetFile(ctx, w.ID, relPath) - if err == nil { - return f, nil - } - - // Fall back to filesystem - abs, err := fs.absPath(w, relPath) - if err != nil { - return nil, err - } - - info, err := os.Stat(abs) - if err != nil { - return nil, fmt.Errorf("workspace: file not found: %s", relPath) - } - - return &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: relPath, - IsDirectory: info.IsDir(), - SizeBytes: info.Size(), - ContentType: detectContentType(relPath, abs), - }, nil -} - -// ListDir returns files at the given prefix. -func (fs *FS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error) { - prefix = cleanPath(prefix) - if prefix != "" && !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - return fs.store.ListFiles(ctx, w.ID, prefix, recursive) -} - -// Tree returns all files in the workspace (recursive from root). -func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error) { - return fs.store.ListFiles(ctx, w.ID, "", true) -} - -// ── Reconcile ─────────────────────────────── - -// Reconcile walks the filesystem and syncs the DB index. -// Adds missing entries, removes stale entries, updates sizes/hashes. -func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) { - root := fs.filesDir(w) - - // v0.27.0: Load .gitignore patterns from workspace root - ignorePatterns := loadGitignore(root) - - // Walk FS and collect all paths - fsPaths := make(map[string]os.FileInfo) - err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(root, abs) - if err != nil { - return err - } - if rel == "." { - return nil - } - // Normalize to forward slashes - rel = filepath.ToSlash(rel) - - // v0.27.0: Skip gitignored paths - if matchesGitignore(rel, info.IsDir(), ignorePatterns) { - if info.IsDir() { - return filepath.SkipDir - } - return nil - } - - fsPaths[rel] = info - return nil - }) - if err != nil && !os.IsNotExist(err) { - return 0, 0, 0, fmt.Errorf("workspace: reconcile walk: %w", err) - } - - // Get all DB entries - dbFiles, err := fs.store.ListFiles(ctx, w.ID, "", true) - if err != nil { - return 0, 0, 0, fmt.Errorf("workspace: reconcile list: %w", err) - } - - dbPaths := make(map[string]*models.WorkspaceFile, len(dbFiles)) - for i := range dbFiles { - dbPaths[dbFiles[i].Path] = &dbFiles[i] - } - - // Add missing FS entries to DB - for relPath, info := range fsPaths { - if _, exists := dbPaths[relPath]; !exists { - abs := filepath.Join(root, relPath) - f := &models.WorkspaceFile{ - WorkspaceID: w.ID, - Path: relPath, - IsDirectory: info.IsDir(), - SizeBytes: info.Size(), - ContentType: detectContentType(relPath, abs), - } - if !info.IsDir() { - if hash, hashErr := hashFile(abs); hashErr == nil { - f.SHA256 = hash - } - } - if upsertErr := fs.store.UpsertFile(ctx, f); upsertErr == nil { - added++ - } - } - } - - // Remove DB entries not on FS - for dbPath := range dbPaths { - if _, exists := fsPaths[dbPath]; !exists { - if delErr := fs.store.DeleteFile(ctx, w.ID, dbPath); delErr == nil { - removed++ - } - } - } - - log.Printf("workspace: reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated) - return added, removed, updated, nil -} - -// ── Content Type Detection ────────────────── - -// detectContentType determines the MIME type from extension first, -// then falls back to http.DetectContentType on the first 512 bytes. -func detectContentType(relPath, absPath string) string { - // Extension-based detection first (covers source code) - ext := strings.ToLower(filepath.Ext(relPath)) - if ct := mimeByExtension(ext); ct != "" { - return ct - } - - // Sniff the first 512 bytes - f, err := os.Open(absPath) - if err != nil { - return "application/octet-stream" - } - defer f.Close() - - buf := make([]byte, 512) - n, _ := f.Read(buf) - if n == 0 { - return "application/octet-stream" - } - return http.DetectContentType(buf[:n]) -} - -// mimeByExtension returns MIME type for common extensions. -// mime.TypeByExtension misses many source code types. -func mimeByExtension(ext string) string { - // Source code extensions that mime.TypeByExtension doesn't know about - codeTypes := map[string]string{ - ".go": "text/x-go", - ".rs": "text/x-rust", - ".py": "text/x-python", - ".rb": "text/x-ruby", - ".java": "text/x-java", - ".kt": "text/x-kotlin", - ".swift": "text/x-swift", - ".c": "text/x-c", - ".cpp": "text/x-c++", - ".h": "text/x-c", - ".hpp": "text/x-c++", - ".ts": "text/typescript", - ".tsx": "text/typescript", - ".jsx": "text/jsx", - ".vue": "text/x-vue", - ".svelte": "text/x-svelte", - ".scala": "text/x-scala", - ".pl": "text/x-perl", - ".pm": "text/x-perl", - ".lua": "text/x-lua", - ".zig": "text/x-zig", - ".nim": "text/x-nim", - ".ex": "text/x-elixir", - ".exs": "text/x-elixir", - ".sh": "text/x-shellscript", - ".bash": "text/x-shellscript", - ".zsh": "text/x-shellscript", - ".fish": "text/x-shellscript", - ".sql": "text/x-sql", - ".tf": "text/x-terraform", - ".hcl": "text/x-hcl", - ".proto": "text/x-protobuf", - ".graphql": "text/x-graphql", - ".toml": "application/toml", - ".yaml": "text/yaml", - ".yml": "text/yaml", - ".dockerfile": "text/x-dockerfile", - ".mod": "text/x-go-mod", - ".sum": "text/x-go-sum", - ".lock": "text/plain", - ".gitignore": "text/plain", - ".env": "text/plain", - ".editorconfig": "text/plain", - } - - if ct, ok := codeTypes[ext]; ok { - return ct - } - - // Fall back to standard library - if ct := mime.TypeByExtension(ext); ct != "" { - return ct - } - - return "" -} - -// ── Hashing ───────────────────────────────── - -// hashFile computes SHA256 of a file. -func hashFile(absPath string) (string, error) { - f, err := os.Open(absPath) - if err != nil { - return "", err - } - defer f.Close() - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -// ── Gitignore ─────────────────────────────── -// v0.27.0: Simple .gitignore parser for workspace indexing. -// Supports glob patterns, directory-only patterns (trailing /), -// negation (!), and comments (#). Does not support ** (double-star). - -type gitignorePattern struct { - pattern string - negate bool - dirOnly bool -} - -// loadGitignore reads .gitignore from the workspace root. -// Returns nil if the file doesn't exist. -func loadGitignore(root string) []gitignorePattern { - f, err := os.Open(filepath.Join(root, ".gitignore")) - if err != nil { - return nil - } - defer f.Close() - - var patterns []gitignorePattern - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || line[0] == '#' { - continue - } - p := gitignorePattern{} - if line[0] == '!' { - p.negate = true - line = line[1:] - } - if strings.HasSuffix(line, "/") { - p.dirOnly = true - line = strings.TrimSuffix(line, "/") - } - p.pattern = line - patterns = append(patterns, p) - } - - // Always ignore .git directory - patterns = append([]gitignorePattern{{pattern: ".git", dirOnly: true}}, patterns...) - return patterns -} - -// matchesGitignore checks if a relative path matches any gitignore pattern. -// Last matching pattern wins (same as git semantics). -func matchesGitignore(relPath string, isDir bool, patterns []gitignorePattern) bool { - if len(patterns) == 0 { - return false - } - ignored := false - basename := path.Base(relPath) - for _, p := range patterns { - if p.dirOnly && !isDir { - continue - } - // Match against basename or full relative path - matched := false - if strings.Contains(p.pattern, "/") { - // Pattern with slash — match against full path - matched, _ = filepath.Match(p.pattern, relPath) - } else { - // Pattern without slash — match against basename - matched, _ = filepath.Match(p.pattern, basename) - if !matched { - // Also try against full path for prefix directory matches - matched, _ = filepath.Match(p.pattern, relPath) - } - } - if matched { - ignored = !p.negate - } - } - return ignored -} diff --git a/server/workspace/fs_test.go b/server/workspace/fs_test.go deleted file mode 100644 index 43bf210..0000000 --- a/server/workspace/fs_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package workspace - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "switchboard-core/models" -) - -// ── Path Cleaning Tests ───────────────────── - -func TestCleanPath(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"src/main.go", "src/main.go"}, - {"/src/main.go", "src/main.go"}, - {"./src/main.go", "src/main.go"}, - {"src/../etc/passwd", "etc/passwd"}, - {"../etc/passwd", ""}, - {"../..", ""}, - {"..", ""}, - {".", ""}, - {"", ""}, - {" src/main.go ", "src/main.go"}, - {"src/./main.go", "src/main.go"}, - {".gitignore", ".gitignore"}, - {".env", ".env"}, - {"src/.hidden/file.go", "src/.hidden/file.go"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := cleanPath(tt.input) - if got != tt.want { - t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestAbsPathTraversal(t *testing.T) { - tmpDir := t.TempDir() - fs := &FS{basePath: tmpDir} - - w := &models.Workspace{ - BaseModel: models.BaseModel{ID: "test-workspace"}, - } - - // Create the files dir so absPath has a real root to resolve against - filesDir := filepath.Join(tmpDir, w.ID, filesSubdir) - os.MkdirAll(filesDir, 0750) - - // Valid paths should work - validPaths := []string{ - "src/main.go", - "README.md", - ".gitignore", - "deeply/nested/path/file.txt", - } - for _, p := range validPaths { - abs, err := fs.absPath(w, p) - if err != nil { - t.Errorf("absPath(%q) unexpected error: %v", p, err) - continue - } - if !strings.HasPrefix(abs, filesDir) { - t.Errorf("absPath(%q) = %s, not under %s", p, abs, filesDir) - } - } - - // Traversal attempts should fail - traversalPaths := []string{ - "../../../etc/passwd", - "src/../../etc/passwd", - } - for _, p := range traversalPaths { - _, err := fs.absPath(w, p) - if err == nil { - t.Errorf("absPath(%q) should have returned error for traversal", p) - } - } -} - -// ── Content Type Detection Tests ──────────── - -func TestMimeByExtension(t *testing.T) { - tests := []struct { - ext string - want string - }{ - {".go", "text/x-go"}, - {".py", "text/x-python"}, - {".rs", "text/x-rust"}, - {".ts", "text/typescript"}, - {".sh", "text/x-shellscript"}, - {".sql", "text/x-sql"}, - {".yaml", "text/yaml"}, - {".yml", "text/yaml"}, - {".toml", "application/toml"}, - {".pl", "text/x-perl"}, - {".pm", "text/x-perl"}, - {".lua", "text/x-lua"}, - {".dockerfile", "text/x-dockerfile"}, - {".unknown", ""}, - } - - for _, tt := range tests { - t.Run(tt.ext, func(t *testing.T) { - got := mimeByExtension(tt.ext) - if got != tt.want { - t.Errorf("mimeByExtension(%q) = %q, want %q", tt.ext, got, tt.want) - } - }) - } -} - -// ── Unsafe Path Tests ─────────────────────── - -func TestIsUnsafePath(t *testing.T) { - tests := []struct { - path string - unsafe bool - }{ - {"src/main.go", false}, - {".gitignore", false}, - {".env", false}, - {"../etc/passwd", true}, - {"/etc/passwd", true}, - {"src/../../../etc/passwd", true}, - {"\\windows\\system32", true}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - got := isUnsafePath(tt.path) - if got != tt.unsafe { - t.Errorf("isUnsafePath(%q) = %v, want %v", tt.path, got, tt.unsafe) - } - }) - } -} - -// ── Common Prefix Detection Tests ─────────── - -func TestDetectCommonPrefix(t *testing.T) { - tests := []struct { - name string - names []string - want string - }{ - { - name: "common prefix", - names: []string{"project/src/a.go", "project/src/b.go", "project/README.md"}, - want: "project/", - }, - { - name: "no common prefix", - names: []string{"src/a.go", "lib/b.go"}, - want: "", - }, - { - name: "file at root", - names: []string{"project/src/a.go", "Makefile"}, - want: "", - }, - { - name: "empty", - names: []string{}, - want: "", - }, - { - name: "single dir", - names: []string{"myproject/file.txt"}, - want: "myproject/", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := detectCommonPrefix(tt.names) - if got != tt.want { - t.Errorf("detectCommonPrefix(%v) = %q, want %q", tt.names, got, tt.want) - } - }) - } -} - -// ── Integration: Write + Read Round-Trip ──── - -type mockWorkspaceStore struct { - files map[string]*models.WorkspaceFile -} - -func newMockStore() *mockWorkspaceStore { - return &mockWorkspaceStore{files: make(map[string]*models.WorkspaceFile)} -} - -func (m *mockWorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { return nil } -func (m *mockWorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) { - return nil, nil -} -func (m *mockWorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error { - return nil -} -func (m *mockWorkspaceStore) Delete(ctx context.Context, id string) error { return nil } -func (m *mockWorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) { - return nil, nil -} -func (m *mockWorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) { - return nil, nil -} -func (m *mockWorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) { - return nil, nil -} - -func (m *mockWorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error { - key := f.WorkspaceID + ":" + f.Path - m.files[key] = f - return nil -} -func (m *mockWorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error { - delete(m.files, workspaceID+":"+path) - return nil -} -func (m *mockWorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error { - for k := range m.files { - if strings.HasPrefix(k, workspaceID+":"+prefix) { - delete(m.files, k) - } - } - return nil -} -func (m *mockWorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) { - f, ok := m.files[workspaceID+":"+path] - if !ok { - return nil, os.ErrNotExist - } - return f, nil -} -func (m *mockWorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) { - var out []models.WorkspaceFile - for _, f := range m.files { - if f.WorkspaceID == workspaceID { - if prefix == "" || strings.HasPrefix(f.Path, prefix) { - out = append(out, *f) - } - } - } - return out, nil -} -func (m *mockWorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error { - for k, f := range m.files { - if f.WorkspaceID == workspaceID { - delete(m.files, k) - } - } - return nil -} - -// Chunk methods (v0.21.2) — no-ops for FS tests -func (m *mockWorkspaceStore) InsertChunks(ctx context.Context, chunks []models.WorkspaceChunk) error { - return nil -} -func (m *mockWorkspaceStore) DeleteChunksByFile(ctx context.Context, fileID string) error { - return nil -} -func (m *mockWorkspaceStore) SimilaritySearch(ctx context.Context, workspaceID string, queryVec []float64, threshold float64, limit int) ([]models.WorkspaceChunkResult, error) { - return nil, nil -} -func (m *mockWorkspaceStore) UpdateFileIndexStatus(ctx context.Context, fileID, status string, chunkCount int) error { - return nil -} -func (m *mockWorkspaceStore) SetGitLastSync(ctx context.Context, workspaceID string, t time.Time) error { - return nil -} - -func TestWriteReadRoundTrip(t *testing.T) { - tmpDir := t.TempDir() - mock := newMockStore() - wfs := NewFS(tmpDir, mock) - - w := &models.Workspace{ - BaseModel: models.BaseModel{ID: "test-ws"}, - Status: "active", - } - if err := wfs.CreateDir(w); err != nil { - t.Fatalf("CreateDir: %v", err) - } - - ctx := context.Background() - content := "package main\n\nfunc main() {}\n" - - // Write - err := wfs.WriteFile(ctx, w, "src/main.go", strings.NewReader(content), int64(len(content))) - if err != nil { - t.Fatalf("WriteFile: %v", err) - } - - // Verify DB index was updated - f, ok := mock.files["test-ws:src/main.go"] - if !ok { - t.Fatal("expected file in mock store index") - } - if f.ContentType != "text/x-go" { - t.Errorf("content_type = %q, want %q", f.ContentType, "text/x-go") - } - if f.SizeBytes != int64(len(content)) { - t.Errorf("size_bytes = %d, want %d", f.SizeBytes, len(content)) - } - if f.SHA256 == "" { - t.Error("expected non-empty sha256") - } - - // Read back - rc, size, err := wfs.ReadFile(ctx, w, "src/main.go") - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - defer rc.Close() - - if size != int64(len(content)) { - t.Errorf("read size = %d, want %d", size, len(content)) - } - - buf := make([]byte, size) - if _, err := rc.Read(buf); err != nil { - t.Fatalf("Read: %v", err) - } - if string(buf) != content { - t.Errorf("content = %q, want %q", string(buf), content) - } -} - -func TestDeleteFile(t *testing.T) { - tmpDir := t.TempDir() - mock := newMockStore() - wfs := NewFS(tmpDir, mock) - - w := &models.Workspace{ - BaseModel: models.BaseModel{ID: "test-ws"}, - } - wfs.CreateDir(w) - - ctx := context.Background() - content := "hello" - wfs.WriteFile(ctx, w, "test.txt", strings.NewReader(content), int64(len(content))) - - // Delete - err := wfs.DeleteFile(ctx, w, "test.txt", false) - if err != nil { - t.Fatalf("DeleteFile: %v", err) - } - - // Verify removed from index - if _, ok := mock.files["test-ws:test.txt"]; ok { - t.Error("expected file removed from index") - } - - // Verify removed from filesystem - abs := filepath.Join(tmpDir, w.ID, filesSubdir, "test.txt") - if _, err := os.Stat(abs); !os.IsNotExist(err) { - t.Error("expected file removed from filesystem") - } -} - -func TestMkdir(t *testing.T) { - tmpDir := t.TempDir() - mock := newMockStore() - wfs := NewFS(tmpDir, mock) - - w := &models.Workspace{ - BaseModel: models.BaseModel{ID: "test-ws"}, - } - wfs.CreateDir(w) - - ctx := context.Background() - err := wfs.Mkdir(ctx, w, "src/pkg/handlers") - if err != nil { - t.Fatalf("Mkdir: %v", err) - } - - // Verify on disk - abs := filepath.Join(tmpDir, w.ID, filesSubdir, "src/pkg/handlers") - info, err := os.Stat(abs) - if err != nil { - t.Fatalf("stat: %v", err) - } - if !info.IsDir() { - t.Error("expected directory") - } - - // Verify in index - f, ok := mock.files["test-ws:src/pkg/handlers"] - if !ok { - t.Fatal("expected dir in mock store") - } - if !f.IsDirectory { - t.Error("expected is_directory = true") - } -} diff --git a/server/workspace/gitops.go b/server/workspace/gitops.go deleted file mode 100644 index 48e8837..0000000 --- a/server/workspace/gitops.go +++ /dev/null @@ -1,585 +0,0 @@ -package workspace - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "log" - "net/url" - "os" - "os/exec" - "strconv" - "strings" - "time" - - "switchboard-core/crypto" - "switchboard-core/models" - "switchboard-core/store" -) - -// GitOps provides git operations for workspaces via exec. -// Credentials are decrypted from the vault and injected via temporary -// files or environment variables for the duration of each operation. -type GitOps struct { - fs *FS - stores store.Stores - keyResolver *crypto.KeyResolver - indexer *Indexer // may be nil -} - -// NewGitOps creates a new git operations handler. -func NewGitOps(fs *FS, stores store.Stores, resolver *crypto.KeyResolver, indexer *Indexer) *GitOps { - return &GitOps{ - fs: fs, - stores: stores, - keyResolver: resolver, - indexer: indexer, - } -} - -// ── Clone ──────────────────────────────────── - -// Clone clones a remote repository into the workspace. -func (g *GitOps) Clone(ctx context.Context, w *models.Workspace, remoteURL, branch, credID string) error { - if err := validateRemoteURL(remoteURL); err != nil { - return err - } - - dir := g.fs.workspacePath(w) - - args := []string{"clone", "--single-branch"} - if branch != "" { - args = append(args, "--branch", branch) - } - args = append(args, remoteURL, ".") - - env, cleanup, err := g.credEnv(ctx, credID, remoteURL) - if err != nil { - return fmt.Errorf("git clone: credential setup: %w", err) - } - defer cleanup() - - if _, err := g.run(ctx, dir, env, args...); err != nil { - return fmt.Errorf("git clone: %w", err) - } - - // Update workspace with git metadata - now := time.Now().UTC() - patch := models.WorkspacePatch{ - GitRemoteURL: &remoteURL, - GitCredentialID: &credID, - } - if branch != "" { - patch.GitBranch = &branch - } else { - // Detect default branch - if out, err := g.run(ctx, dir, nil, "rev-parse", "--abbrev-ref", "HEAD"); err == nil { - b := strings.TrimSpace(out) - patch.GitBranch = &b - } - } - if err := g.stores.Workspaces.Update(ctx, w.ID, patch); err != nil { - return fmt.Errorf("git clone: update workspace: %w", err) - } - g.updateSyncTime(ctx, w.ID, now) - - // Post-operation: reconcile + re-index - g.postOpHook(ctx, w) - - return nil -} - -// ── Pull ───────────────────────────────────── - -func (g *GitOps) Pull(ctx context.Context, w *models.Workspace) error { - dir := g.fs.workspacePath(w) - env, cleanup, err := g.credEnvFromWorkspace(ctx, w) - if err != nil { - return fmt.Errorf("git pull: %w", err) - } - defer cleanup() - - if _, err := g.run(ctx, dir, env, "pull", "--ff-only"); err != nil { - return fmt.Errorf("git pull: %w", err) - } - - g.updateSyncTime(ctx, w.ID, time.Now().UTC()) - g.postOpHook(ctx, w) - return nil -} - -// ── Push ───────────────────────────────────── - -func (g *GitOps) Push(ctx context.Context, w *models.Workspace) error { - dir := g.fs.workspacePath(w) - env, cleanup, err := g.credEnvFromWorkspace(ctx, w) - if err != nil { - return fmt.Errorf("git push: %w", err) - } - defer cleanup() - - if _, err := g.run(ctx, dir, env, "push"); err != nil { - return fmt.Errorf("git push: %w", err) - } - - g.updateSyncTime(ctx, w.ID, time.Now().UTC()) - return nil -} - -// ── Status ─────────────────────────────────── - -func (g *GitOps) Status(ctx context.Context, w *models.Workspace) (*models.GitStatus, error) { - dir := g.fs.workspacePath(w) - - // Branch name - branchOut, err := g.run(ctx, dir, nil, "rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - return nil, fmt.Errorf("git status: %w", err) - } - - // Porcelain status - out, err := g.run(ctx, dir, nil, "status", "--porcelain=v1", "-b") - if err != nil { - return nil, fmt.Errorf("git status: %w", err) - } - - status := &models.GitStatus{ - Branch: strings.TrimSpace(branchOut), - } - - lines := strings.Split(strings.TrimSpace(out), "\n") - for _, line := range lines { - if len(line) == 0 { - continue - } - // Branch line: ## main...origin/main [ahead 1, behind 2] - if strings.HasPrefix(line, "## ") { - if idx := strings.Index(line, "["); idx > 0 { - bracket := line[idx:] - if n := parseAheadBehind(bracket, "ahead"); n > 0 { - status.Ahead = n - } - if n := parseAheadBehind(bracket, "behind"); n > 0 { - status.Behind = n - } - } - continue - } - if len(line) < 4 { - continue - } - x := line[0] // index status - y := line[1] // working tree status - path := strings.TrimSpace(line[3:]) - - if x == '?' && y == '?' { - status.Untracked = append(status.Untracked, path) - } else if x != ' ' && x != '?' { - status.Staged = append(status.Staged, models.GitFileStatus{Path: path, Status: string(x)}) - } - if y != ' ' && y != '?' { - status.Modified = append(status.Modified, models.GitFileStatus{Path: path, Status: string(y)}) - } - } - - status.Clean = len(status.Staged) == 0 && len(status.Modified) == 0 && len(status.Untracked) == 0 - return status, nil -} - -// ── Diff ───────────────────────────────────── - -func (g *GitOps) Diff(ctx context.Context, w *models.Workspace, path string) (string, error) { - dir := g.fs.workspacePath(w) - args := []string{"diff"} - if path != "" { - args = append(args, "--", path) - } - out, err := g.run(ctx, dir, nil, args...) - if err != nil { - return "", fmt.Errorf("git diff: %w", err) - } - return out, nil -} - -// ── Commit ─────────────────────────────────── - -func (g *GitOps) Commit(ctx context.Context, w *models.Workspace, message string, paths []string) error { - dir := g.fs.workspacePath(w) - - // Stage files - if len(paths) == 0 { - if _, err := g.run(ctx, dir, nil, "add", "-A"); err != nil { - return fmt.Errorf("git add: %w", err) - } - } else { - args := append([]string{"add", "--"}, paths...) - if _, err := g.run(ctx, dir, nil, args...); err != nil { - return fmt.Errorf("git add: %w", err) - } - } - - // Commit - if _, err := g.run(ctx, dir, nil, "commit", "-m", message); err != nil { - return fmt.Errorf("git commit: %w", err) - } - - return nil -} - -// ── Log ────────────────────────────────────── - -func (g *GitOps) Log(ctx context.Context, w *models.Workspace, n int) ([]models.GitLogEntry, error) { - dir := g.fs.workspacePath(w) - if n <= 0 { - n = 20 - } - - // JSON-like format for reliable parsing - format := `{"hash":"%H","short_hash":"%h","author":"%an","date":"%aI","message":"%s"}` - out, err := g.run(ctx, dir, nil, "log", fmt.Sprintf("-n%d", n), fmt.Sprintf("--format=%s", format)) - if err != nil { - return nil, fmt.Errorf("git log: %w", err) - } - - var entries []models.GitLogEntry - scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(out))) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - var entry models.GitLogEntry - if err := json.Unmarshal([]byte(line), &entry); err != nil { - continue // skip malformed - } - entries = append(entries, entry) - } - return entries, nil -} - -// ── Branch ─────────────────────────────────── - -func (g *GitOps) BranchList(ctx context.Context, w *models.Workspace) ([]string, string, error) { - dir := g.fs.workspacePath(w) - out, err := g.run(ctx, dir, nil, "branch", "-a", "--format=%(refname:short)%(if)%(HEAD)%(then) *%(end)") - if err != nil { - return nil, "", fmt.Errorf("git branch: %w", err) - } - - var branches []string - var current string - for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - if line == "" { - continue - } - if strings.HasSuffix(line, " *") { - name := strings.TrimSuffix(line, " *") - current = name - branches = append(branches, name) - } else { - branches = append(branches, strings.TrimSpace(line)) - } - } - return branches, current, nil -} - -// Checkout switches to a branch. -func (g *GitOps) Checkout(ctx context.Context, w *models.Workspace, branch string) error { - dir := g.fs.workspacePath(w) - if _, err := g.run(ctx, dir, nil, "checkout", branch); err != nil { - return fmt.Errorf("git checkout: %w", err) - } - - // Update workspace branch - patch := models.WorkspacePatch{GitBranch: &branch} - if err := g.stores.Workspaces.Update(ctx, w.ID, patch); err != nil { - log.Printf("git: failed to update workspace branch: %v", err) - } - - g.postOpHook(ctx, w) - return nil -} - -// ── Internals ──────────────────────────────── - -// run executes a git command in the given directory with optional env vars. -func (g *GitOps) run(ctx context.Context, dir string, env []string, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir - - // Base environment + any credential env vars - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") - if len(env) > 0 { - cmd.Env = append(cmd.Env, env...) - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if err != nil { - // Include stderr for diagnostics but sanitize credentials - errMsg := sanitizeOutput(stderr.String()) - return "", fmt.Errorf("%s: %s", err, errMsg) - } - return stdout.String(), nil -} - -// credEnv sets up credential environment for a git operation. -// Returns env vars, a cleanup function, and any error. -func (g *GitOps) credEnv(ctx context.Context, credID, remoteURL string) ([]string, func(), error) { - noop := func() {} - if credID == "" { - return nil, noop, nil - } - - cred, err := g.stores.GitCredentials.GetByID(ctx, credID) - if err != nil { - return nil, noop, fmt.Errorf("credential not found: %w", err) - } - - // Decrypt credential data - plaintext, err := g.keyResolver.Decrypt(cred.EncryptedData, cred.Nonce, "global", cred.UserID) - if err != nil { - return nil, noop, fmt.Errorf("credential decryption failed: %w", err) - } - - var data map[string]string - if err := json.Unmarshal([]byte(plaintext), &data); err != nil { - return nil, noop, fmt.Errorf("credential data invalid: %w", err) - } - - switch cred.AuthType { - case "https_pat": - return g.setupHTTPSPAT(data, remoteURL) - case "https_basic": - return g.setupHTTPSBasic(data, remoteURL) - case "ssh_key": - return g.setupSSHKey(data) - default: - return nil, noop, fmt.Errorf("unsupported auth type: %s", cred.AuthType) - } -} - -func (g *GitOps) credEnvFromWorkspace(ctx context.Context, w *models.Workspace) ([]string, func(), error) { - credID := "" - if w.GitCredentialID != nil { - credID = *w.GitCredentialID - } - remoteURL := "" - if w.GitRemoteURL != nil { - remoteURL = *w.GitRemoteURL - } - return g.credEnv(ctx, credID, remoteURL) -} - -// setupHTTPSPAT creates a GIT_ASKPASS script that echoes the PAT. -func (g *GitOps) setupHTTPSPAT(data map[string]string, remoteURL string) ([]string, func(), error) { - token := data["token"] - if token == "" { - return nil, func() {}, fmt.Errorf("PAT token is empty") - } - - // Create temp askpass script - f, err := os.CreateTemp("", "git-askpass-*.sh") - if err != nil { - return nil, func() {}, err - } - - script := fmt.Sprintf("#!/bin/sh\necho '%s'\n", strings.ReplaceAll(token, "'", "'\\''")) - f.WriteString(script) - f.Close() - os.Chmod(f.Name(), 0700) - - cleanup := func() { - os.Remove(f.Name()) - } - - env := []string{ - "GIT_ASKPASS=" + f.Name(), - } - return env, cleanup, nil -} - -// setupHTTPSBasic creates a temporary .git-credentials file. -func (g *GitOps) setupHTTPSBasic(data map[string]string, remoteURL string) ([]string, func(), error) { - username := data["username"] - password := data["password"] - if username == "" || password == "" { - return nil, func() {}, fmt.Errorf("basic auth credentials incomplete") - } - - // Parse remote URL to extract host - parsed, err := url.Parse(remoteURL) - if err != nil { - return nil, func() {}, fmt.Errorf("invalid remote URL: %w", err) - } - - credURL := fmt.Sprintf("%s://%s:%s@%s", parsed.Scheme, - url.PathEscape(username), url.PathEscape(password), parsed.Host) - - f, err := os.CreateTemp("", "git-creds-*.txt") - if err != nil { - return nil, func() {}, err - } - f.WriteString(credURL + "\n") - f.Close() - os.Chmod(f.Name(), 0600) - - cleanup := func() { - os.Remove(f.Name()) - } - - env := []string{ - fmt.Sprintf("GIT_CONFIG_COUNT=2"), - fmt.Sprintf("GIT_CONFIG_KEY_0=credential.helper"), - fmt.Sprintf("GIT_CONFIG_VALUE_0=store --file=%s", f.Name()), - fmt.Sprintf("GIT_CONFIG_KEY_1=credential.helper"), - fmt.Sprintf("GIT_CONFIG_VALUE_1="), - } - return env, cleanup, nil -} - -// setupSSHKey creates a temporary SSH key file and GIT_SSH_COMMAND. -func (g *GitOps) setupSSHKey(data map[string]string) ([]string, func(), error) { - privateKey := data["private_key"] - if privateKey == "" { - return nil, func() {}, fmt.Errorf("SSH private key is empty") - } - - f, err := os.CreateTemp("", "git-ssh-key-*") - if err != nil { - return nil, func() {}, err - } - f.WriteString(privateKey) - if !strings.HasSuffix(privateKey, "\n") { - f.WriteString("\n") - } - f.Close() - os.Chmod(f.Name(), 0600) - - cleanup := func() { - os.Remove(f.Name()) - } - - sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes", f.Name()) - - env := []string{ - "GIT_SSH_COMMAND=" + sshCmd, - } - return env, cleanup, nil -} - -// validateRemoteURL rejects file:// and local path URLs. -func validateRemoteURL(u string) error { - if u == "" { - return fmt.Errorf("remote URL is required") - } - lower := strings.ToLower(u) - if strings.HasPrefix(lower, "file://") || strings.HasPrefix(lower, "/") || strings.HasPrefix(lower, ".") { - return fmt.Errorf("local filesystem URLs are not allowed") - } - return nil -} - -// sanitizeOutput removes potential credential leaks from error messages. -func sanitizeOutput(s string) string { - // Strip lines containing tokens/passwords - var lines []string - for _, line := range strings.Split(s, "\n") { - lower := strings.ToLower(line) - if strings.Contains(lower, "password") || strings.Contains(lower, "token") || - strings.Contains(lower, "credential") { - continue - } - lines = append(lines, line) - } - result := strings.Join(lines, "\n") - if len(result) > 500 { - result = result[:500] + "..." - } - return strings.TrimSpace(result) -} - -func parseAheadBehind(bracket, key string) int { - idx := strings.Index(bracket, key) - if idx < 0 { - return 0 - } - rest := bracket[idx+len(key):] - rest = strings.TrimLeft(rest, " ") - num := "" - for _, c := range rest { - if c >= '0' && c <= '9' { - num += string(c) - } else { - break - } - } - n, _ := strconv.Atoi(num) - return n -} - -// updateSyncTime sets git_last_sync on the workspace. -func (g *GitOps) updateSyncTime(ctx context.Context, wsID string, t time.Time) { - if err := g.stores.Workspaces.SetGitLastSync(ctx, wsID, t); err != nil { - log.Printf("git: failed to update sync time for %s: %v", wsID, err) - } -} - -// postOpHook triggers workspace reconcile and re-indexing after git operations -// that change the working tree (clone, pull, checkout). -func (g *GitOps) postOpHook(ctx context.Context, w *models.Workspace) { - // Reconcile: sync filesystem → database file index - added, removed, updated, err := g.fs.Reconcile(ctx, w) - if err != nil { - log.Printf("git: post-op reconcile failed for %s: %v", w.ID, err) - return - } - log.Printf("git: post-op reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated) - - // Re-index if indexer is configured and there are changes - if g.indexer != nil && g.indexer.IsEnabled(ctx) && (added+updated) > 0 { - files, err := g.stores.Workspaces.ListFiles(ctx, w.ID, "", true) - if err != nil { - log.Printf("git: post-op index list failed: %v", err) - return - } - - // Resolve userID/teamID from workspace owner - var userID string - var teamID *string - switch w.OwnerType { - case "user": - userID = w.OwnerID - case "team": - teamID = &w.OwnerID - default: - userID = w.OwnerID - } - - g.indexer.IndexBatch(w, files, userID, teamID) - } -} - -// workspacePath returns the filesystem path for git operations on a workspace. -// This is the files directory where the working tree lives. -func (fs *FS) workspacePath(w *models.Workspace) string { - return fs.filesDir(w) -} - -// LoadWorkspace fetches a workspace by ID and validates it has a git remote. -// Used by git tools to resolve workspace + check git configuration. -func (g *GitOps) LoadWorkspace(ctx context.Context, workspaceID string) (*models.Workspace, error) { - w, err := g.stores.Workspaces.GetByID(ctx, workspaceID) - if err != nil { - return nil, fmt.Errorf("workspace not found: %w", err) - } - if w.GitRemoteURL == nil || *w.GitRemoteURL == "" { - return nil, fmt.Errorf("workspace has no git remote configured") - } - return w, nil -} diff --git a/server/workspace/indexer.go b/server/workspace/indexer.go deleted file mode 100644 index db23dc5..0000000 --- a/server/workspace/indexer.go +++ /dev/null @@ -1,355 +0,0 @@ -package workspace - -import ( - "context" - "fmt" - "io" - "log" - "path/filepath" - "strings" - "sync" - "time" - - "switchboard-core/knowledge" - "switchboard-core/models" - "switchboard-core/notifications" - "switchboard-core/store" -) - -// ── Configuration ──────────────────────────── - -const ( - // indexTimeout is the max time for indexing a single file. - indexTimeout = 5 * time.Minute - - // maxIndexTextLength caps text extracted from a single file (5 MB). - maxIndexTextLength = 5 * 1024 * 1024 -) - -// indexableExtensions defines source code and text file extensions -// eligible for chunking and embedding. MIME detection covers text/*, -// but code files often have application/* types — this map catches them. -var indexableExtensions = map[string]bool{ - ".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, - ".tsx": true, ".rs": true, ".c": true, ".cpp": true, ".h": true, - ".hpp": true, ".java": true, ".rb": true, ".php": true, ".swift": true, - ".kt": true, ".scala": true, ".sh": true, ".bash": true, ".zsh": true, - ".sql": true, ".yaml": true, ".yml": true, ".toml": true, ".json": true, - ".xml": true, ".css": true, ".scss": true, ".less": true, - ".md": true, ".txt": true, ".csv": true, ".html": true, ".htm": true, - ".dockerfile": true, ".tf": true, ".hcl": true, ".proto": true, - ".graphql": true, ".vue": true, ".svelte": true, - ".pl": true, ".pm": true, // Perl - ".lua": true, ".zig": true, ".nim": true, ".ex": true, ".exs": true, - // Config / data - ".ini": true, ".cfg": true, ".conf": true, ".env": true, - ".mk": true, ".cmake": true, ".makefile": true, - ".r": true, ".jl": true, // R, Julia -} - -// CodeChunkConfig returns chunking settings optimized for source code. -// Larger chunks (1500 chars) because functions can be long, with -// separators tuned to split on function/class/type boundaries. -func CodeChunkConfig() knowledge.ChunkConfig { - return knowledge.ChunkConfig{ - ChunkSize: 1500, - ChunkOverlap: 200, - Separators: []string{ - "\n\nfunc ", "\n\nfn ", // Go, Rust - "\n\nclass ", "\n\ndef ", // Python, Ruby - "\n\ntype ", "\n\nsub ", // Go types, Perl - "\n\npub ", "\n\nimpl ", // Rust - "\n\n", "\n", " ", // fallback hierarchy - }, - } -} - -// isIndexable returns true if the file is eligible for text indexing. -func isIndexable(path string, contentType string) bool { - // Check extension first (catches source code with non-text MIME types) - ext := strings.ToLower(filepath.Ext(path)) - if indexableExtensions[ext] { - return true - } - // Also index any text/* content type - if strings.HasPrefix(contentType, "text/") { - return true - } - // Specific filenames without extension - base := strings.ToLower(filepath.Base(path)) - switch base { - case "makefile", "dockerfile", "jenkinsfile", "gemfile", - "rakefile", "procfile", "vagrantfile", ".gitignore", - ".dockerignore", ".editorconfig": - return true - } - return false -} - -// isCodeFile returns true if the file is a source code file (uses code chunking). -func isCodeFile(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".md", ".txt", ".csv", ".html", ".htm", ".xml", ".json", ".yaml", ".yml": - return false // prose / data — use default chunking - } - return indexableExtensions[ext] -} - -// ── Indexer ────────────────────────────────── - -// Indexer processes workspace files through the chunking + embedding pipeline. -// It reuses the knowledge.Embedder for vector generation and shares the -// concurrency semaphore with KB ingestion to avoid overwhelming the -// embedding provider. -type Indexer struct { - stores store.Stores - embedder *knowledge.Embedder - wfs *FS - sem chan struct{} // shared semaphore with KB ingestion - wg sync.WaitGroup - enabled bool // global kill switch (WORKSPACE_INDEXING_ENABLED) -} - -// NewIndexer creates a workspace indexer. -// The semaphore should be shared with the KB Ingester to cap total -// embedding concurrency. -func NewIndexer(stores store.Stores, embedder *knowledge.Embedder, wfs *FS, sem chan struct{}, enabled bool) *Indexer { - return &Indexer{ - stores: stores, - embedder: embedder, - wfs: wfs, - sem: sem, - enabled: enabled, - } -} - -// IsEnabled returns true if workspace indexing is globally enabled -// AND the embedding role is configured. -func (idx *Indexer) IsEnabled(ctx context.Context) bool { - return idx.enabled && idx.embedder.IsConfigured(ctx) -} - -// IndexFile starts async indexing for a single workspace file. -// Called from WorkspaceFS.WriteFile after a successful write. -// No-op if indexing is disabled, the file is non-indexable, or the -// sha256 hasn't changed (content-addressed skip). -func (idx *Indexer) IndexFile(w *models.Workspace, f *models.WorkspaceFile, userID string, teamID *string) { - if !idx.enabled || !w.IndexingEnabled { - return - } - - // Check if indexable - if f.IsDirectory || !isIndexable(f.Path, f.ContentType) { - // Mark as skipped so the UI knows it's intentional - ctx := context.Background() - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusSkipped, 0) - return - } - - // Mark as pending - ctx := context.Background() - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusPending, 0) - - idx.wg.Add(1) - go func() { - defer idx.wg.Done() - - // Acquire semaphore - idx.sem <- struct{}{} - defer func() { <-idx.sem }() - - ictx, cancel := context.WithTimeout(context.Background(), indexTimeout) - defer cancel() - - if err := idx.indexFile(ictx, w, f, userID, teamID); err != nil { - log.Printf("❌ workspace index %s/%s: %v", w.ID[:8], f.Path, err) - idx.stores.Workspaces.UpdateFileIndexStatus(ictx, f.ID, models.IndexStatusError, 0) - } - }() -} - -// IndexBatch starts async indexing for multiple files (e.g. after archive extract). -// Files are indexed sequentially within a single goroutine to be friendly -// to rate limits. -func (idx *Indexer) IndexBatch(w *models.Workspace, files []models.WorkspaceFile, userID string, teamID *string) { - if !idx.enabled || !w.IndexingEnabled { - return - } - - // Filter to indexable files only - var indexable []models.WorkspaceFile - ctx := context.Background() - for _, f := range files { - if f.IsDirectory || !isIndexable(f.Path, f.ContentType) { - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusSkipped, 0) - continue - } - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusPending, 0) - indexable = append(indexable, f) - } - - if len(indexable) == 0 { - return - } - - idx.wg.Add(1) - go func() { - defer idx.wg.Done() - - // One semaphore slot for the entire batch - idx.sem <- struct{}{} - defer func() { <-idx.sem }() - - var indexed, errors int - for _, f := range indexable { - f := f - ictx, cancel := context.WithTimeout(context.Background(), indexTimeout) - if err := idx.indexFile(ictx, w, &f, userID, teamID); err != nil { - log.Printf("❌ workspace index %s/%s: %v", w.ID[:8], f.Path, err) - idx.stores.Workspaces.UpdateFileIndexStatus(ictx, f.ID, models.IndexStatusError, 0) - errors++ - } else { - indexed++ - } - cancel() - } - - log.Printf("📑 workspace batch index %s: %d indexed, %d errors, %d skipped", - w.ID[:8], indexed, errors, len(files)-len(indexable)) - - // Notify on completion - if svc := notifications.Default(); svc != nil && indexed > 0 { - // Find workspace owner for notification target - if w.OwnerType == models.WorkspaceOwnerUser { - svc.Notify(context.Background(), &models.Notification{ - UserID: w.OwnerID, - Type: "workspace.indexed", - Title: "Workspace indexed", - Body: fmt.Sprintf("%d files indexed in workspace %q", indexed, w.Name), - ResourceType: "workspace", - ResourceID: w.ID, - }) - } - } - }() -} - -// Wait blocks until all in-flight indexing goroutines complete. -func (idx *Indexer) Wait() { - idx.wg.Wait() -} - -// ── Internal Pipeline ──────────────────────── - -func (idx *Indexer) indexFile(ctx context.Context, w *models.Workspace, f *models.WorkspaceFile, userID string, teamID *string) error { - start := time.Now() - - // Mark as indexing - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusIndexing, 0) - - // Check if embedding is configured (might have changed since enqueue) - if !idx.embedder.IsConfigured(ctx) { - return fmt.Errorf("embedding role not configured") - } - - // ── Step 1: Read file content ─────────── - rc, _, err := idx.wfs.ReadFile(ctx, w, f.Path) - if err != nil { - return fmt.Errorf("read: %w", err) - } - defer rc.Close() - - // Cap at max text length - limited := io.LimitReader(rc, maxIndexTextLength) - data, err := io.ReadAll(limited) - if err != nil { - return fmt.Errorf("read content: %w", err) - } - text := string(data) - - if strings.TrimSpace(text) == "" { - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusSkipped, 0) - return nil - } - - // ── Step 2: Chunk ─────────────────────── - var cfg knowledge.ChunkConfig - if isCodeFile(f.Path) { - cfg = CodeChunkConfig() - } else { - cfg = knowledge.DefaultChunkConfig() - } - - chunks := knowledge.SplitText(text, cfg) - if len(chunks) == 0 { - idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusSkipped, 0) - return nil - } - - // ── Step 3: Embed ─────────────────────── - texts := make([]string, len(chunks)) - for i, c := range chunks { - texts[i] = c.Content - } - - embedResult, err := idx.embedder.EmbedChunks(ctx, userID, teamID, texts) - if err != nil { - return fmt.Errorf("embed: %w", err) - } - - // Track usage - idx.embedder.LogUsage(ctx, userID, nil, embedResult) - - // ── Step 4: Delete old chunks + insert new ─ - if err := idx.stores.Workspaces.DeleteChunksByFile(ctx, f.ID); err != nil { - return fmt.Errorf("delete old chunks: %w", err) - } - - dbChunks := make([]models.WorkspaceChunk, len(chunks)) - for i, c := range chunks { - // Compute approximate line number from character offset - lineStart := 0 - if i > 0 { - charOffset := i * (cfg.ChunkSize - cfg.ChunkOverlap) - if charOffset < len(text) { - lineStart = strings.Count(text[:charOffset], "\n") + 1 - } - } - - dbChunks[i] = models.WorkspaceChunk{ - WorkspaceID: w.ID, - FileID: f.ID, - ChunkIndex: c.Index, - Content: c.Content, - TokenCount: c.TokenCount, - Embedding: embedResult.Vectors[i], - Metadata: models.JSONMap{ - "line_start": lineStart, - "language": inferLanguage(f.Path), - }, - } - } - - if err := idx.stores.Workspaces.InsertChunks(ctx, dbChunks); err != nil { - return fmt.Errorf("store chunks: %w", err) - } - - // ── Step 5: Update file index status ──── - if err := idx.stores.Workspaces.UpdateFileIndexStatus(ctx, f.ID, models.IndexStatusReady, len(chunks)); err != nil { - log.Printf("⚠ failed to update index status for %s: %v", f.Path, err) - } - - log.Printf(" 📑 indexed %s → %d chunks (%s)", f.Path, len(chunks), time.Since(start).Round(time.Millisecond)) - return nil -} - -// inferLanguage extracts a simple language identifier from file extension. -func inferLanguage(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - if ext == "" { - return "" - } - // Strip leading dot - return ext[1:] -}