step 5 (complete): docs purge, fresh ARCHITECTURE + ROADMAP + CHANGELOG
Purged 29,966 lines of stale chat-switchboard documentation: - 41 docs/ files (ICD specs, design docs, archive, workflow docs) - 5 root MD files (CHANGESET, TURNOVER, ICD-DRIFT-AUDIT, etc.) New documentation: - docs/ARCHITECTURE.md — kernel components, design principles, data layer - ROADMAP.md — v0.1.0 through v0.5.0 MVP with decision log - CHANGELOG.md — fresh, starting from v0.1.0 fork - README.md — rewritten for switchboard-core Also in this commit: - config.go: stripped 7 dropped fields, DB default → switchboard_core - pages/loaders.go: stripped provider/model/notes/projects loaders - pages/pages.go: stripped persona store lookup - handlers/workflows.go: stripped persona tool grants - main.go: stripped team provider routes, avatar routes Production code: zero references to deleted packages, stores, or models. -29,966/+352 lines across 73 files.
This commit is contained in:
@@ -25,11 +25,6 @@ type Config struct {
|
||||
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
|
||||
SeedUsers string
|
||||
|
||||
// Seed providers (dev/test only) — CSV: "provider:api_key[:name]"
|
||||
// Shortcuts: "openai:sk-xxx", "anthropic:sk-ant-xxx", "openrouter:sk-or-xxx"
|
||||
// Skips if provider with same name exists (idempotent on restart).
|
||||
SeedProviders string
|
||||
|
||||
// API key encryption (required for v0.9.4+)
|
||||
// Used to derive AES-256 key for global/team provider API keys.
|
||||
// Personal keys use per-user UEK (derived from password).
|
||||
@@ -51,37 +46,6 @@ type Config struct {
|
||||
S3Prefix string // optional key prefix within bucket (e.g. "switchboard/")
|
||||
S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted)
|
||||
|
||||
// Extraction pipeline (v0.12.0+)
|
||||
// EXTRACTION_MODE: "inline" (in-process) or "sidecar" (shared PVC watcher).
|
||||
// EXTRACTION_CONCURRENCY: max concurrent extraction jobs (default 3).
|
||||
ExtractionMode string
|
||||
ExtractionConcurrency int
|
||||
|
||||
// Workspace indexing (v0.21.2)
|
||||
// WORKSPACE_INDEXING_ENABLED: global kill switch for workspace file indexing (default true).
|
||||
// WORKSPACE_INDEX_CONCURRENCY: max concurrent indexing goroutines (default 2).
|
||||
WorkspaceIndexingEnabled bool
|
||||
WorkspaceIndexConcurrency int
|
||||
|
||||
// Provider auto-disable (v0.22.4)
|
||||
// PROVIDER_AUTO_DISABLE_THRESHOLD: consecutive "down" hourly windows before a
|
||||
// provider is automatically deactivated. Default 3. Set to 0 to disable.
|
||||
ProviderAutoDisableThreshold int
|
||||
|
||||
// Config-file provider types (v0.29.1)
|
||||
// PROVIDER_TYPES_FILE: path to JSON file defining custom provider types
|
||||
// (e.g., Ollama, LiteLLM, vLLM). Empty = no custom types.
|
||||
ProviderTypesFile string
|
||||
|
||||
// SESSION_EXPIRY_DAYS: anonymous sessions older than this with no messages
|
||||
// are cleaned up by the background session sweeper. Default 30. Set to 0
|
||||
// to disable cleanup.
|
||||
SessionExpiryDays int
|
||||
|
||||
// WORKFLOW_STALE_HOURS: workflow instances with no activity for this many
|
||||
// hours are marked 'stale'. Default 72 (3 days). Set to 0 to disable.
|
||||
WorkflowStaleHours int
|
||||
|
||||
// Structured logging (v0.33.0)
|
||||
// LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured).
|
||||
// LOG_LEVEL: "debug", "info" (default), "warn", "error".
|
||||
@@ -131,7 +95,6 @@ func Load() *Config {
|
||||
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
||||
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
||||
SeedUsers: getEnv("SEED_USERS", ""),
|
||||
SeedProviders: getEnv("SEED_PROVIDERS", ""),
|
||||
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
|
||||
|
||||
StorageBackend: getEnv("STORAGE_BACKEND", ""),
|
||||
@@ -143,19 +106,6 @@ func Load() *Config {
|
||||
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||
S3Prefix: getEnv("S3_PREFIX", ""),
|
||||
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
|
||||
ExtractionMode: getEnv("EXTRACTION_MODE", "inline"),
|
||||
ExtractionConcurrency: getEnvInt("EXTRACTION_CONCURRENCY", 3),
|
||||
|
||||
WorkspaceIndexingEnabled: getEnvBool("WORKSPACE_INDEXING_ENABLED", true),
|
||||
WorkspaceIndexConcurrency: getEnvInt("WORKSPACE_INDEX_CONCURRENCY", 2),
|
||||
|
||||
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
|
||||
|
||||
ProviderTypesFile: getEnv("PROVIDER_TYPES_FILE", ""),
|
||||
|
||||
SessionExpiryDays: getEnvInt("SESSION_EXPIRY_DAYS", 30),
|
||||
|
||||
WorkflowStaleHours: getEnvInt("WORKFLOW_STALE_HOURS", 72),
|
||||
|
||||
LogFormat: getEnv("LOG_FORMAT", "text"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
@@ -205,7 +155,7 @@ func resolveDatabaseURL() string {
|
||||
port := getEnv("POSTGRES_PORT", "5432")
|
||||
user := getEnv("POSTGRES_USER", "")
|
||||
pass := getEnv("POSTGRES_PASSWORD", "")
|
||||
db := getEnv("POSTGRES_DB", "chat_switchboard")
|
||||
db := getEnv("POSTGRES_DB", "switchboard_core")
|
||||
ssl := getEnv("POSTGRES_SSLMODE", "disable")
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, db, ssl)
|
||||
}
|
||||
|
||||
@@ -293,10 +293,6 @@ func (h *WorkflowHandler) Publish(c *gin.Context) {
|
||||
snapshotStages := make([]stageSnapshot, len(stages))
|
||||
for i, st := range stages {
|
||||
snapshotStages[i] = stageSnapshot{WorkflowStage: st}
|
||||
if st.PersonaID != nil && h.stores.Personas != nil {
|
||||
grants, _ := h.stores.Personas.GetToolGrants(c.Request.Context(), *st.PersonaID)
|
||||
snapshotStages[i].ToolGrants = grants
|
||||
}
|
||||
}
|
||||
|
||||
snapshot := map[string]interface{}{
|
||||
|
||||
@@ -63,13 +63,6 @@ func main() {
|
||||
|
||||
|
||||
// v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.)
|
||||
if cfg.ProviderTypesFile != "" {
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err)
|
||||
} else if n > 0 {
|
||||
log.Printf(" 📦 Loaded %d custom provider type(s) from %s", n, cfg.ProviderTypesFile)
|
||||
}
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
uekCache := crypto.NewUEKCache()
|
||||
@@ -568,8 +561,6 @@ func main() {
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
protected.PUT("/profile", settings.UpdateProfile)
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
protected.POST("/profile/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
@@ -664,11 +655,6 @@ func main() {
|
||||
teamScoped.GET("/groups", groupH.ListTeamGroups)
|
||||
|
||||
// Team providers
|
||||
teamScoped.GET("/providers", teams.ListTeamProviders)
|
||||
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
||||
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||
|
||||
// Team connections (v0.38.1)
|
||||
teamScoped.GET("/connections", teams.ListTeamConnections)
|
||||
@@ -697,8 +683,6 @@ func main() {
|
||||
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) // v0.17.0
|
||||
teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) // v0.28.0
|
||||
teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) // v0.28.0
|
||||
teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0
|
||||
teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // v0.28.0
|
||||
|
||||
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||
@@ -787,8 +771,6 @@ func main() {
|
||||
admin.POST("/personas", personaAdm.CreateAdminPersona)
|
||||
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
|
||||
admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
|
||||
admin.POST("/personas/:id/avatar", func(c *gin.Context) { handlers.UploadPersonaAvatar(stores.Personas, c) })
|
||||
admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { handlers.DeletePersonaAvatar(stores.Personas, c) })
|
||||
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
|
||||
admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0
|
||||
|
||||
@@ -10,23 +10,6 @@ import (
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// ── Types for template data ──────────────────
|
||||
|
||||
// ModelOption is a flat model for template dropdowns.
|
||||
type ModelOption struct {
|
||||
ProviderConfigID string `json:"provider_config_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ModelType string `json:"model_type"`
|
||||
}
|
||||
|
||||
// ProviderOption for template dropdowns.
|
||||
type ProviderOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// TeamOption for template dropdowns.
|
||||
type TeamOption struct {
|
||||
ID string `json:"id"`
|
||||
@@ -36,16 +19,6 @@ type TeamOption struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ProviderDetail is an enriched provider for the providers table.
|
||||
type ProviderDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Scope string `json:"scope"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
ModelCount int `json:"model_count"`
|
||||
}
|
||||
|
||||
// ProviderTypeOption for the provider type dropdown.
|
||||
type ProviderTypeOption struct {
|
||||
ID string `json:"id"`
|
||||
@@ -86,10 +59,7 @@ type RoleSelection struct {
|
||||
type AdminPageData struct {
|
||||
Section string `json:"section"`
|
||||
Category string `json:"category"`
|
||||
Providers []ProviderOption `json:"providers"`
|
||||
ProviderDetails []ProviderDetail `json:"provider_details,omitempty"`
|
||||
ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"`
|
||||
Models []ModelOption `json:"models"`
|
||||
Teams []TeamOption `json:"teams"`
|
||||
Users []UserRow `json:"users,omitempty"`
|
||||
Roles []RoleConfig `json:"roles"`
|
||||
@@ -102,11 +72,6 @@ type ChatPageData struct {
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
// NotesPageData provides context for the notes surface shell.
|
||||
type NotesPageData struct {
|
||||
NoteID string `json:"NoteID,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsPageData is what the settings surface templates receive.
|
||||
// Feature gates (BYOK, personas) moved to sw.auth.policies in v0.37.19.
|
||||
type SettingsPageData struct {
|
||||
@@ -122,18 +87,10 @@ type TeamAdminPageData struct {
|
||||
ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3
|
||||
}
|
||||
|
||||
// ProjectsPageData is what the projects surface receives.
|
||||
type ProjectsPageData struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
|
||||
func (e *Engine) registerLoaders() {
|
||||
e.RegisterLoader("admin", e.adminLoader)
|
||||
e.RegisterLoader("chat", e.chatLoader)
|
||||
e.RegisterLoader("notes", e.notesLoader)
|
||||
e.RegisterLoader("settings", e.settingsLoader)
|
||||
e.RegisterLoader("team-admin", e.teamAdminLoader)
|
||||
e.RegisterLoader("projects", e.projectsLoader)
|
||||
}
|
||||
|
||||
// Used for manifest validation — DataRequires entries must match a key here.
|
||||
@@ -160,119 +117,6 @@ func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
Category: sectionCategory(section),
|
||||
}
|
||||
|
||||
// ── Providers (global scope) ─────────────
|
||||
if s.Providers != nil {
|
||||
configs, err := s.Providers.ListGlobal(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list providers: %v", err)
|
||||
} else {
|
||||
for _, pc := range configs {
|
||||
name := pc.Name
|
||||
if name == "" {
|
||||
name = pc.Provider
|
||||
}
|
||||
data.Providers = append(data.Providers, ProviderOption{
|
||||
ID: pc.ID,
|
||||
Name: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Teams ────────────────────────────────
|
||||
if s.Teams != nil {
|
||||
teams, err := s.Teams.List(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list teams: %v", err)
|
||||
} else {
|
||||
for _, t := range teams {
|
||||
data.Teams = append(data.Teams, TeamOption{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
MemberCount: t.MemberCount,
|
||||
IsActive: t.IsActive,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full model catalog with model_type ───
|
||||
// This is what fixes bug #1: all models from all providers,
|
||||
// including embedding models, with their model_type intact.
|
||||
if s.Catalog != nil {
|
||||
entries, err := s.Catalog.ListAllGlobal(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list catalog: %v", err)
|
||||
} else {
|
||||
provNames := make(map[string]string, len(data.Providers))
|
||||
for _, p := range data.Providers {
|
||||
provNames[p.ID] = p.Name
|
||||
}
|
||||
for _, entry := range entries {
|
||||
mt := entry.ModelType
|
||||
if mt == "" {
|
||||
mt = "chat"
|
||||
}
|
||||
dn := entry.DisplayName
|
||||
if dn == "" {
|
||||
dn = entry.ModelID
|
||||
}
|
||||
data.Models = append(data.Models, ModelOption{
|
||||
ProviderConfigID: entry.ProviderConfigID,
|
||||
ProviderName: provNames[entry.ProviderConfigID],
|
||||
ModelID: entry.ModelID,
|
||||
DisplayName: dn,
|
||||
ModelType: mt,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section-specific data ────────────────
|
||||
switch section {
|
||||
case "roles":
|
||||
data.Roles = e.loadRolesConfig(ctx, s)
|
||||
case "routing":
|
||||
if s.RoutingPolicies != nil {
|
||||
policies, err := s.RoutingPolicies.ListAll(ctx)
|
||||
if err == nil {
|
||||
data.Policies = policies
|
||||
}
|
||||
}
|
||||
case "providers":
|
||||
data.ProviderTypes = loadProviderTypes()
|
||||
data.ProviderDetails = e.loadProviderDetails(ctx, s, data.Providers, data.Models)
|
||||
case "users":
|
||||
data.Users = e.loadUsers(ctx, s)
|
||||
}
|
||||
|
||||
// v0.38.3: Discover extension config sections for admin surface
|
||||
data.ConfigSections = configSectionsForSurface(ctx, s, "admin")
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// loadRolesConfig reads current role assignments.
|
||||
func (e *Engine) loadRolesConfig(ctx context.Context, s store.Stores) []RoleConfig {
|
||||
roleNames := []string{"utility", "embedding"}
|
||||
roles := make([]RoleConfig, 0, len(roleNames))
|
||||
|
||||
if s.GlobalConfig == nil {
|
||||
for _, name := range roleNames {
|
||||
roles = append(roles, RoleConfig{Name: name})
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
raw, err := s.GlobalConfig.Get(ctx, "model_roles")
|
||||
if err != nil || raw == nil {
|
||||
for _, name := range roleNames {
|
||||
roles = append(roles, RoleConfig{Name: name})
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
rolesMap, ok := raw["roles"].(map[string]any)
|
||||
if !ok {
|
||||
rolesMap = raw
|
||||
@@ -300,13 +144,6 @@ func (e *Engine) loadRolesConfig(ctx context.Context, s store.Stores) []RoleConf
|
||||
return roles
|
||||
}
|
||||
|
||||
// ── Chat loader ──────────────────────────────
|
||||
|
||||
func (e *Engine) chatLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
chatID := c.Param("chatID")
|
||||
return &ChatPageData{ChatID: chatID}, nil
|
||||
}
|
||||
|
||||
// ── Admin helpers ────────────────────────────
|
||||
|
||||
// sectionCategory maps an admin section to its category tab.
|
||||
@@ -336,42 +173,6 @@ func loadProviderTypes() []ProviderTypeOption {
|
||||
return out
|
||||
}
|
||||
|
||||
// loadProviderDetails enriches providers with model counts.
|
||||
func (e *Engine) loadProviderDetails(ctx context.Context, s store.Stores, provs []ProviderOption, models []ModelOption) []ProviderDetail {
|
||||
// Count models per provider
|
||||
counts := make(map[string]int)
|
||||
for _, m := range models {
|
||||
counts[m.ProviderConfigID]++
|
||||
}
|
||||
|
||||
// Get full provider configs for endpoint/scope
|
||||
if s.Providers == nil {
|
||||
return nil
|
||||
}
|
||||
configs, err := s.Providers.ListGlobal(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[pages/admin] Failed to list provider details: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]ProviderDetail, 0, len(configs))
|
||||
for _, pc := range configs {
|
||||
name := pc.Name
|
||||
if name == "" {
|
||||
name = pc.Provider
|
||||
}
|
||||
out = append(out, ProviderDetail{
|
||||
ID: pc.ID,
|
||||
Name: name,
|
||||
Provider: pc.Provider,
|
||||
Scope: pc.Scope,
|
||||
Endpoint: pc.Endpoint,
|
||||
ModelCount: counts[pc.ID],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadUsers returns the user list for the admin users table.
|
||||
func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
|
||||
if s.Users == nil {
|
||||
@@ -396,13 +197,6 @@ func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Notes loader ─────────────────────────────
|
||||
|
||||
func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
noteID := c.Param("noteId")
|
||||
return &NotesPageData{NoteID: noteID}, nil
|
||||
}
|
||||
|
||||
// ── Settings loader ──────────────────────────
|
||||
// v0.22.7: Reads feature gates from GlobalConfig to control
|
||||
// which nav links/tabs are visible (BYOK, User Personas).
|
||||
@@ -430,11 +224,6 @@ func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *Engine) projectsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
projectID := c.Param("id")
|
||||
return &ProjectsPageData{ProjectID: projectID}, nil
|
||||
}
|
||||
|
||||
// ── Config section discovery (v0.38.3) ───────
|
||||
//
|
||||
// Packages declare a config_section in their manifest. This helper scans
|
||||
|
||||
@@ -749,12 +749,7 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
|
||||
if data.FirstStageMode == "" {
|
||||
data.FirstStageMode = "custom"
|
||||
}
|
||||
if stages[0].PersonaID != nil {
|
||||
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
|
||||
data.PersonaName = p.Name
|
||||
data.PersonaIcon = p.Icon
|
||||
}
|
||||
}
|
||||
// Persona lookup deferred — personas are extensions now
|
||||
}
|
||||
|
||||
// Check for existing active session
|
||||
|
||||
Reference in New Issue
Block a user