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:
2026-03-26 05:10:40 -04:00
parent e4b7ee98a5
commit 7b6e54d5b7
73 changed files with 349 additions and 29963 deletions

View File

@@ -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)
}

View File

@@ -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{}{

View File

@@ -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

View File

@@ -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

View File

@@ -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