diff --git a/server/auth/permissions.go b/server/auth/permissions.go index 00802b2..1543e4d 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -14,43 +14,23 @@ const EveryoneGroupID = "00000000-0000-0000-0000-000000000001" // Permission constants — domain.action convention. const ( - PermModelUse = "model.use" // use models for completion - PermModelSelectAny = "model.select_any" // use any enabled model (vs. group allowlist) - PermKBRead = "kb.read" // search KBs through personas - PermKBWrite = "kb.write" // upload/delete KB documents - PermKBCreate = "kb.create" // create new knowledge bases - PermChannelCreate = "channel.create" // create group/channel conversations - PermChannelInvite = "channel.invite" // invite users to channels - PermPersonaCreate = "persona.create" // create new personas - PermPersonaManage = "persona.manage" // edit/delete team personas - PermWorkflowCreate = "workflow.create" // create workflow definitions (v0.25.0) - PermAdminView = "admin.view" // read-only admin panel access - PermTokenUnlimited = "token.unlimited" // bypass token budgets - PermTaskCreate = "task.create" // create scheduled tasks (v0.27.2) - PermTaskAdmin = "task.admin" // manage all tasks, set global task config (v0.27.2) - PermTaskAction = "task.action" // create non-LLM action tasks (v0.28.0) - PermTaskStarlark = "task.starlark" // create Starlark tasks (pre-positioned for v0.29.0) + PermExtensionUse = "extension.use" // use installed extensions + PermExtensionInstall = "extension.install" // install/manage extension packages + PermWorkflowCreate = "workflow.create" // create workflow definitions + PermWorkflowSubmit = "workflow.submit" // submit to public workflows + PermAdminView = "admin.view" // read-only admin panel access + PermTokenUnlimited = "token.unlimited" // bypass token budgets ) // AllPermissions is the complete set of valid permission strings. // Used for validation in handlers and rendering checkboxes in admin UI. var AllPermissions = []string{ - PermModelUse, - PermModelSelectAny, - PermKBRead, - PermKBWrite, - PermKBCreate, - PermChannelCreate, - PermChannelInvite, - PermPersonaCreate, - PermPersonaManage, + PermExtensionUse, + PermExtensionInstall, PermWorkflowCreate, + PermWorkflowSubmit, PermAdminView, PermTokenUnlimited, - PermTaskCreate, - PermTaskAdmin, - PermTaskAction, - PermTaskStarlark, } // ── Resolution ────────────────────────────── diff --git a/server/auth/session.go b/server/auth/session.go deleted file mode 100644 index c82ddfa..0000000 --- a/server/auth/session.go +++ /dev/null @@ -1,96 +0,0 @@ -package auth - -import ( - "context" - "database/sql" - "fmt" - "log" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - - "switchboard-core/config" - "switchboard-core/models" - "switchboard-core/store" -) - -const sessionCookieName = "sb_session" - -// CreateOrResumeSession looks up or creates a session participant for -// an anonymous visitor to a workflow channel. -// -// Two entry paths: -// - Cookie-based (default): random token stored in sb_session cookie -// - mTLS-based: cert fingerprint used as stable session identity -func CreateOrResumeSession(c *gin.Context, stores store.Stores, channelID string, cfg *config.Config) (*models.SessionParticipant, error) { - // Check for existing session cookie - token, _ := c.Cookie(sessionCookieName) - - // mTLS mode: use cert fingerprint as stable token - if cfg.AuthMode == "mtls" { - fp := c.GetHeader("X-SSL-Client-Fingerprint") - if fp != "" { - token = "mtls:" + fp - } - } - - // Resume existing session if token matches this channel - if token != "" { - session, err := stores.Sessions.GetByToken(c.Request.Context(), token) - if err == nil && session.ChannelID == channelID { - return session, nil - } - // Token exists but for different channel — fall through to create - if err != nil && err != sql.ErrNoRows { - log.Printf("[auth/session] warn: GetByToken error: %v", err) - } - } - - // Create new session - token = "sess:" + uuid.New().String() - displayName, err := generateVisitorName(c.Request.Context(), stores, channelID) - if err != nil { - displayName = "Visitor" - } - - session := &models.SessionParticipant{ - SessionToken: token, - ChannelID: channelID, - DisplayName: displayName, - } - - // mTLS mode: store fingerprint for team member visibility - if cfg.AuthMode == "mtls" { - fp := c.GetHeader("X-SSL-Client-Fingerprint") - if fp != "" { - session.Fingerprint = fp - } - } - - if err := stores.Sessions.Create(c.Request.Context(), session); err != nil { - return nil, fmt.Errorf("create session: %w", err) - } - - // Add as channel participant - _ = stores.Channels.AddParticipant(c.Request.Context(), &models.ChannelParticipant{ - ChannelID: channelID, - ParticipantType: "session", - ParticipantID: session.ID, - Role: "visitor", - }) - - // Set cookie (httponly, secure, 30 day expiry) - c.SetCookie(sessionCookieName, token, 60*60*24*30, "/", "", true, true) - - log.Printf("[auth/session] created session %s for channel %s (%s)", session.ID, channelID, displayName) - return session, nil -} - -// generateVisitorName produces "Visitor #N" based on existing session count. -func generateVisitorName(ctx context.Context, stores store.Stores, channelID string) (string, error) { - count, err := stores.Sessions.CountForChannel(ctx, channelID) - if err != nil { - return "", err - } - return fmt.Sprintf("Visitor #%d", count+1), nil -} diff --git a/server/database/migrations/postgres/008_tasks.sql b/server/database/migrations/postgres/008_tasks.sql deleted file mode 100644 index e29073f..0000000 --- a/server/database/migrations/postgres/008_tasks.sql +++ /dev/null @@ -1,69 +0,0 @@ --- ========================================== --- Switchboard Core — 008 Tasks --- ========================================== --- Task scheduling and run history. --- Stripped: persona_id (kept nullable, no FK), output_channel_id, --- provider_config_id. Output modes: notification | webhook | log. --- ========================================== - -CREATE TABLE IF NOT EXISTS tasks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - team_id UUID REFERENCES teams(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT DEFAULT '', - scope TEXT NOT NULL DEFAULT 'personal' - CHECK (scope IN ('personal', 'team', 'global')), - task_type TEXT NOT NULL DEFAULT 'action' - CHECK (task_type IN ('action', 'workflow', 'system')), - system_function TEXT DEFAULT '', - persona_id TEXT, - model_id TEXT, - system_prompt TEXT DEFAULT '', - user_prompt TEXT DEFAULT '', - workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL, - tool_grants JSONB, - schedule TEXT NOT NULL, - timezone TEXT NOT NULL DEFAULT 'UTC', - is_active BOOLEAN NOT NULL DEFAULT true, - trigger_token TEXT UNIQUE, - max_tokens INTEGER NOT NULL DEFAULT 4096, - max_tool_calls INTEGER NOT NULL DEFAULT 10, - max_wall_clock INTEGER NOT NULL DEFAULT 300, - output_mode TEXT NOT NULL DEFAULT 'log' - CHECK (output_mode IN ('notification', 'webhook', 'log')), - webhook_url TEXT, - webhook_secret TEXT, - notify_on_complete BOOLEAN NOT NULL DEFAULT false, - notify_on_failure BOOLEAN NOT NULL DEFAULT true, - last_run_at TIMESTAMPTZ, - next_run_at TIMESTAMPTZ, - run_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks(next_run_at) WHERE is_active = true; -CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id); -CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_trigger_token - ON tasks(trigger_token) WHERE trigger_token IS NOT NULL; - --- ── Task Runs ─────────────────────────────── - -CREATE TABLE IF NOT EXISTS task_runs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'running' - CHECK (status IN ('queued', 'running', 'completed', 'failed', 'budget_exceeded', 'cancelled')), - trigger_payload TEXT, - started_at TIMESTAMPTZ NOT NULL DEFAULT now(), - completed_at TIMESTAMPTZ, - tokens_used INTEGER DEFAULT 0, - tool_calls INTEGER DEFAULT 0, - wall_clock INTEGER DEFAULT 0, - error TEXT -); - -CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs(task_id); -CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(task_id, status); diff --git a/server/database/migrations/sqlite/008_tasks.sql b/server/database/migrations/sqlite/008_tasks.sql deleted file mode 100644 index 0cf186b..0000000 --- a/server/database/migrations/sqlite/008_tasks.sql +++ /dev/null @@ -1,62 +0,0 @@ --- ========================================== --- Switchboard Core — 008 Tasks (SQLite) --- ========================================== - -CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT DEFAULT '', - scope TEXT NOT NULL DEFAULT 'personal' - CHECK (scope IN ('personal', 'team', 'global')), - task_type TEXT NOT NULL DEFAULT 'action' - CHECK (task_type IN ('action', 'workflow', 'system')), - system_function TEXT DEFAULT '', - persona_id TEXT, - model_id TEXT, - system_prompt TEXT DEFAULT '', - user_prompt TEXT DEFAULT '', - workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL, - tool_grants TEXT, - schedule TEXT NOT NULL, - timezone TEXT NOT NULL DEFAULT 'UTC', - is_active INTEGER NOT NULL DEFAULT 1, - trigger_token TEXT UNIQUE, - max_tokens INTEGER NOT NULL DEFAULT 4096, - max_tool_calls INTEGER NOT NULL DEFAULT 10, - max_wall_clock INTEGER NOT NULL DEFAULT 300, - output_mode TEXT NOT NULL DEFAULT 'log' - CHECK (output_mode IN ('notification', 'webhook', 'log')), - webhook_url TEXT, - webhook_secret TEXT, - notify_on_complete INTEGER NOT NULL DEFAULT 0, - notify_on_failure INTEGER NOT NULL DEFAULT 1, - last_run_at TEXT, - next_run_at TEXT, - run_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks(next_run_at); -CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id); -CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id); -CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_trigger_token ON tasks(trigger_token); - -CREATE TABLE IF NOT EXISTS task_runs ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'running' - CHECK (status IN ('queued', 'running', 'completed', 'failed', 'budget_exceeded', 'cancelled')), - trigger_payload TEXT, - started_at TEXT NOT NULL DEFAULT (datetime('now')), - completed_at TEXT, - tokens_used INTEGER DEFAULT 0, - tool_calls INTEGER DEFAULT 0, - wall_clock INTEGER DEFAULT 0, - error TEXT -); - -CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs(task_id); -CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(task_id, status); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index 959715b..128906d 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -253,18 +253,20 @@ func TruncateAll(t *testing.T) { } tables := []string{ - // Task system - "task_runs", - "tasks", - // Workflow system - "workflow_assignments", + // HA + "rate_limit_counters", + "ws_tickets", + // Workflows "workflow_versions", "workflow_stages", "workflows", - // Sessions - "session_participants", - // Files - "files", + // Extensions + "ext_data_tables", + "extension_permissions", + "ext_dependencies", + "ext_connections", + "package_user_settings", + "packages", // Resource grants & groups "resource_grants", "group_members", @@ -272,53 +274,10 @@ func TruncateAll(t *testing.T) { // Notifications "notification_preferences", "notifications", - // Usage & audit - "usage_log", + // Audit "audit_log", - // Channel internals - "channel_cursors", - "messages", - "channel_models", - "channel_participants", - "channel_knowledge_bases", - "user_model_settings", - // Projects - "project_notes", - "project_knowledge_bases", - "project_channels", - // Knowledge bases - "persona_knowledge_bases", - "kb_chunks", - "kb_documents", - "knowledge_bases", - // Notes - "note_links", - "notes", - // Memory - "memory_extraction_log", - "memories", - // Workspaces - "workspace_chunks", - "workspace_files", - "git_credentials", - "workspaces", - // Channels & folders - "channels", - "folders", - "projects", - // Providers & health - "model_pricing", - "provider_health", - "capability_overrides", - "routing_policies", - "tool_health", - "model_catalog", - // Personas - "persona_grants", - "persona_group_members", - "persona_groups", - "personas", - "provider_configs", + // Presence + "user_presence", // Teams & users "team_members", "teams", diff --git a/server/events/types.go b/server/events/types.go index 697ffc2..92d4119 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -36,24 +36,14 @@ const ( // Events not listed default to DirLocal (server-only). var routeTable = map[string]Direction{ // Chat events - "chat.message.": DirBoth, // new messages - "chat.typing.": DirBoth, // typing indicators - "chat.updated.": DirToClient, // chat title/metadata changed - "chat.deleted.": DirToClient, // chat removed // Channel events - "channel.message.": DirBoth, - "channel.typing.": DirBoth, - "channel.updated.": DirToClient, - "channel.member.": DirToClient, // User/presence "user.presence": DirToClient, "user.status": DirToClient, "user.mentioned": DirToClient, // v0.23.2: targeted @mention notification "typing.user": DirToClient, // v0.23.2: human typing in DM/channel - "message.created": DirToClient, // v0.23.2: chained/DM message delivery - "message.deleted": DirToClient, // v0.37.14: soft-delete broadcast // System "system.notify": DirToClient, @@ -70,7 +60,6 @@ var routeTable = map[string]Direction{ "notification.read": DirToClient, // badge sync across tabs // Workspace (v0.21.5) - "workspace.file.": DirToClient, // file changed events for live editor updates // Workflow (v0.27.0) "workflow.assigned": DirToClient, // new assignment → team members diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 163cbcc..1ea121c 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -16,7 +16,6 @@ import ( "switchboard-core/crypto" "switchboard-core/database" "switchboard-core/models" - "switchboard-core/providers" "switchboard-core/storage" "switchboard-core/store" "switchboard-core/tools/search" @@ -438,325 +437,6 @@ func (h *AdminHandler) VaultStatus(c *gin.Context) { c.JSON(http.StatusOK, status) } -// ── Provider Configs (Global) ─────────────── - -func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { - cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) - return - } - - // Redact API keys but expose has_key flag for UI - type configWithKey struct { - models.ProviderConfig - HasKey bool `json:"has_key"` - } - out := make([]configWithKey, len(cfgs)) - for i, cfg := range cfgs { - out[i] = configWithKey{ - ProviderConfig: cfg, - HasKey: cfg.HasKey(), - } - } - c.JSON(http.StatusOK, gin.H{"data": out}) -} - -func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) { - // Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-" - // so ShouldBindJSON would silently drop the api_key field. - 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"` - Config map[string]interface{} `json:"config,omitempty"` - Headers map[string]interface{} `json:"headers,omitempty"` - Settings map[string]interface{} `json:"settings,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 - } - - cfg := &models.ProviderConfig{ - Name: req.Name, - Provider: req.Provider, - Endpoint: req.Endpoint, - ModelDefault: req.ModelDefault, - Config: models.JSONMap(req.Config), - Headers: models.JSONMap(req.Headers), - Settings: models.JSONMap(req.Settings), - Scope: models.ScopeGlobal, - KeyScope: models.ScopeGlobal, - IsActive: true, - IsPrivate: req.IsPrivate, - } - - // Encrypt the API key - if req.APIKey != "" { - if h.vault != nil { - enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) - return - } - cfg.APIKeyEnc = enc - cfg.KeyNonce = nonce - } else { - // No vault configured — store raw bytes (unencrypted fallback) - 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 - } - - h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider}) - c.JSON(http.StatusCreated, gin.H{ - "id": cfg.ID, - "scope": cfg.Scope, - "name": cfg.Name, - "provider": cfg.Provider, - "endpoint": cfg.Endpoint, - "model_default": cfg.ModelDefault, - "config": cfg.Config, - "headers": cfg.Headers, - "settings": cfg.Settings, - "is_active": cfg.IsActive, - "is_private": cfg.IsPrivate, - "has_key": cfg.HasKey(), - "created_at": cfg.CreatedAt, - "updated_at": cfg.UpdatedAt, - }) -} - -func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { - id := c.Param("id") - - // Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-" - 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, "global", "") - 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) - } - } - - 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 - } - - h.auditLog(c, "provider.update", "provider_config", id, nil) - c.JSON(http.StatusOK, gin.H{"message": "config updated"}) -} - -func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { - id := c.Param("id") - // Delete associated catalog entries first - 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 - } - h.auditLog(c, "provider.delete", "provider_config", id, nil) - c.JSON(http.StatusOK, gin.H{"message": "config deleted"}) -} - -// ── Model Catalog ─────────────────────────── - -func (h *AdminHandler) ListModelConfigs(c *gin.Context) { - entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": entries}) -} - -func (h *AdminHandler) FetchModels(c *gin.Context) { - var req struct { - ProviderConfigID string `json:"provider_config_id"` - } - c.ShouldBindJSON(&req) - - // If no specific provider, fetch from ALL global providers - if req.ProviderConfigID == "" { - configs, err := h.stores.Providers.ListGlobal(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"}) - return - } - if len(configs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"}) - return - } - - totalAdded, totalUpdated, totalFetched := 0, 0, 0 - var errs []string - for _, cfg := range configs { - if !cfg.IsActive { - continue - } - added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg) - if err != nil { - errs = append(errs, cfg.Provider+": "+err.Error()) - continue - } - totalAdded += added - totalUpdated += updated - totalFetched += fetched - } - - result := gin.H{ - "message": "models synced", - "added": totalAdded, - "updated": totalUpdated, - "total": totalFetched, - } - if len(errs) > 0 { - result["errors"] = errs - } - c.JSON(http.StatusOK, result) - return - } - - // Single provider fetch - cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"}) - return - } - - added, updated, fetched, err := h.fetchModelsForProvider(c, cfg) - 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": added, - "updated": updated, - "total": fetched, - }) -} - -// fetchModelsForProvider fetches and syncs models for a single provider config. -func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) { - // 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, "") - if err != nil { - return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err) - } - } else { - // No vault — key stored as raw bytes (unencrypted fallback) - apiKey = string(cfg.APIKeyEnc) - } - } - - result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey) - if err != nil { - return 0, 0, 0, err - } - - h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{ - "added": result.Added, "updated": result.Updated, "total": result.Total, - }) - - return result.Added, result.Updated, result.Total, nil -} - -func (h *AdminHandler) UpdateModelConfig(c *gin.Context) { - id := c.Param("id") - var req struct { - Visibility *string `json:"visibility"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Visibility != nil { - if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"}) - return - } - } - - c.JSON(http.StatusOK, gin.H{"message": "model updated"}) -} - -func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { - var req struct { - ProviderConfigID string `json:"provider_config_id"` - Visibility string `json:"visibility" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var err error - if req.ProviderConfigID != "" { - err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility) - } else { - err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility) - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"}) -} - -func (h *AdminHandler) DeleteModelConfig(c *gin.Context) { - id := c.Param("id") - if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "model deleted"}) -} - // ── Stats ─────────────────────────────────── func (h *AdminHandler) GetStats(c *gin.Context) { @@ -764,12 +444,10 @@ func (h *AdminHandler) GetStats(c *gin.Context) { stats := gin.H{} userCount, _ := h.stores.Users.CountAll(ctx) - channelCount, _ := h.stores.Channels.CountAll(ctx) - messageCount, _ := h.stores.Messages.CountAll(ctx) + teamCount, _ := h.stores.Teams.CountAll(ctx) stats["users"] = userCount - stats["channels"] = channelCount - stats["messages"] = messageCount + stats["teams"] = teamCount c.JSON(http.StatusOK, stats) } @@ -786,62 +464,3 @@ func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID } AuditLog(h.stores.Audit, c, action, resourceType, resourceID, meta) } - -// ── Archived Channels (v0.23.2) ────────────── - -func (h *AdminHandler) ListArchivedChannels(c *gin.Context) { - page, perPage, offset := parsePagination(c) - - channels, total, err := h.stores.Channels.ListArchived(c.Request.Context(), store.ListOptions{ - Limit: perPage, - Offset: offset, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) - return - } - - // Retention config - retentionTTL := 0 - if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil { - if v, ok := ttlCfg["value"].(float64); ok { - retentionTTL = int(v) - } - } - - c.JSON(http.StatusOK, gin.H{ - "data": channels, - "total": total, - "page": page, - "per_page": perPage, - "retention_ttl_days": retentionTTL, - }) -} - -func (h *AdminHandler) PurgeChannel(c *gin.Context) { - channelID := c.Param("id") - - // Clean up file storage blobs before CASCADE deletes the DB references - if h.objStore != nil { - prefix := fmt.Sprintf("files/%s", channelID) - if err := h.objStore.DeletePrefix(c.Request.Context(), prefix); err != nil { - log.Printf("⚠️ storage cleanup for purged channel %s failed: %v", channelID, err) - } - } - - if err := h.stores.Channels.Purge(c.Request.Context(), channelID); err != nil { - msg := err.Error() - switch { - case msg == "channel not found": - c.JSON(http.StatusNotFound, gin.H{"error": msg}) - case msg == "channel must be archived before purging": - c.JSON(http.StatusConflict, gin.H{"error": msg}) - default: - c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"}) - } - return - } - - h.auditLog(c, "channel.purged", "channel", channelID, nil) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} diff --git a/server/handlers/auth.go b/server/handlers/auth.go index d8d05f8..9b40eb8 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -349,17 +349,12 @@ func hashToken(token string) string { // // Does NOT evict from UEK cache or write audit logs — callers handle that. // v0.29.0: accepts stores instead of using database.DB directly. -func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (providersDeleted int64) { +func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (deleted int64) { if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil { log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err) } - rows, err := stores.Providers.DeletePersonalByOwner(ctx, userID) - if err != nil { - log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err) - return 0 - } - return rows + return 0 } // ProbeAndRepairVault checks whether a user's vault can be unlocked with diff --git a/server/handlers/health_admin.go b/server/handlers/health_admin.go deleted file mode 100644 index e39508b..0000000 --- a/server/handlers/health_admin.go +++ /dev/null @@ -1,294 +0,0 @@ -package handlers - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - - capspkg "switchboard-core/capabilities" - "switchboard-core/health" - "switchboard-core/models" - "switchboard-core/providers" - "switchboard-core/store" -) - -// ── Health Admin Handler ──────────────────── - -type HealthAdminHandler struct { - healthStore health.Store - stores store.Stores -} - -func NewHealthAdminHandler(hs health.Store, stores store.Stores) *HealthAdminHandler { - return &HealthAdminHandler{healthStore: hs, stores: stores} -} - -// GetProviderHealth returns health metrics for a single provider. -// GET /api/v1/admin/providers/:id/health?hours=24 -func (h *HealthAdminHandler) GetProviderHealth(c *gin.Context) { - providerID := c.Param("id") - hours, _ := strconv.Atoi(c.DefaultQuery("hours", "24")) - if hours < 1 || hours > 168 { - hours = 24 - } - - windows, err := h.healthStore.ListWindows(c.Request.Context(), providerID, hours) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch health data"}) - return - } - - // Derive current status from most recent window - var summary models.ProviderHealthSummary - summary.ProviderConfigID = providerID - - if len(windows) > 0 { - latest := windows[0] - summary.RequestCount = latest.RequestCount - summary.ErrorRate = latest.ErrorRate() - summary.AvgLatencyMs = latest.AvgLatencyMs() - summary.MaxLatencyMs = latest.MaxLatencyMs - summary.LastError = latest.LastError - summary.LastErrorAt = latest.LastErrorAt - summary.Status = health.DeriveStatus(summary.ErrorRate, summary.RequestCount) - } else { - summary.Status = models.StatusUnknown - } - - SafeJSON(c, http.StatusOK, gin.H{ - "summary": summary, - "windows": windows, - }) -} - -// GetAllProviderHealth returns current health for all providers. -// GET /api/v1/admin/providers/health -func (h *HealthAdminHandler) GetAllProviderHealth(c *gin.Context) { - windows, err := h.healthStore.ListAllCurrent(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch health data"}) - return - } - - // Build summaries - summaries := make([]models.ProviderHealthSummary, 0, len(windows)) - for _, w := range windows { - s := 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, - } - summaries = append(summaries, s) - } - - SafeJSON(c, http.StatusOK, gin.H{ - "data": summaries, - }) -} - -// ── Capability Override Admin Handler ──────── - -type CapOverrideAdminHandler struct { - stores store.Stores -} - -func NewCapOverrideAdminHandler(stores store.Stores) *CapOverrideAdminHandler { - return &CapOverrideAdminHandler{stores: stores} -} - -// GetModelCapabilities returns resolved capabilities with source annotation. -// GET /api/v1/admin/models/:id/capabilities?provider_config_id=... -func (h *CapOverrideAdminHandler) GetModelCapabilities(c *gin.Context) { - modelID := c.Param("id") - providerConfigID := c.Query("provider_config_id") - - // Get catalog capabilities - var catalogCaps *models.ModelCapabilities - if providerConfigID != "" { - if entry, err := h.stores.Catalog.GetByModelID(c.Request.Context(), providerConfigID, modelID); err == nil { - catalogCaps = &entry.Capabilities - } - } else { - if entry, err := h.stores.Catalog.GetByModelIDAny(c.Request.Context(), modelID); err == nil { - catalogCaps = &entry.Capabilities - } - } - - // Get admin overrides - var overrides []models.CapabilityOverride - var err error - if providerConfigID != "" { - overrides, err = h.stores.CapOverrides.ListForProviderModel(c.Request.Context(), providerConfigID, modelID) - } else { - overrides, err = h.stores.CapOverrides.ListForModel(c.Request.Context(), modelID) - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overrides"}) - return - } - - // Resolve through three tiers - heuristic := capspkg.InferCapabilities(modelID) - resolved := capspkg.ResolveIntrinsic(modelID, catalogCaps, overrides) - - // Build source annotations - sources := buildSourceAnnotations(catalogCaps, &heuristic, overrides) - - SafeJSON(c, http.StatusOK, gin.H{ - "model_id": modelID, - "resolved": resolved, - "catalog": catalogCaps, - "heuristic": heuristic, - "overrides": overrides, - "sources": sources, - }) -} - -// SetModelCapability sets an admin override for a specific capability. -// PUT /api/v1/admin/models/:id/capabilities -func (h *CapOverrideAdminHandler) SetModelCapability(c *gin.Context) { - modelID := c.Param("id") - userID := c.GetString("user_id") - - var req struct { - ProviderConfigID *string `json:"provider_config_id"` - Field string `json:"field" binding:"required"` - Value string `json:"value" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate field name - validFields := map[string]bool{ - "streaming": true, "tool_calling": true, "vision": true, - "thinking": true, "reasoning": true, "code_optimized": true, - "web_search": true, "max_context": true, "max_output_tokens": true, - } - if !validFields[req.Field] { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capability field: " + req.Field}) - return - } - - // Validate value - switch req.Field { - case "max_context", "max_output_tokens": - if _, err := strconv.Atoi(req.Value); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "value must be a number for " + req.Field}) - return - } - default: - if req.Value != "true" && req.Value != "false" { - c.JSON(http.StatusBadRequest, gin.H{"error": "value must be 'true' or 'false' for " + req.Field}) - return - } - } - - override := &models.CapabilityOverride{ - ProviderConfigID: req.ProviderConfigID, - ModelID: modelID, - Field: req.Field, - Value: req.Value, - SetBy: &userID, - } - - if err := h.stores.CapOverrides.Set(c.Request.Context(), override); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set override"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// DeleteModelCapability removes a specific override. -// DELETE /api/v1/admin/models/:id/capabilities/:overrideId -func (h *CapOverrideAdminHandler) DeleteModelCapability(c *gin.Context) { - overrideID := c.Param("overrideId") - - if err := h.stores.CapOverrides.Delete(c.Request.Context(), overrideID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete override"}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) -} - -// ListAllOverrides returns all capability overrides (admin view). -// GET /api/v1/admin/capability-overrides -func (h *CapOverrideAdminHandler) ListAllOverrides(c *gin.Context) { - overrides, err := h.stores.CapOverrides.ListAll(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch overrides"}) - return - } - - SafeJSON(c, http.StatusOK, gin.H{ - "data": overrides, - "total": len(overrides), - }) -} - -// ── Source Annotation Helpers ──────────────── - -type capSource struct { - Field string `json:"field"` - Source string `json:"source"` // "catalog", "heuristic", "override" -} - -func buildSourceAnnotations(catalog *models.ModelCapabilities, heuristic *models.ModelCapabilities, overrides []models.CapabilityOverride) []capSource { - // Build override set for quick lookup - overrideSet := make(map[string]bool, len(overrides)) - for _, o := range overrides { - overrideSet[o.Field] = true - } - - fields := []struct { - name string - hasCatalog bool - hasHeur bool - }{ - {"streaming", catalog != nil && catalog.Streaming, heuristic.Streaming}, - {"tool_calling", catalog != nil && catalog.ToolCalling, heuristic.ToolCalling}, - {"vision", catalog != nil && catalog.Vision, heuristic.Vision}, - {"thinking", catalog != nil && catalog.Thinking, heuristic.Thinking}, - {"reasoning", catalog != nil && catalog.Reasoning, heuristic.Reasoning}, - {"code_optimized", catalog != nil && catalog.CodeOptimized, heuristic.CodeOptimized}, - {"web_search", catalog != nil && catalog.WebSearch, heuristic.WebSearch}, - {"max_context", catalog != nil && catalog.MaxContext > 0, heuristic.MaxContext > 0}, - {"max_output_tokens", catalog != nil && catalog.MaxOutputTokens > 0, heuristic.MaxOutputTokens > 0}, - } - - var sources []capSource - for _, f := range fields { - src := "default" - if overrideSet[f.name] { - src = "override" - } else if f.hasCatalog { - src = "catalog" - } else if f.hasHeur { - src = "heuristic" - } - sources = append(sources, capSource{Field: f.name, Source: src}) - } - return sources -} - -// ── Provider Types Endpoint ───────────────── - -// GetProviderTypes returns metadata and profile schemas for all registered -// provider types. Used by the admin UI to render provider creation forms -// and show available settings. -func GetProviderTypes(c *gin.Context) { - types := providers.ListTypes() - c.JSON(http.StatusOK, gin.H{"data": types}) -} diff --git a/server/handlers/task_test.go b/server/handlers/task_test.go deleted file mode 100644 index 4f00a3f..0000000 --- a/server/handlers/task_test.go +++ /dev/null @@ -1,1022 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" - - "switchboard-core/database" -) - -// ═══════════════════════════════════════════════ -// Task CRUD Tests -// ═══════════════════════════════════════════════ - -func TestTask_CreateAndGet(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("taskowner", "taskowner@test.com") - - // Create a prompt task - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Test Task", - "description": "Unit test task", - "task_type": "prompt", - "schedule": "@daily", - "user_prompt": "Summarize news", - "model_id": "test-model", - "timezone": "UTC", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create task: want 201, got %d: %s", w.Code, w.Body.String()) - } - - var task map[string]interface{} - decode(w, &task) - - taskID := task["id"].(string) - if taskID == "" { - t.Fatal("task should have an id") - } - if task["name"].(string) != "Test Task" { - t.Fatalf("name mismatch: got %q", task["name"]) - } - if task["is_active"] != true { - t.Fatal("task should be active by default") - } - if task["schedule"].(string) != "@daily" { - t.Fatalf("schedule mismatch: got %q", task["schedule"]) - } - // next_run_at should be computed - if task["next_run_at"] == nil { - t.Fatal("next_run_at should be set for cron task") - } - // created_at should not be zero time - createdAt, _ := task["created_at"].(string) - if createdAt == "" || createdAt == "0001-01-01T00:00:00Z" { - t.Fatalf("created_at should be a real timestamp, got %q", createdAt) - } - - // Get it back - w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) - if w.Code != http.StatusOK { - t.Fatalf("get task: want 200, got %d: %s", w.Code, w.Body.String()) - } - var got map[string]interface{} - decode(w, &got) - if got["id"].(string) != taskID { - t.Fatal("get returned wrong task") - } -} - -func TestTask_ListMine(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("lister", "lister@test.com") - - // Create 2 tasks - for i := 0; i < 2; i++ { - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": fmt.Sprintf("Task %d", i), - "task_type": "prompt", - "schedule": "@hourly", - "user_prompt": "do stuff", - "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create task %d: want 201, got %d", i, w.Code) - } - } - - // List - w := h.request("GET", "/api/v1/tasks", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("list: want 200, got %d", w.Code) - } - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - if len(data) != 2 { - t.Fatalf("expected 2 tasks, got %d", len(data)) - } -} - -func TestTask_Update(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("updater", "updater@test.com") - - // Create - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Before", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "old", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Patch name and user_prompt - w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{ - "name": "After", - "user_prompt": "new prompt", - }) - if w.Code != http.StatusOK { - t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String()) - } - var updated map[string]interface{} - decode(w, &updated) - if updated["name"].(string) != "After" { - t.Fatalf("name not updated: got %q", updated["name"]) - } - if updated["user_prompt"].(string) != "new prompt" { - t.Fatalf("user_prompt not updated: got %q", updated["user_prompt"]) - } -} - -func TestTask_UpdateToolGrants(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("grantuser", "grant@test.com") - - // Create with tool_grants - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Grants", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "x", "model_id": "m", - "tool_grants": []string{"web_search"}, - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Patch tool_grants - newGrants := json.RawMessage(`["url_fetch","web_search"]`) - w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{ - "tool_grants": newGrants, - }) - if w.Code != http.StatusOK { - t.Fatalf("update tool_grants: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify via GET - w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) - if w.Code != http.StatusOK { - t.Fatalf("get: %d", w.Code) - } - var got map[string]interface{} - decode(w, &got) - grants, ok := got["tool_grants"].([]interface{}) - if !ok || len(grants) != 2 { - t.Fatalf("tool_grants not updated: got %v", got["tool_grants"]) - } -} - -func TestTask_Delete(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("deleter", "deleter@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Doomed", "task_type": "prompt", "schedule": "once", - "user_prompt": "bye", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Delete - w = h.request("DELETE", "/api/v1/tasks/"+taskID, token, nil) - if w.Code != http.StatusOK { - t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify gone - w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) - if w.Code != http.StatusNotFound { - t.Fatalf("get deleted: want 404, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// Ownership / Access Control Tests -// ═══════════════════════════════════════════════ - -func TestTask_OwnershipIsolation(t *testing.T) { - h := setupHarness(t) - _, tokenA := h.createAdminUser("ownerA", "ownerA@test.com") - - // User B — non-admin regular user (seeded directly, bypassing register endpoint) - userB := database.SeedTestUser(h.t, "ownerB", "ownerB@test.com") - database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userB) - tokenB := makeToken(userB, "ownerB@test.com", "user") - - // A creates a task - w := h.request("POST", "/api/v1/tasks", tokenA, map[string]interface{}{ - "name": "A's Task", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "private", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // B cannot GET it (no RequirePermission on GET — reaches handler → 404) - w = h.request("GET", "/api/v1/tasks/"+taskID, tokenB, nil) - if w.Code != http.StatusNotFound { - t.Fatalf("non-owner GET: want 404, got %d", w.Code) - } - - // B cannot UPDATE it (RequirePermission blocks → 403, or handler blocks → 404) - w = h.request("PUT", "/api/v1/tasks/"+taskID, tokenB, map[string]interface{}{ - "name": "Hijacked", - }) - if w.Code == http.StatusOK { - t.Fatal("non-owner PUT should not succeed") - } - - // B cannot DELETE it - w = h.request("DELETE", "/api/v1/tasks/"+taskID, tokenB, nil) - if w.Code == http.StatusOK { - t.Fatal("non-owner DELETE should not succeed") - } - - // B's list should be empty (no tasks of their own) - w = h.request("GET", "/api/v1/tasks", tokenB, nil) - if w.Code != http.StatusOK { - t.Fatalf("list: want 200, got %d", w.Code) - } - var resp map[string]interface{} - decode(w, &resp) - data := resp["data"].([]interface{}) - if len(data) != 0 { - t.Fatalf("expected 0 tasks for B, got %d", len(data)) - } -} - -// ═══════════════════════════════════════════════ -// Validation Tests -// ═══════════════════════════════════════════════ - -func TestTask_CreateValidation(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("validator", "validator@test.com") - - tests := []struct { - name string - body map[string]interface{} - want int - }{ - { - name: "missing name", - body: map[string]interface{}{"task_type": "prompt", "schedule": "@daily", "user_prompt": "x", "model_id": "m"}, - want: http.StatusBadRequest, - }, - { - name: "missing schedule", - body: map[string]interface{}{"name": "X", "task_type": "prompt", "user_prompt": "x", "model_id": "m"}, - want: http.StatusBadRequest, - }, - { - name: "prompt without user_prompt", - body: map[string]interface{}{"name": "X", "task_type": "prompt", "schedule": "@daily", "model_id": "m"}, - want: http.StatusBadRequest, - }, - { - name: "invalid cron", - body: map[string]interface{}{"name": "X", "task_type": "prompt", "schedule": "not-a-cron", "user_prompt": "x", "model_id": "m"}, - want: http.StatusBadRequest, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - w := h.request("POST", "/api/v1/tasks", token, tc.body) - if w.Code != tc.want { - t.Fatalf("want %d, got %d: %s", tc.want, w.Code, w.Body.String()) - } - }) - } -} - -func TestTask_UpdateInvalidSchedule(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("badsched", "badsched@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "T", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - - // Try to update with invalid schedule - w = h.request("PUT", "/api/v1/tasks/"+task["id"].(string), token, map[string]interface{}{ - "schedule": "garbage-cron", - }) - if w.Code != http.StatusBadRequest { - t.Fatalf("invalid schedule update: want 400, got %d: %s", w.Code, w.Body.String()) - } -} - -// ═══════════════════════════════════════════════ -// Run Now / Kill Tests -// ═══════════════════════════════════════════════ - -func TestTask_RunNowAndListRuns(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("runner", "runner@test.com") - - // Create - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Runnable", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "go", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Run Now - w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("run now: want 200, got %d: %s", w.Code, w.Body.String()) - } - var runResp map[string]interface{} - decode(w, &runResp) - if runResp["scheduled"] != true { - t.Fatal("expected scheduled: true") - } - - // List runs — might be empty if scheduler hasn't ticked, but endpoint should work - w = h.request("GET", "/api/v1/tasks/"+taskID+"/runs", token, nil) - if w.Code != http.StatusOK { - t.Fatalf("list runs: want 200, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestTask_KillNoActiveRun(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("killer", "killer@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "NoRun", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - - // Kill with no active run — should 404 - w = h.request("POST", "/api/v1/tasks/"+task["id"].(string)+"/kill", token, nil) - if w.Code != http.StatusNotFound { - t.Fatalf("kill no-run: want 404, got %d: %s", w.Code, w.Body.String()) - } -} - -// ═══════════════════════════════════════════════ -// Webhook Trigger Tests (v0.28.0) -// ═══════════════════════════════════════════════ - -func TestTask_WebhookScheduleCreatesTriggerToken(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("webhooker", "webhooker@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Webhook Task", - "task_type": "prompt", - "schedule": "webhook", - "user_prompt": "process this", - "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create webhook task: want 201, got %d: %s", w.Code, w.Body.String()) - } - - var task map[string]interface{} - decode(w, &task) - - // Should have trigger_token - triggerToken, ok := task["trigger_token"].(string) - if !ok || triggerToken == "" { - t.Fatal("webhook task should have a trigger_token") - } - - // next_run_at should be nil (webhook tasks don't have cron) - if task["next_run_at"] != nil { - t.Fatalf("webhook task should have nil next_run_at, got %v", task["next_run_at"]) - } -} - -func TestTask_TriggerEndpoint(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("triguser", "triguser@test.com") - - // Create webhook task - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "CI Watcher", - "task_type": "prompt", - "schedule": "webhook", - "user_prompt": "analyze failure", - "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - triggerToken := task["trigger_token"].(string) - - // Fire the trigger (unauthenticated) - w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{ - "build_id": 12345, - "status": "failed", - "repo": "switchboard-core", - }) - if w.Code != http.StatusAccepted { - t.Fatalf("trigger: want 202, got %d: %s", w.Code, w.Body.String()) - } - var trigResp map[string]interface{} - decode(w, &trigResp) - if trigResp["triggered"] != true { - t.Fatal("expected triggered: true") - } - if trigResp["run_id"] == nil || trigResp["run_id"].(string) == "" { - t.Fatal("expected run_id") - } - - // Second trigger should 409 (queued run exists) - w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{ - "build_id": 12346, - }) - if w.Code != http.StatusConflict { - t.Fatalf("duplicate trigger: want 409, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestTask_TriggerInvalidToken(t *testing.T) { - h := setupHarness(t) - - w := h.request("POST", "/api/v1/hooks/t/nonexistent-token", "", map[string]interface{}{ - "data": "nope", - }) - if w.Code != http.StatusNotFound { - t.Fatalf("bad token: want 404, got %d", w.Code) - } -} - -func TestTask_TriggerInactiveTask(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("inactive", "inactive@test.com") - - // Create webhook task - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Deactivated", "task_type": "prompt", "schedule": "webhook", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - triggerToken := task["trigger_token"].(string) - - // Deactivate - w = h.request("PUT", "/api/v1/tasks/"+taskID, token, map[string]interface{}{ - "is_active": false, - }) - if w.Code != http.StatusOK { - t.Fatalf("deactivate: %d", w.Code) - } - - // Trigger should 410 - w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{}) - if w.Code != http.StatusGone { - t.Fatalf("inactive trigger: want 410, got %d: %s", w.Code, w.Body.String()) - } -} - -// ═══════════════════════════════════════════════ -// Action Task Tests (v0.28.0) -// ═══════════════════════════════════════════════ - -func TestTask_ActionTaskAdminAllowed(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("actionadmin", "action@test.com") - - // Admin can always create action tasks - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Action Relay", - "task_type": "action", - "schedule": "webhook", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create action task: want 201, got %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - if task["task_type"].(string) != "action" { - t.Fatalf("task_type should be action, got %q", task["task_type"]) - } -} - -// ═══════════════════════════════════════════════ -// Admin List All Tasks -// ═══════════════════════════════════════════════ - -func TestTask_AdminListAll(t *testing.T) { - h := setupHarness(t) - _, adminToken := h.createAdminUser("tadmin", "tadmin@test.com") - - // Create 2 tasks - for i := 0; i < 2; i++ { - h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ - "name": fmt.Sprintf("Admin Task %d", i), "task_type": "prompt", - "schedule": "@daily", "user_prompt": "x", "model_id": "m", - }) - } - - // Admin list - w := h.request("GET", "/api/v1/admin/tasks", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin 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("admin list: expected at least 2 tasks, got %d", len(data)) - } -} - -// ═══════════════════════════════════════════════ -// Webhook Secret / Output Channel (Audit C2, C3) -// ═══════════════════════════════════════════════ - -func TestTask_WebhookSecretGenerated(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("secretuser", "secret@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Webhook Out", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "x", "model_id": "m", - "webhook_url": "https://example.com/hook", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Verify webhook_secret was auto-generated - secret, ok := task["webhook_secret"].(string) - if !ok || secret == "" { - t.Fatal("webhook_secret should be auto-generated when webhook_url is set") - } - - // Verify it persists on GET (C2 fix) - w = h.request("GET", "/api/v1/tasks/"+taskID, token, nil) - if w.Code != http.StatusOK { - t.Fatalf("get: %d", w.Code) - } - var got map[string]interface{} - decode(w, &got) - gotSecret, ok := got["webhook_secret"].(string) - if !ok || gotSecret != secret { - t.Fatalf("webhook_secret not persisted on read: got %q, want %q", gotSecret, secret) - } -} - -// ═══════════════════════════════════════════════ -// Once-shot schedule -// ═══════════════════════════════════════════════ - -func TestTask_OnceSchedule(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("onceuser", "once@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "One Shot", "task_type": "prompt", "schedule": "once", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - - // Should have next_run_at set (approximately now) - if task["next_run_at"] == nil { - t.Fatal("once task should have next_run_at set to ~now") - } -} - -// ═══════════════════════════════════════════════ -// Budget defaults applied -// ═══════════════════════════════════════════════ - -func TestTask_DefaultBudgets(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("budget", "budget@test.com") - - // Create with zero budgets — should get defaults - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Defaults", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d: %s", w.Code, w.Body.String()) - } - var task map[string]interface{} - decode(w, &task) - - maxTokens := int(task["max_tokens"].(float64)) - maxToolCalls := int(task["max_tool_calls"].(float64)) - maxWall := int(task["max_wall_clock"].(float64)) - - if maxTokens != 4096 { - t.Fatalf("max_tokens: want 4096, got %d", maxTokens) - } - if maxToolCalls != 10 { - t.Fatalf("max_tool_calls: want 10, got %d", maxToolCalls) - } - if maxWall != 300 { - t.Fatalf("max_wall_clock: want 300, got %d", maxWall) - } -} - -// ═══════════════════════════════════════════════ -// F2: Workflow task_type rejected at API boundary -// ═══════════════════════════════════════════════ - -func TestTask_WorkflowTypeRejected(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("wfuser", "wfuser@test.com") - - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Workflow Task", - "task_type": "workflow", - "schedule": "@daily", - "workflow_id": "00000000-0000-0000-0000-000000000001", - "model_id": "m", - }) - if w.Code != http.StatusBadRequest { - t.Fatalf("workflow task_type: want 400, got %d: %s", w.Code, w.Body.String()) - } - // Verify error message mentions "not yet implemented" - body := w.Body.String() - if !strings.Contains(body, "not yet implemented") { - t.Fatalf("expected 'not yet implemented' in error, got: %s", body) - } -} - -// ═══════════════════════════════════════════════ -// Task Type RBAC (v0.28.7) -// ═══════════════════════════════════════════════ - -func TestTask_StarlarkTypeRequiresPackageID(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("staruser", "staruser@test.com") - - // Starlark tasks require system_function (package_id) - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Starlark Task", - "task_type": "starlark", - "schedule": "@daily", - "user_prompt": "x", - "model_id": "m", - }) - if w.Code != http.StatusBadRequest { - t.Fatalf("starlark task_type without package_id: want 400, got %d: %s", w.Code, w.Body.String()) - } - body := w.Body.String() - if !strings.Contains(body, "system_function") && !strings.Contains(body, "package_id") { - t.Fatalf("expected 'system_function' or 'package_id' in error, got: %s", body) - } -} - -func TestTask_SystemTypeNonAdminDenied(t *testing.T) { - h := setupHarness(t) - - // Seed Everyone group with task.create so the route middleware passes - database.TestDB.Exec(dialectSQL( - `INSERT INTO groups (id, name, source, permissions) VALUES ($1, $2, $3, $4) - ON CONFLICT (id) DO UPDATE SET permissions = $4`), - "00000000-0000-0000-0000-000000000001", "Everyone", "system", `["task.create"]`, - ) - - // Enable registration so registerUser works - 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("sysuser", "sysuser@test.com", "password123!") - - w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{ - "name": "System Task", - "task_type": "system", - "system_function": "session_cleanup", - "schedule": "@daily", - }) - if w.Code != http.StatusForbidden { - t.Fatalf("system task as non-admin: want 403, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestTask_ActionTypeNonAdminDenied(t *testing.T) { - h := setupHarness(t) - - // Seed Everyone group with task.create but NOT task.action - database.TestDB.Exec(dialectSQL( - `INSERT INTO groups (id, name, source, permissions) VALUES ($1, $2, $3, $4) - ON CONFLICT (id) DO UPDATE SET permissions = $4`), - "00000000-0000-0000-0000-000000000001", "Everyone", "system", `["task.create"]`, - ) - - // Enable registration so registerUser works - 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("actuser", "actuser@test.com", "password123!") - - w := h.request("POST", "/api/v1/tasks", userToken, map[string]interface{}{ - "name": "Action Task", - "task_type": "action", - "schedule": "webhook", - }) - if w.Code != http.StatusForbidden { - t.Fatalf("action task without task.action: want 403, got %d: %s", w.Code, w.Body.String()) - } -} - -// ═══════════════════════════════════════════════ -// F8: Team Task Routes -// ═══════════════════════════════════════════════ - -func TestTask_TeamTaskCRUD(t *testing.T) { - h := setupHarness(t) - adminID, adminToken := h.createAdminUser("teamadm", "teamadm@test.com") - - // Create a team (admin users bypass RequireTeamAdmin middleware) - teamID := seedInsertReturningID(t, - `INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`, - "Task Team", "For team task tests", adminID, - ) - - // Create team task via team route - w := h.request("POST", "/api/v1/teams/"+teamID+"/tasks", adminToken, map[string]interface{}{ - "name": "Team Task", - "task_type": "prompt", - "schedule": "@daily", - "user_prompt": "team work", - "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create team task: want 201, got %d: %s", w.Code, w.Body.String()) - } - - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Verify scope was injected as "team" - if task["scope"].(string) != "team" { - t.Fatalf("team task scope: want 'team', got %q", task["scope"]) - } - if task["team_id"] == nil || task["team_id"].(string) != teamID { - t.Fatalf("team task team_id: want %q, got %v", teamID, task["team_id"]) - } - - // List team tasks (member route — admin bypasses membership check) - w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("list team tasks: want 200, got %d: %s", w.Code, w.Body.String()) - } - var listResp map[string]interface{} - decode(w, &listResp) - data := listResp["data"].([]interface{}) - if len(data) != 1 { - t.Fatalf("expected 1 team task, got %d", len(data)) - } - - // Update via team route - w = h.request("PUT", "/api/v1/teams/"+teamID+"/tasks/"+taskID, adminToken, map[string]interface{}{ - "name": "Updated Team Task", - }) - if w.Code != http.StatusOK { - t.Fatalf("update team task: want 200, got %d: %s", w.Code, w.Body.String()) - } - var updated map[string]interface{} - decode(w, &updated) - if updated["name"].(string) != "Updated Team Task" { - t.Fatalf("name not updated: got %q", updated["name"]) - } - - // List runs via team member route - w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks/"+taskID+"/runs", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("list team task runs: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // Delete via team route - w = h.request("DELETE", "/api/v1/teams/"+teamID+"/tasks/"+taskID, adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("delete team task: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify gone - w = h.request("GET", "/api/v1/teams/"+teamID+"/tasks", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("list after delete: want 200, got %d", w.Code) - } - decode(w, &listResp) - data = listResp["data"].([]interface{}) - if len(data) != 0 { - t.Fatalf("expected 0 team tasks after delete, got %d", len(data)) - } -} - -// ═══════════════════════════════════════════════ -// F9: Admin Run / Kill / Delete -// ═══════════════════════════════════════════════ - -func TestTask_AdminRunNow(t *testing.T) { - h := setupHarness(t) - _, adminToken := h.createAdminUser("admrun", "admrun@test.com") - - // Create a task - w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ - "name": "Admin Runnable", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "go", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Admin run via admin route - w = h.request("POST", "/api/v1/admin/tasks/"+taskID+"/run", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin run: want 200, got %d: %s", w.Code, w.Body.String()) - } - var runResp map[string]interface{} - decode(w, &runResp) - if runResp["scheduled"] != true { - t.Fatal("expected scheduled: true") - } -} - -func TestTask_AdminKillRun(t *testing.T) { - h := setupHarness(t) - _, adminToken := h.createAdminUser("admkill", "admkill@test.com") - - // Create a webhook task and trigger it to get a queued run - w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ - "name": "Killable", "task_type": "prompt", "schedule": "webhook", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - triggerToken := task["trigger_token"].(string) - - // Trigger to create a queued run - w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true}) - if w.Code != http.StatusAccepted { - t.Fatalf("trigger: %d", w.Code) - } - var trigResp map[string]interface{} - decode(w, &trigResp) - runID := trigResp["run_id"].(string) - - // Manually transition the run to "running" so kill works - database.TestDB.Exec(dialectSQL("UPDATE task_runs SET status = 'running' WHERE id = $1"), runID) - - // Admin kill - w = h.request("POST", "/api/v1/admin/tasks/"+taskID+"/kill", adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin kill: want 200, got %d: %s", w.Code, w.Body.String()) - } - var killResp map[string]interface{} - decode(w, &killResp) - if killResp["killed"] != true { - t.Fatal("expected killed: true") - } -} - -func TestTask_AdminDelete(t *testing.T) { - h := setupHarness(t) - _, adminToken := h.createAdminUser("admdel", "admdel@test.com") - - // Create a task - w := h.request("POST", "/api/v1/tasks", adminToken, map[string]interface{}{ - "name": "Admin Deletable", "task_type": "prompt", "schedule": "@daily", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - - // Admin delete - w = h.request("DELETE", "/api/v1/admin/tasks/"+taskID, adminToken, nil) - if w.Code != http.StatusOK { - t.Fatalf("admin delete: want 200, got %d: %s", w.Code, w.Body.String()) - } - - // Verify gone - w = h.request("GET", "/api/v1/tasks/"+taskID, adminToken, nil) - if w.Code != http.StatusNotFound { - t.Fatalf("get deleted: want 404, got %d", w.Code) - } -} - -// ═══════════════════════════════════════════════ -// F10: RunNow 409 Conflict (queued run exists) -// ═══════════════════════════════════════════════ - -func TestTask_RunNowConflictQueued(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("conflict", "conflict@test.com") - - // Create a webhook task - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Conflict Test", "task_type": "prompt", "schedule": "webhook", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - triggerToken := task["trigger_token"].(string) - - // Trigger to create a queued run - w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true}) - if w.Code != http.StatusAccepted { - t.Fatalf("trigger: %d", w.Code) - } - - // RunNow should 409 because a queued run exists - w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil) - if w.Code != http.StatusConflict { - t.Fatalf("run-now with queued run: want 409, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestTask_RunNowConflictActive(t *testing.T) { - h := setupHarness(t) - _, token := h.createAdminUser("active", "active@test.com") - - // Create a webhook task - w := h.request("POST", "/api/v1/tasks", token, map[string]interface{}{ - "name": "Active Conflict", "task_type": "prompt", "schedule": "webhook", - "user_prompt": "x", "model_id": "m", - }) - if w.Code != http.StatusCreated { - t.Fatalf("create: %d", w.Code) - } - var task map[string]interface{} - decode(w, &task) - taskID := task["id"].(string) - triggerToken := task["trigger_token"].(string) - - // Trigger and promote to running - w = h.request("POST", "/api/v1/hooks/t/"+triggerToken, "", map[string]interface{}{"test": true}) - if w.Code != http.StatusAccepted { - t.Fatalf("trigger: %d", w.Code) - } - var trigResp map[string]interface{} - decode(w, &trigResp) - runID := trigResp["run_id"].(string) - database.TestDB.Exec(dialectSQL("UPDATE task_runs SET status = 'running' WHERE id = $1"), runID) - - // RunNow should 409 because an active run exists - w = h.request("POST", "/api/v1/tasks/"+taskID+"/run", token, nil) - if w.Code != http.StatusConflict { - t.Fatalf("run-now with active run: want 409, got %d: %s", w.Code, w.Body.String()) - } -} - diff --git a/server/handlers/tasks.go b/server/handlers/tasks.go deleted file mode 100644 index 524a7cd..0000000 --- a/server/handlers/tasks.go +++ /dev/null @@ -1,590 +0,0 @@ -package handlers - -import ( - "io" - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/auth" - "switchboard-core/middleware" - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/taskutil" - "switchboard-core/webhook" -) - -// TaskHandler manages task CRUD and manual execution. -type TaskHandler struct { - stores store.Stores -} - -func NewTaskHandler(stores store.Stores) *TaskHandler { - return &TaskHandler{stores: stores} -} - -// ── List endpoints ────────────────────────────── - -// ListMine returns tasks owned by the current user. -// GET /api/v1/tasks -func (h *TaskHandler) ListMine(c *gin.Context) { - userID := c.GetString("user_id") - tasks, err := h.stores.Tasks.ListByOwner(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"}) - return - } - if tasks == nil { - tasks = []models.Task{} - } - c.JSON(http.StatusOK, gin.H{"data": tasks}) -} - -// ListAll returns all tasks (admin only). -// GET /api/v1/admin/tasks -func (h *TaskHandler) ListAll(c *gin.Context) { - tasks, err := h.stores.Tasks.ListAll(c.Request.Context()) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tasks"}) - return - } - if tasks == nil { - tasks = []models.Task{} - } - c.JSON(http.StatusOK, gin.H{"data": tasks}) -} - -// ListTeamTasks returns tasks scoped to the given team. -// Any team member can view; RequireTeamMember middleware enforces access. -// GET /api/v1/teams/:teamId/tasks -func (h *TaskHandler) ListTeamTasks(c *gin.Context) { - teamID := c.Param("teamId") - tasks, err := h.stores.Tasks.ListByTeam(c.Request.Context(), teamID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team tasks"}) - return - } - if tasks == nil { - tasks = []models.Task{} - } - c.JSON(http.StatusOK, gin.H{"data": tasks}) -} - -// ── Create ────────────────────────────────────── - -// CreateTeamTask creates a task scoped to the team. -// Team admins only (RequireTeamAdmin middleware). -// POST /api/v1/teams/:teamId/tasks -func (h *TaskHandler) CreateTeamTask(c *gin.Context) { - teamID := c.Param("teamId") - // Inject team context, then delegate to Create - c.Set("force_team_id", teamID) - c.Set("force_scope", "team") - h.Create(c) -} - -// Create creates a new task. -// POST /api/v1/tasks -func (h *TaskHandler) Create(c *gin.Context) { - ctx := c.Request.Context() - - // v0.27.2: Check global task configuration - taskCfg := taskutil.LoadTaskConfig(ctx, h.stores.GlobalConfig) - if !taskCfg.Enabled { - c.JSON(http.StatusForbidden, gin.H{"error": "tasks are disabled by the administrator"}) - return - } - - var t models.Task - if err := c.ShouldBindJSON(&t); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - t.OwnerID = c.GetString("user_id") - - // v0.27.5: Team task context override (set by CreateTeamTask) - if forceTeam, ok := c.Get("force_team_id"); ok { - teamID := forceTeam.(string) - t.TeamID = &teamID - t.Scope = "team" - } - if forceScope, ok := c.Get("force_scope"); ok { - t.Scope = forceScope.(string) - } - - // v0.27.2: Personal task check — non-admin users need tasks.allow_personal - if t.Scope == "personal" || t.Scope == "" { - if c.GetString("role") != "admin" && !taskCfg.AllowPersonal { - c.JSON(http.StatusForbidden, gin.H{"error": "personal tasks are disabled — contact your administrator"}) - return - } - } - - // v0.28.0: Action tasks require task.action permission - if t.TaskType == "action" { - if c.GetString("role") != "admin" { - perms := middleware.GetResolvedPermissions(c) - if perms == nil || !perms[auth.PermTaskAction] { - c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + auth.PermTaskAction}) - return - } - } - } - - // v0.29.0: Starlark tasks — run sandboxed extension scripts. - // Requires task.starlark permission. system_function holds package ID. - if t.TaskType == "starlark" { - if c.GetString("role") != "admin" { - perms := middleware.GetResolvedPermissions(c) - if perms == nil || !perms[auth.PermTaskStarlark] { - c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + auth.PermTaskStarlark}) - return - } - } - if t.SystemFunction == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "system_function (package_id) is required for starlark tasks"}) - return - } - // Verify package exists and is starlark tier - pkg, err := h.stores.Packages.Get(c.Request.Context(), t.SystemFunction) - if err != nil || pkg == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "package not found: " + t.SystemFunction}) - return - } - if pkg.Tier != models.ExtTierStarlark { - c.JSON(http.StatusBadRequest, gin.H{"error": "package " + t.SystemFunction + " is not a starlark package"}) - return - } - } - - // v0.28.0-audit: Workflow task execution is not yet implemented. - // Reject at the API boundary to prevent silent wrong behavior - // (workflow tasks would fall through to the prompt pipeline). - if t.TaskType == "workflow" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow task execution is not yet implemented — use task_type 'prompt' or 'action'"}) - return - } - - // v0.28.6: System tasks — admin-only, requires valid system_function - if t.TaskType == "system" { - if c.GetString("role") != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "system tasks are admin-only"}) - return - } - if t.SystemFunction == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "system_function is required for system tasks"}) - return - } - if err := taskutil.ValidateSystemFunc(t.SystemFunction); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - } - - // Validate required fields - if t.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) - return - } - if t.Schedule == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "schedule is required"}) - return - } - if t.TaskType == "prompt" && t.UserPrompt == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "user_prompt is required for prompt tasks"}) - return - } - - // v0.28.0: Webhook schedule validation — cannot use cron for webhook-triggered tasks - if t.Schedule == "webhook" { - // Webhook tasks never have a cron schedule — they fire on inbound POST - } else { - // v0.27.2: Validate cron expression before persisting - if err := taskutil.ValidateCron(t.Schedule); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()}) - return - } - } - - // Defaults - if t.Scope == "" { - t.Scope = "personal" - } - if t.TaskType == "" { - t.TaskType = "prompt" - } - if t.Timezone == "" { - t.Timezone = "UTC" - } - if t.OutputMode == "" { - t.OutputMode = "channel" - } - - // v0.27.2: Apply global default budgets for zero-value fields - taskCfg.ApplyDefaults(&t) - - // Compute initial next_run_at (webhook tasks have no schedule-based next_run) - if t.Schedule == "webhook" { - // No initial next_run_at — triggered externally - t.NextRunAt = nil - } else if t.Schedule == "once" { - now := time.Now().UTC() - t.NextRunAt = &now - } else { - // v0.27.2: Full cron parsing via robfig/cron/v3 - t.NextRunAt = taskutil.NextRunFromSchedule(t.Schedule, t.Timezone) - } - - t.IsActive = true - - // v0.27.3: Generate webhook secret if webhook URL is provided - if t.WebhookURL != "" && t.WebhookSecret == "" { - t.WebhookSecret = webhook.GenerateSecret() - } - - // v0.28.0: Generate trigger token for webhook-scheduled tasks - if t.Schedule == "webhook" { - t.TriggerToken = webhook.GenerateSecret() - } - - if err := h.stores.Tasks.Create(ctx, &t); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create task: " + err.Error()}) - return - } - - c.JSON(http.StatusCreated, t) -} - -// ── Access control helpers ────────────────────── - -// canAccessTask returns true if the user can view this task. -// Access: owner, system admin, or team member (for team-scoped tasks). -func (h *TaskHandler) canAccessTask(c *gin.Context, t *models.Task) bool { - if c.GetString("role") == "admin" { - return true - } - if t.OwnerID == c.GetString("user_id") { - return true - } - // Team members can view team-scoped tasks - if t.Scope == "team" && t.TeamID != nil { - ok, _ := h.stores.Teams.IsMember(c.Request.Context(), *t.TeamID, c.GetString("user_id")) - return ok - } - return false -} - -// canMutateTask returns true if the user can edit/delete/run this task. -// Mutation: owner, system admin, or team admin (for team-scoped tasks). -func (h *TaskHandler) canMutateTask(c *gin.Context, t *models.Task) bool { - if c.GetString("role") == "admin" { - return true - } - if t.OwnerID == c.GetString("user_id") { - return true - } - if t.Scope == "team" && t.TeamID != nil { - ok, _ := h.stores.Teams.IsTeamAdmin(c.Request.Context(), *t.TeamID, c.GetString("user_id")) - return ok - } - return false -} - -// ── Get / Update / Delete ─────────────────────── - -// Get returns a single task. -// GET /api/v1/tasks/:id -func (h *TaskHandler) Get(c *gin.Context) { - id := c.Param("id") - t, err := h.stores.Tasks.GetByID(c.Request.Context(), id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if !h.canAccessTask(c, t) { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - c.JSON(http.StatusOK, t) -} - -// Update patches a task. -// PUT /api/v1/tasks/:id -func (h *TaskHandler) Update(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - // Ownership check - t, err := h.stores.Tasks.GetByID(ctx, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if !h.canMutateTask(c, t) { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - - var patch models.TaskPatch - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // v0.27.2: Validate new schedule if provided - if patch.Schedule != nil && *patch.Schedule != "webhook" { - if err := taskutil.ValidateCron(*patch.Schedule); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid schedule: " + err.Error()}) - return - } - } - - if err := h.stores.Tasks.Update(ctx, id, patch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update task"}) - return - } - - updated, _ := h.stores.Tasks.GetByID(ctx, id) - - // Recompute next_run_at if schedule or timezone changed - if patch.Schedule != nil || patch.Timezone != nil { - if updated.Schedule == "webhook" { - // Webhook tasks have no cron-based next_run - _ = h.stores.Tasks.SetNextRun(ctx, id, nil) - updated.NextRunAt = nil - } else { - tz := updated.Timezone - next := taskutil.NextRunFromSchedule(updated.Schedule, tz) - _ = h.stores.Tasks.SetNextRun(ctx, id, next) - updated.NextRunAt = next - } - } - - c.JSON(http.StatusOK, updated) -} - -// Delete removes a task. -// DELETE /api/v1/tasks/:id -func (h *TaskHandler) Delete(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - t, err := h.stores.Tasks.GetByID(ctx, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if !h.canMutateTask(c, t) { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - - if err := h.stores.Tasks.Delete(ctx, id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete task"}) - return - } - c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id}) -} - -// ── Run History ───────────────────────────────── - -// ListRuns returns run history for a task. -// GET /api/v1/tasks/:id/runs -func (h *TaskHandler) ListRuns(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - // Access check (v0.27.5) - t, err := h.stores.Tasks.GetByID(ctx, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if !h.canAccessTask(c, t) { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - - runs, err := h.stores.Tasks.ListRuns(ctx, id, 50) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list runs"}) - return - } - if runs == nil { - runs = []models.TaskRun{} - } - c.JSON(http.StatusOK, gin.H{"data": runs}) -} - -// ── RunNow / KillRun ──────────────────────────── - -// RunNow triggers immediate execution of a task (sets next_run_at to now). -// POST /api/v1/tasks/:id/run -func (h *TaskHandler) RunNow(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - // v0.27.2: Check tasks enabled - taskCfg := taskutil.LoadTaskConfig(ctx, h.stores.GlobalConfig) - if !taskCfg.Enabled { - c.JSON(http.StatusForbidden, gin.H{"error": "tasks are disabled by the administrator"}) - return - } - - t, err := h.stores.Tasks.GetByID(ctx, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if !h.canMutateTask(c, t) { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - - // Check for already-running execution - active, _ := h.stores.Tasks.GetActiveRun(ctx, id) - if active != nil { - c.JSON(http.StatusConflict, gin.H{"error": "task already has an active run"}) - return - } - - // Also check for queued runs - queued, _ := h.stores.Tasks.GetQueuedRun(ctx, id) - if queued != nil { - c.JSON(http.StatusConflict, gin.H{"error": "task already has a queued run"}) - return - } - - now := time.Now().UTC() - if err := h.stores.Tasks.SetNextRun(ctx, id, now); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"}) - return - } - - c.JSON(http.StatusOK, gin.H{"scheduled": true, "next_run_at": now}) -} - -// KillRun cancels the active run of a task. -// POST /api/v1/tasks/:id/kill -func (h *TaskHandler) KillRun(c *gin.Context) { - id := c.Param("id") - ctx := c.Request.Context() - - t, err := h.stores.Tasks.GetByID(ctx, id) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - if !h.canMutateTask(c, t) { - c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) - return - } - - active, _ := h.stores.Tasks.GetActiveRun(ctx, id) - if active == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no active run to cancel"}) - return - } - - if err := h.stores.Tasks.UpdateRun(ctx, active.ID, "cancelled", active.TokensUsed, active.ToolCalls, active.WallClock, "killed by user"); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel run"}) - return - } - - c.JSON(http.StatusOK, gin.H{"killed": true, "run_id": active.ID}) -} - -// ════════════════════════════════════════════════ -// Webhook Trigger Handler (v0.28.0) -// ════════════════════════════════════════════════ -// -// POST /api/v1/hooks/t/:token — unauthenticated, token-based auth. -// External systems (CI, task chaining, etc.) POST here to fire a -// webhook-triggered task. The request body is stored as trigger_payload -// and forwarded to the executor. - -// TriggerHandler handles inbound webhook triggers. -type TriggerHandler struct { - stores store.Stores -} - -func NewTriggerHandler(stores store.Stores) *TriggerHandler { - return &TriggerHandler{stores: stores} -} - -// Handle processes an inbound webhook trigger. -// POST /api/v1/hooks/t/:token -func (h *TriggerHandler) Handle(c *gin.Context) { - token := c.Param("token") - ctx := c.Request.Context() - - // Look up task by trigger token - task, err := h.stores.Tasks.GetByTriggerToken(ctx, token) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - - // Validate task state - if !task.IsActive { - c.JSON(http.StatusGone, gin.H{"error": "task is inactive"}) - return - } - if task.Schedule != "webhook" { - c.JSON(http.StatusBadRequest, gin.H{"error": "task is not webhook-triggered"}) - return - } - - // Check for already-active or queued run - active, _ := h.stores.Tasks.GetActiveRun(ctx, task.ID) - if active != nil { - c.JSON(http.StatusConflict, gin.H{"error": "task already has an active run"}) - return - } - queued, _ := h.stores.Tasks.GetQueuedRun(ctx, task.ID) - if queued != nil { - c.JSON(http.StatusConflict, gin.H{"error": "task already has a queued run"}) - return - } - - // Read trigger payload (optional) - var triggerPayload string - if c.Request.Body != nil { - body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) // 1MB limit - if err == nil && len(body) > 0 { - triggerPayload = string(body) - } - } - - // Create a queued run with the trigger payload - run := &models.TaskRun{ - TaskID: task.ID, - Status: "queued", - TriggerPayload: triggerPayload, - } - if err := h.stores.Tasks.CreateRun(ctx, run); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create run"}) - return - } - - // Set next_run_at to now so the scheduler picks it up - now := time.Now().UTC() - if err := h.stores.Tasks.SetNextRun(ctx, task.ID, now); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to schedule run"}) - return - } - - c.JSON(http.StatusAccepted, gin.H{ - "triggered": true, - "run_id": run.ID, - "task_id": task.ID, - }) -} - -// ListSystemFunctions returns the available system function names and descriptions. -// GET /admin/system-functions -func (h *TaskHandler) ListSystemFunctions(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"data": taskutil.ListSystemFuncs()}) -} diff --git a/server/handlers/teams.go b/server/handlers/teams.go index b1a0d2b..c9f9a16 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -14,7 +14,6 @@ import ( "switchboard-core/crypto" "switchboard-core/database" "switchboard-core/models" - "switchboard-core/providers" "switchboard-core/store" ) @@ -346,91 +345,6 @@ func (h *TeamHandler) MyTeams(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": teams}) } -// ── Team Models: Available for Personas ────── - -// ListAvailableModels returns models with visibility 'enabled' or 'team' -// for team admins building personas. Requires RequireTeamAdmin middleware. -// GET /api/v1/teams/:teamId/models -func (h *TeamHandler) ListAvailableModels(c *gin.Context) { - teamID := getTeamID(c) - ctx := c.Request.Context() - - type availableModel struct { - ID string `json:"id"` - ModelID string `json:"model_id"` - DisplayName *string `json:"display_name"` - Visibility string `json:"visibility"` - Provider string `json:"provider"` - ProviderName string `json:"provider_name"` - Source string `json:"source"` - } - - result := make([]availableModel, 0) - - // ── 1. Global admin models (synced in model_catalog) ── - catalogModels, err := h.stores.Catalog.ListTeamAvailable(ctx) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) - return - } - for _, cm := range catalogModels { - result = append(result, availableModel{ - ID: cm.ID, - ModelID: cm.ModelID, - DisplayName: cm.DisplayName, - Visibility: cm.Visibility, - Provider: cm.Provider, - ProviderName: cm.ProviderName, - Source: "global", - }) - } - - // ── 2. Team provider models (live query) ── - teamConfigs, err := h.stores.Providers.ListForTeam(ctx, teamID) - if err == nil { - for _, cfg := range teamConfigs { - provider, pErr := providers.Get(cfg.Provider) - if pErr != nil { - continue - } - - key := "" - if cfg.HasKey() { - key = string(cfg.APIKeyEnc) - } - - var customHeaders map[string]string - if cfg.Headers != nil { - b, _ := json.Marshal(cfg.Headers) - _ = json.Unmarshal(b, &customHeaders) - } - - provModels, lErr := provider.ListModels(ctx, providers.ProviderConfig{ - Endpoint: cfg.Endpoint, - APIKey: key, - CustomHeaders: customHeaders, - }) - if lErr != nil { - log.Printf("[models] team provider %q list failed: %v", cfg.Name, lErr) - continue - } - - for _, pm := range provModels { - result = append(result, availableModel{ - ID: pm.ID, - ModelID: pm.ID, - Provider: cfg.Provider, - ProviderName: cfg.Name, - Visibility: "enabled", - Source: "team", - }) - } - } - } - - c.JSON(http.StatusOK, gin.H{"data": result}) -} - // ── Helpers ───────────────────────────────── // getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes). @@ -441,30 +355,6 @@ func getTeamID(c *gin.Context) string { return c.Param("id") } -// enforcePrivateProviderPolicy checks if a user belongs to any team that -// requires private providers, and if so, verifies the resolved config is -// marked as private. Returns nil if allowed, error if blocked. -func enforcePrivateProviderPolicy(ctx context.Context, stores store.Stores, userID, configID string) error { - if configID == "" { - return nil - } - - requiresPrivate, err := stores.Teams.HasPrivateProviderRequirement(ctx, userID) - if err != nil || !requiresPrivate { - return nil - } - - // User is in a restricted team — verify the config is private - cfg, err := stores.Providers.GetByID(ctx, configID) - if err != nil { - return nil // config lookup failed, allow (fail open) - } - if !cfg.IsPrivate { - return fmt.Errorf("your team requires private providers — this provider sends data externally") - } - return nil -} - // ── Team Audit Log (scoped to team members) ─ func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { diff --git a/server/handlers/workflow_assignments.go b/server/handlers/workflow_assignments.go deleted file mode 100644 index 8e1b790..0000000 --- a/server/handlers/workflow_assignments.go +++ /dev/null @@ -1,346 +0,0 @@ -package handlers - -// workflow_assignments.go — Assignment queue for human review stages. -// -// v0.29.0: Raw SQL replaced with WorkflowStore + ChannelStore methods. - -import ( - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/events" - "switchboard-core/notifications" - "switchboard-core/store" -) - -// ── Workflow Assignment Handler ───────────── - -type WorkflowAssignmentHandler struct { - stores store.Stores - hub *events.Hub -} - -func NewWorkflowAssignmentHandler(stores store.Stores, hub ...*events.Hub) *WorkflowAssignmentHandler { - h := &WorkflowAssignmentHandler{stores: stores} - if len(hub) > 0 { - h.hub = hub[0] - } - return h -} - -// ListForTeam returns unassigned + claimed assignments for a team. -// GET /api/v1/teams/:teamId/assignments -func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) { - teamID := c.Param("teamId") - status := c.DefaultQuery("status", "unassigned") - - result, err := h.stores.Workflows.ListAssignmentsForTeam(c.Request.Context(), teamID, status) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": result}) -} - -// ListMine returns assignments claimed by the current user plus -// unassigned assignments for teams the user belongs to. -// GET /api/v1/workflow-assignments/mine -func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) { - userID := c.GetString("user_id") - - result, err := h.stores.Workflows.ListAssignmentsMine(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"}) - return - } - c.JSON(http.StatusOK, gin.H{"data": result}) -} - -// Claim assigns a workflow to the current user via optimistic lock. -// POST /api/v1/workflow-assignments/:id/claim -func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) { - assignmentID := c.Param("id") - userID := c.GetString("user_id") - now := time.Now().UTC() - - n, err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"}) - return - } - if n == 0 { - c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"}) - return - } - - // Look up channel for WS delivery + notification - channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) - - // Emit workflow.claimed WS event to all user participants in the channel - if h.hub != nil && channelID != "" { - payload, _ := json.Marshal(map[string]any{ - "assignment_id": assignmentID, - "claimed_by": userID, - "channel_id": channelID, - }) - evt := events.Event{ - Label: "workflow.claimed", - Payload: payload, - Ts: now.UnixMilli(), - } - pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") - for _, uid := range pids { - h.hub.PublishToUser(uid, evt) - } - } - - // Persist notification for bell/inbox - if svc := notifications.Default(); svc != nil && channelID != "" { - notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID) - } - - c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID}) -} - -// Complete marks an assignment as completed. -// POST /api/v1/workflow-assignments/:id/complete -func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) { - assignmentID := c.Param("id") - - n, err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"}) - return - } - if n == 0 { - c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) - return - } - c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID}) -} - -// ── Lifecycle operations (v0.37.15) ── - -// Unclaim returns a claimed assignment to unassigned. -// POST /api/v1/workflow-assignments/:id/unclaim -func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) { - assignmentID := c.Param("id") - userID := c.GetString("user_id") - role, _ := c.Get("role") - - // Auth: claimer or team admin or global admin - a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) - if err != nil || a == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) - return - } - - if role != "admin" && (a.AssignedTo == nil || *a.AssignedTo != userID) { - isTA := false - if h.stores.Teams != nil { - isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID) - } - if !isTA { - c.JSON(http.StatusForbidden, gin.H{"error": "only the claimer or a team admin can unclaim"}) - return - } - } - - n, err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unclaim assignment"}) - return - } - if n == 0 { - c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) - return - } - - // Emit WS event - channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) - if h.hub != nil && channelID != "" { - payload, _ := json.Marshal(map[string]any{ - "assignment_id": assignmentID, - "unclaimed_by": userID, - "channel_id": channelID, - }) - evt := events.Event{ - Label: "workflow.unclaimed", - Payload: payload, - Ts: time.Now().UnixMilli(), - } - pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") - for _, uid := range pids { - h.hub.PublishToUser(uid, evt) - } - } - - c.JSON(http.StatusOK, gin.H{"unclaimed": true, "assignment_id": assignmentID}) -} - -// Reassign changes the assigned_to on a claimed assignment. -// POST /api/v1/workflow-assignments/:id/reassign -func (h *WorkflowAssignmentHandler) Reassign(c *gin.Context) { - assignmentID := c.Param("id") - userID := c.GetString("user_id") - role, _ := c.Get("role") - - var body struct { - UserID string `json:"user_id"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) - return - } - - // Auth: team admin or global admin - a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) - if err != nil || a == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) - return - } - - if role != "admin" { - isTA := false - if h.stores.Teams != nil { - isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID) - } - if !isTA { - c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"}) - return - } - } - - n, err := h.stores.Workflows.ReassignAssignment(c.Request.Context(), assignmentID, body.UserID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reassign assignment"}) - return - } - if n == 0 { - c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"}) - return - } - - // Emit WS event - channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID) - if h.hub != nil && channelID != "" { - payload, _ := json.Marshal(map[string]any{ - "assignment_id": assignmentID, - "reassigned_by": userID, - "new_assignee": body.UserID, - "channel_id": channelID, - }) - evt := events.Event{ - Label: "workflow.reassigned", - Payload: payload, - Ts: time.Now().UnixMilli(), - } - pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "") - for _, uid := range pids { - h.hub.PublishToUser(uid, evt) - } - } - - c.JSON(http.StatusOK, gin.H{"reassigned": true, "assignment_id": assignmentID, "new_assignee": body.UserID}) -} - -// CancelAssignment cancels a single assignment. -// POST /api/v1/workflow-assignments/:id/cancel -func (h *WorkflowAssignmentHandler) CancelAssignment(c *gin.Context) { - assignmentID := c.Param("id") - userID := c.GetString("user_id") - role, _ := c.Get("role") - - // Auth: team admin or global admin - a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) - if err != nil || a == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"}) - return - } - - if role != "admin" { - isTA := false - if h.stores.Teams != nil { - isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID) - } - if !isTA { - c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"}) - return - } - } - - n, err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel assignment"}) - return - } - if n == 0 { - c.JSON(http.StatusConflict, gin.H{"error": "assignment not in cancellable state"}) - return - } - - c.JSON(http.StatusOK, gin.H{"cancelled": true, "assignment_id": assignmentID}) -} - -// CommentOnAssignment adds a review comment to an assignment. -// POST /api/v1/workflow-assignments/:id/comment -func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) { - assignmentID := c.Param("id") - userID := c.GetString("user_id") - - var body struct { - Text string `json:"text"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"}) - return - } - - comment := store.ReviewComment{ - Text: body.Text, - UserID: userID, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - - if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment}) -} - -// GetAssignment returns a single assignment with review comments. -// GET /api/v1/workflow-assignments/:id -func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) { - assignmentID := c.Param("id") - - a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)}) - return - } - - // Parse review_comments for the response - var comments []store.ReviewComment - if len(a.ReviewComments) > 0 { - _ = json.Unmarshal(a.ReviewComments, &comments) - } - - c.JSON(http.StatusOK, gin.H{ - "id": a.ID, - "channel_id": a.ChannelID, - "stage": a.Stage, - "team_id": a.TeamID, - "assigned_to": a.AssignedTo, - "status": a.Status, - "review_comments": comments, - "created_at": a.CreatedAt, - "claimed_at": a.ClaimedAt, - "completed_at": a.CompletedAt, - }) -} diff --git a/server/handlers/workflow_entry.go b/server/handlers/workflow_entry.go deleted file mode 100644 index 96eeb9d..0000000 --- a/server/handlers/workflow_entry.go +++ /dev/null @@ -1,137 +0,0 @@ -package handlers - -import ( - "fmt" - "log" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── Workflow Entry Handler ────────────────── -// Handles the visitor-facing workflow start flow. -// Landing page rendering is in pages/pages.go (RenderWorkflowLanding). - -type WorkflowEntryHandler struct { - stores store.Stores -} - -func NewWorkflowEntryHandler(stores store.Stores) *WorkflowEntryHandler { - return &WorkflowEntryHandler{stores: stores} -} - -// StartVisitor creates an anonymous session + workflow instance for a visitor. -// POST /api/v1/workflow-entry/:scope/:slug -func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) { - ctx := c.Request.Context() - scope := c.Param("scope") - slug := c.Param("slug") - - var teamID *string - if scope != "global" { - teamID = &scope - } - - wf, err := h.stores.Workflows.GetBySlug(ctx, teamID, slug) - if err != nil || wf == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) - return - } - if !wf.IsActive { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow not active"}) - return - } - if wf.EntryMode != "public_link" { - c.JSON(http.StatusForbidden, gin.H{"error": "workflow requires authentication"}) - return - } - - ver, err := h.stores.Workflows.GetLatestVersion(ctx, wf.ID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version"}) - return - } - - stages, err := h.stores.Workflows.ListStages(ctx, wf.ID) - if err != nil || len(stages) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"}) - return - } - - // Create the workflow channel (owned by workflow creator) - ch := &models.Channel{ - UserID: wf.CreatedBy, - Title: wf.Name, - Description: wf.Description, - Type: "workflow", - TeamID: wf.TeamID, - } - if err := h.stores.Channels.Create(ctx, ch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"}) - return - } - - // Set workflow columns - err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wf.ID, ver.VersionNumber, []byte("{}"), "active") - if err != nil { - log.Printf("Failed to set workflow columns: %v", err) - } - // Enable anonymous access + auto AI mode for visitor entry - _ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{ - "allow_anonymous": true, - "ai_mode": "auto", - }) - - // Create anonymous session - sessionToken := store.NewID() - visitorCount, _ := h.stores.Sessions.CountForChannel(ctx, ch.ID) - displayName := "Visitor" - if visitorCount > 0 { - displayName = fmt.Sprintf("Visitor %d", visitorCount+1) - } - sess := &models.SessionParticipant{ - SessionToken: sessionToken, - ChannelID: ch.ID, - DisplayName: displayName, - } - if err := h.stores.Sessions.Create(ctx, sess); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"}) - return - } - - // Add session as channel participant - _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ - ChannelID: ch.ID, - ParticipantType: "session", - ParticipantID: sess.ID, - Role: "visitor", - }) - - // Bind stage 0 persona - if stages[0].PersonaID != nil { - _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ - ChannelID: ch.ID, - ParticipantType: "persona", - ParticipantID: *stages[0].PersonaID, - Role: "member", - }) - } - - // Set session cookie (30 day expiry, matching v0.24.3) - c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true) - - // Redirect to workflow page — visitor lands on existing /w/:channelId - stageMode := stages[0].StageMode - if stageMode == "" { - stageMode = "chat_only" - } - c.JSON(http.StatusCreated, gin.H{ - "channel_id": ch.ID, - "session_id": sess.ID, - "redirect_to": "/w/" + ch.ID, - "stage_mode": stageMode, - }) -} diff --git a/server/handlers/workflow_forms.go b/server/handlers/workflow_forms.go deleted file mode 100644 index c2764cf..0000000 --- a/server/handlers/workflow_forms.go +++ /dev/null @@ -1,288 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "log" - "net/http" - - "github.com/gin-gonic/gin" - - "switchboard-core/events" - "switchboard-core/models" - "switchboard-core/sandbox" - "switchboard-core/store" - "switchboard-core/tools" - "switchboard-core/workflow" - - "go.starlark.net/starlark" -) - -// ── Workflow Form Handler ───────────────────── -// Handles typed form submission for workflow stages. - -type WorkflowFormHandler struct { - stores store.Stores - runner *sandbox.Runner - hub *events.Hub -} - -func NewWorkflowFormHandler(stores store.Stores, runner *sandbox.Runner, hub *events.Hub) *WorkflowFormHandler { - return &WorkflowFormHandler{stores: stores, runner: runner, hub: hub} -} - -// GetFormTemplate returns the current stage's typed form template. -// GET /api/v1/w/:id/form -func (h *WorkflowFormHandler) GetFormTemplate(c *gin.Context) { - ctx := c.Request.Context() - channelID := c.Param("id") - - ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - if ws.WorkflowID == nil || *ws.WorkflowID == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "not a workflow channel"}) - return - } - - stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) - if err != nil || ws.CurrentStage >= len(stages) { - c.JSON(http.StatusNotFound, gin.H{"error": "stage not found"}) - return - } - - stage := stages[ws.CurrentStage] - tpl := models.ParseTypedFormTemplate(stage.FormTemplate) - - // Check if form was already submitted for this stage - var formSubmitted bool - if len(ws.StageData) > 0 { - var sd map[string]interface{} - if json.Unmarshal(ws.StageData, &sd) == nil { - _, formSubmitted = sd["_form_submitted"] - } - } - - c.JSON(http.StatusOK, gin.H{ - "stage_name": stage.Name, - "stage_mode": stage.StageMode, - "form_template": tpl, - "form_submitted": formSubmitted, - "current_stage": ws.CurrentStage, - "status": ws.Status, - }) -} - -// SubmitForm handles form data submission for a workflow stage. -// POST /api/v1/w/:id/form-submit -func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) { - ctx := c.Request.Context() - channelID := c.Param("id") - - // 1. Verify workflow channel - ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - if ws.Status != "active" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + ws.Status + ", cannot submit form"}) - return - } - if ws.WorkflowID == nil || *ws.WorkflowID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "not a workflow channel"}) - return - } - - // 2. Load stage definition - stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) - if err != nil || ws.CurrentStage >= len(stages) { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage not found"}) - return - } - stage := stages[ws.CurrentStage] - - if stage.StageMode == models.StageModeChatOnly { - c.JSON(http.StatusBadRequest, gin.H{"error": "this stage does not accept form submissions"}) - return - } - - // 3. Parse typed form template - tpl := models.ParseTypedFormTemplate(stage.FormTemplate) - if tpl == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage has no typed form template"}) - return - } - - // 4. Parse submitted data (flat fields per ICD spec) - var formData map[string]interface{} - if err := c.ShouldBindJSON(&formData); err != nil || len(formData) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "form data is required"}) - return - } - - // 5. Go-level validation - if errs := models.ValidateFormData(tpl, formData); len(errs) > 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": errs}) - return - } - - // 6. Starlark validate hook (if configured) - if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.Validate != "" { - if hookErrs := h.runValidateHook(c, tpl.Hooks, channelID, formData, ws.StageData, stage.Name); hookErrs != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": hookErrs}) - return - } - } - - // 7. Merge into stage_data - formData["_form_submitted"] = true - dataJSON, _ := json.Marshal(formData) - mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, dataJSON) - - // 8. Starlark on_submit hook (fire-and-forget) - if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.OnSubmit != "" { - go h.runOnSubmitHook(tpl.Hooks, channelID, formData, json.RawMessage(mergedData), stage.Name) - } - - // 9. Auto-advance if form_only + auto_transition - if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition { - nextStage, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData)) - if nextStage >= len(stages) { - // Complete - _ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) - tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") - h.emitFormEvent(channelID, "workflow.completed", ws.CurrentStage) - c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage}) - return - } - _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) - tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") - h.emitFormEvent(channelID, "workflow.advanced", nextStage) - c.JSON(http.StatusOK, gin.H{"status": "advanced", "current_stage": nextStage, "stage": stages[nextStage]}) - return - } - - // 10. Not auto-advancing — persist stage_data and confirm submission - _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, ws.CurrentStage, json.RawMessage(mergedData)) - tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "") - h.emitFormEvent(channelID, "workflow.form_submitted", ws.CurrentStage) - c.JSON(http.StatusOK, gin.H{"status": "submitted", "current_stage": ws.CurrentStage}) -} - -// ── Starlark Hooks ────────────────────────── - -func (h *WorkflowFormHandler) runValidateHook(c *gin.Context, hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) []models.FieldError { - if h.runner == nil { - return nil - } - - pkg, err := h.stores.Packages.Get(c.Request.Context(), hooks.PackageID) - if err != nil || pkg == nil { - log.Printf("[workflow-forms] validate hook: package %s not found", hooks.PackageID) - return nil - } - - ctxDict := starlark.NewDict(4) - _ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data)) - _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData))) - _ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName)) - _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) - - val, _, err := h.runner.CallEntryPoint(c.Request.Context(), pkg, hooks.Validate, - starlark.Tuple{ctxDict}, nil, nil) - if err != nil { - log.Printf("[workflow-forms] validate hook error: %v", err) - return nil - } - - return parseValidationResult(val) -} - -func (h *WorkflowFormHandler) runOnSubmitHook(hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) { - if h.runner == nil { - return - } - - ctx := context.Background() - pkg, err := h.stores.Packages.Get(ctx, hooks.PackageID) - if err != nil || pkg == nil { - return - } - - ctxDict := starlark.NewDict(4) - _ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data)) - _ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData))) - _ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName)) - _ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID)) - - _, _, err = h.runner.CallEntryPoint(ctx, pkg, hooks.OnSubmit, - starlark.Tuple{ctxDict}, nil, nil) - if err != nil { - log.Printf("[workflow-forms] on_submit hook error: %v", err) - } -} - -// ── Helpers ───────────────────────────────── - -func (h *WorkflowFormHandler) emitFormEvent(channelID, label string, stage int) { - if h.hub == nil { - return - } - payload, _ := json.Marshal(map[string]interface{}{ - "channel_id": channelID, - "stage": stage, - }) - ctx := context.Background() - pids, err := h.stores.Channels.ListUserParticipantIDs(ctx, channelID, "") - if err != nil { - return - } - evt := events.Event{Label: label, Payload: payload} - for _, uid := range pids { - h.hub.PublishToUser(uid, evt) - } -} - -// parseValidationResult extracts field errors from a Starlark return value. -// Expected: None (pass) or {"errors": [{"key": "...", "message": "..."}, ...]} -func parseValidationResult(val starlark.Value) []models.FieldError { - if val == nil || val == starlark.None { - return nil - } - d, ok := val.(*starlark.Dict) - if !ok { - return nil - } - errVal, found, _ := d.Get(starlark.String("errors")) - if !found { - return nil - } - list, ok := errVal.(*starlark.List) - if !ok { - return nil - } - var errs []models.FieldError - iter := list.Iterate() - defer iter.Done() - var item starlark.Value - for iter.Next(&item) { - ed, ok := item.(*starlark.Dict) - if !ok { - continue - } - keyVal, _, _ := ed.Get(starlark.String("key")) - msgVal, _, _ := ed.Get(starlark.String("message")) - k, _ := keyVal.(starlark.String) - m, _ := msgVal.(starlark.String) - if k != "" && m != "" { - errs = append(errs, models.FieldError{Key: string(k), Message: string(m)}) - } - } - if len(errs) == 0 { - return nil - } - return errs -} diff --git a/server/handlers/workflow_instance_test.go b/server/handlers/workflow_instance_test.go deleted file mode 100644 index 1b28f0c..0000000 --- a/server/handlers/workflow_instance_test.go +++ /dev/null @@ -1,845 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - "testing" - - "github.com/gin-gonic/gin" - - "switchboard-core/config" - authpkg "switchboard-core/auth" - "switchboard-core/database" - "switchboard-core/middleware" - "switchboard-core/store" - postgres "switchboard-core/store/postgres" - sqlite "switchboard-core/store/sqlite" -) - -// ═══════════════════════════════════════════════════════════ -// Extended harness: CRUD + instances + assignments + entry -// ═══════════════════════════════════════════════════════════ - -type workflowInstanceHarness struct { - *testHarness - stores store.Stores - adminToken string - adminID string -} - -func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness { - 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") - - auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) - api.POST("/auth/login", auth.Login) - api.POST("/auth/register", auth.Register) - - protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) - - // Workflow CRUD - wfH := NewWorkflowHandler(stores) - protected.GET("/workflows", wfH.List) - protected.POST("/workflows", wfH.Create) - protected.GET("/workflows/:id", wfH.Get) - protected.PATCH("/workflows/:id", wfH.Update) - protected.DELETE("/workflows/:id", wfH.Delete) - protected.GET("/workflows/:id/stages", wfH.ListStages) - protected.POST("/workflows/:id/stages", wfH.CreateStage) - protected.PUT("/workflows/:id/stages/:sid", wfH.UpdateStage) - protected.DELETE("/workflows/:id/stages/:sid", wfH.DeleteStage) - protected.PATCH("/workflows/:id/stages/reorder", wfH.ReorderStages) - protected.POST("/workflows/:id/publish", wfH.Publish) - protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) - - // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil) - protected.POST("/workflows/:id/start", wfInstH.Start) - protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) - protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) - protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) - - // Assignments - wfAssignH := NewWorkflowAssignmentHandler(stores) - protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) - protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) - protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) - - // Teams (admin routes for creating teams + adding members) - teamH := NewTeamHandler(stores, nil) - admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin()) - admin.POST("/teams", teamH.CreateTeam) - admin.POST("/teams/:id/members", teamH.AddMember) - - // Team-scoped assignment listing (mirrors main.go teamScoped) - teamScoped := protected.Group("/teams/:teamId") - teamAssignH := NewWorkflowAssignmentHandler(stores) - teamScoped.GET("/assignments", teamAssignH.ListForTeam) - - // Visitor entry - wfEntry := NewWorkflowEntryHandler(stores) - r.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor) - - // Seed admin user - adminID := seedInsertReturningID(t, - `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - "wfinst-admin", "wfinst-admin@test.com", "$2a$10$test", "admin", "wfinst-admin", "builtin", - ) - token := makeToken(adminID, "wfinst-admin@test.com", "admin") - - return &workflowInstanceHarness{ - testHarness: &testHarness{router: r, t: t}, - stores: stores, - adminToken: token, - adminID: adminID, - } -} - -// createPublishedWorkflow is a helper that creates a workflow with N stages, -// activates it, and publishes it. Returns workflow ID + stage IDs. -func (h *workflowInstanceHarness) createPublishedWorkflow(name string, numStages int) (string, []string) { - h.t.Helper() - - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": name, - "entry_mode": "public_link", - }) - if resp.Code != http.StatusCreated { - h.t.Fatalf("create workflow: %d: %s", resp.Code, resp.Body.String()) - } - var wf struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &wf) - - stageIDs := make([]string, numStages) - for i := 0; i < numStages; i++ { - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": fmt.Sprintf("Stage %d", i), - "ordinal": i, - "history_mode": "full", - }) - if resp.Code != http.StatusCreated { - h.t.Fatalf("create stage %d: %d: %s", i, resp.Code, resp.Body.String()) - } - var st struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &st) - stageIDs[i] = st.ID - } - - // Activate + publish - h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ - "is_active": true, - }) - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) - if resp.Code != http.StatusCreated { - h.t.Fatalf("publish: %d: %s", resp.Code, resp.Body.String()) - } - - return wf.ID, stageIDs -} - -// ═══════════════════════════════════════════════════════════ -// #15 — Instance Lifecycle (currently untested) -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowInstanceLifecycle exercises: start → status → advance → complete. -func TestWorkflowInstanceLifecycle(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - wfID, _ := h.createPublishedWorkflow("Lifecycle Test", 2) - - // ── Start ─────────────────────────────── - resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil) - if resp.Code != http.StatusCreated { - t.Fatalf("Start: got %d, body: %s", resp.Code, resp.Body.String()) - } - var startResp struct { - ChannelID string `json:"channel_id"` - WorkflowVersion int `json:"workflow_version"` - CurrentStage int `json:"current_stage"` - } - json.Unmarshal(resp.Body.Bytes(), &startResp) - if startResp.ChannelID == "" { - t.Fatal("Start: channel_id empty") - } - if startResp.CurrentStage != 0 { - t.Errorf("Start: current_stage = %d, want 0", startResp.CurrentStage) - } - chID := startResp.ChannelID - - // ── Status ────────────────────────────── - resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Status: got %d, body: %s", resp.Code, resp.Body.String()) - } - var status struct { - WorkflowID *string `json:"workflow_id"` - Status string `json:"status"` - Stage int `json:"current_stage"` - StageData json.RawMessage `json:"stage_data"` - } - json.Unmarshal(resp.Body.Bytes(), &status) - if status.Status != "active" { - t.Errorf("Status: got %q, want \"active\"", status.Status) - } - if status.WorkflowID == nil || *status.WorkflowID != wfID { - t.Errorf("Status: workflow_id mismatch") - } - - // ── Advance (stage 0 → 1) ─────────────── - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"name": "Jane", "email": "jane@test.com"}, - }) - if resp.Code != http.StatusOK { - t.Fatalf("Advance 0→1: got %d, body: %s", resp.Code, resp.Body.String()) - } - var advResp struct { - Status string `json:"status"` - CurrentStage int `json:"current_stage"` - } - json.Unmarshal(resp.Body.Bytes(), &advResp) - if advResp.Status != "active" { - t.Errorf("Advance 0→1: status = %q, want \"active\"", advResp.Status) - } - if advResp.CurrentStage != 1 { - t.Errorf("Advance 0→1: current_stage = %d, want 1", advResp.CurrentStage) - } - - // ── Verify merged data persisted ──────── - resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil) - json.Unmarshal(resp.Body.Bytes(), &status) - var data map[string]string - json.Unmarshal(status.StageData, &data) - if data["name"] != "Jane" || data["email"] != "jane@test.com" { - t.Errorf("Stage data after advance: got %v", data) - } - - // ── Advance (stage 1 → complete) ──────── - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"resolution": "approved"}, - }) - if resp.Code != http.StatusOK { - t.Fatalf("Advance 1→complete: got %d, body: %s", resp.Code, resp.Body.String()) - } - json.Unmarshal(resp.Body.Bytes(), &advResp) - if advResp.Status != "completed" { - t.Errorf("Completion: status = %q, want \"completed\"", advResp.Status) - } - - // ── Status after completion ────────────── - resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil) - json.Unmarshal(resp.Body.Bytes(), &status) - if status.Status != "completed" { - t.Errorf("Post-complete status: got %q, want \"completed\"", status.Status) - } - - // ── Advance on completed should fail ──── - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"extra": "data"}, - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Advance after complete: got %d, want 400", resp.Code) - } -} - -// TestWorkflowInstanceStart_InactiveWorkflow verifies start fails on inactive workflow. -func TestWorkflowInstanceStart_InactiveWorkflow(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - // Create workflow with a stage but don't activate - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Inactive WF", - }) - var wf struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &wf) - - h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Stage 0", "ordinal": 0, "history_mode": "full", - }) - - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil) - if resp.Code != http.StatusBadRequest { - t.Errorf("Start inactive: got %d, want 400", resp.Code) - } -} - -// TestWorkflowInstanceStart_NoPublishedVersion verifies start fails without a published version. -func TestWorkflowInstanceStart_NoPublishedVersion(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Unpublished WF", - }) - var wf struct{ ID string `json:"id"` } - json.Unmarshal(resp.Body.Bytes(), &wf) - - h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Stage 0", "ordinal": 0, "history_mode": "full", - }) - // Activate but don't publish - h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ - "is_active": true, - }) - - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil) - if resp.Code != http.StatusBadRequest { - t.Errorf("Start unpublished: got %d, want 400", resp.Code) - } -} - -// ═══════════════════════════════════════════════════════════ -// #15 — Reject validation -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowReject exercises reject validation: reason required, stage-0 guard. -func TestWorkflowReject(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - wfID, _ := h.createPublishedWorkflow("Reject Test", 3) - - // Start - resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil) - var start struct{ ChannelID string `json:"channel_id"` } - json.Unmarshal(resp.Body.Bytes(), &start) - chID := start.ChannelID - - // ── Reject from stage 0 should fail ───── - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{ - "reason": "want to go back", - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Reject at stage 0: got %d, want 400", resp.Code) - } - - // ── Reject without reason should fail ─── - // Advance to stage 1 first - h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"step": "0"}, - }) - - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, nil) - if resp.Code != http.StatusBadRequest { - t.Errorf("Reject no reason: got %d, want 400", resp.Code) - } - - // ── Reject with empty reason should fail ─ - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{ - "reason": "", - }) - if resp.Code != http.StatusBadRequest { - t.Errorf("Reject empty reason: got %d, want 400", resp.Code) - } - - // ── Valid reject ──────────────────────── - resp = h.request("POST", "/api/v1/channels/"+chID+"/workflow/reject", h.adminToken, map[string]interface{}{ - "reason": "Missing email field", - }) - if resp.Code != http.StatusOK { - t.Fatalf("Valid reject: got %d, body: %s", resp.Code, resp.Body.String()) - } - var rejResp struct { - CurrentStage int `json:"current_stage"` - Reason string `json:"reason"` - } - json.Unmarshal(resp.Body.Bytes(), &rejResp) - if rejResp.CurrentStage != 0 { - t.Errorf("After reject: current_stage = %d, want 0", rejResp.CurrentStage) - } - if rejResp.Reason != "Missing email field" { - t.Errorf("Reject reason: got %q", rejResp.Reason) - } -} - -// ═══════════════════════════════════════════════════════════ -// #1 — Advance body field is "data" (not "stage_data") -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowAdvance_BodyFieldName verifies the advance endpoint reads "data" -// from the request body, not "stage_data" (which the old ICD incorrectly documented). -func TestWorkflowAdvance_BodyFieldName(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - wfID, _ := h.createPublishedWorkflow("Field Name Test", 2) - - resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil) - var start struct{ ChannelID string `json:"channel_id"` } - json.Unmarshal(resp.Body.Bytes(), &start) - chID := start.ChannelID - - // Send data under the correct "data" key - h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"collected": "yes"}, - }) - - // Verify data was merged - resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil) - var status struct{ StageData json.RawMessage `json:"stage_data"` } - json.Unmarshal(resp.Body.Bytes(), &status) - var data map[string]string - json.Unmarshal(status.StageData, &data) - if data["collected"] != "yes" { - t.Errorf("Data sent under 'data' key not merged: got %v", data) - } -} - -// ═══════════════════════════════════════════════════════════ -// #2 — GetStatus response uses "status" (not "workflow_status") -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowGetStatus_ResponseShape verifies the exact JSON field names -// returned by the status endpoint. -func TestWorkflowGetStatus_ResponseShape(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - wfID, _ := h.createPublishedWorkflow("Status Shape", 1) - - resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil) - var start struct{ ChannelID string `json:"channel_id"` } - json.Unmarshal(resp.Body.Bytes(), &start) - - resp = h.request("GET", "/api/v1/channels/"+start.ChannelID+"/workflow/status", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("GetStatus: %d", resp.Code) - } - - // Parse as raw map to check exact field names - var raw map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &raw) - - if _, ok := raw["status"]; !ok { - t.Error("Response missing 'status' field — should be 'status', not 'workflow_status'") - } - if _, ok := raw["workflow_status"]; ok { - t.Error("Response has 'workflow_status' — should be 'status' per ICD") - } - if _, ok := raw["workflow_id"]; !ok { - t.Error("Response missing 'workflow_id'") - } - if _, ok := raw["current_stage"]; !ok { - t.Error("Response missing 'current_stage'") - } - if _, ok := raw["last_activity_at"]; !ok { - t.Error("Response missing 'last_activity_at'") - } -} - -// ═══════════════════════════════════════════════════════════ -// #4 — StartVisitor response shape -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowVisitorEntry_ResponseShape verifies the visitor start endpoint -// returns the correct field names (session_id + redirect_to, not session_token). -func TestWorkflowVisitorEntry_ResponseShape(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - wfID, _ := h.createPublishedWorkflow("Visitor Test", 1) - - // Read slug - resp := h.request("GET", "/api/v1/workflows/"+wfID, h.adminToken, nil) - var wf struct{ Slug string `json:"slug"` } - json.Unmarshal(resp.Body.Bytes(), &wf) - - // Visitor start (no auth, no body) - resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil) - if resp.Code != http.StatusCreated { - t.Fatalf("Visitor start: got %d, body: %s", resp.Code, resp.Body.String()) - } - - var raw map[string]interface{} - json.Unmarshal(resp.Body.Bytes(), &raw) - - if _, ok := raw["channel_id"]; !ok { - t.Error("Response missing 'channel_id'") - } - if _, ok := raw["session_id"]; !ok { - t.Error("Response missing 'session_id' — should be 'session_id', not 'session_token'") - } - if _, ok := raw["redirect_to"]; !ok { - t.Error("Response missing 'redirect_to'") - } - // These should NOT be present - if _, ok := raw["session_token"]; ok { - t.Error("Response has 'session_token' — field was renamed to 'session_id'") - } - if _, ok := raw["workflow"]; ok { - t.Error("Response has 'workflow' object — not in actual response") - } -} - -// TestWorkflowVisitorEntry_TeamOnlyBlocked verifies team_only workflows reject visitors. -func TestWorkflowVisitorEntry_TeamOnlyBlocked(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Team Only WF", - "entry_mode": "team_only", - }) - var wf struct { - ID string `json:"id"` - Slug string `json:"slug"` - } - json.Unmarshal(resp.Body.Bytes(), &wf) - - h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "S0", "ordinal": 0, "history_mode": "full", - }) - h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ - "is_active": true, - }) - h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) - - resp = h.request("POST", "/api/v1/workflow-entry/global/"+wf.Slug, "", nil) - if resp.Code != http.StatusForbidden { - t.Errorf("Visitor on team_only: got %d, want 403", resp.Code) - } -} - -// ═══════════════════════════════════════════════════════════ -// #6/#7 — webhook_url/webhook_secret round-trip -// EXPECTED TO FAIL until stores are fixed to SELECT/INSERT these columns. -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowWebhookFields verifies webhook_url and webhook_secret survive -// create → get round-trip. This WILL FAIL until the Postgres and SQLite -// workflow stores are updated to include these columns in their queries. -func TestWorkflowWebhookFields(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Webhook WF", - "webhook_url": "https://hooks.example.com/wf", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Create with webhook_url: %d: %s", resp.Code, resp.Body.String()) - } - var wf struct { - ID string `json:"id"` - WebhookURL string `json:"webhook_url"` - } - json.Unmarshal(resp.Body.Bytes(), &wf) - - // BUG #6: webhook_url is not included in the store's INSERT/SELECT, - // so it will be empty on the create response. - if wf.WebhookURL != "https://hooks.example.com/wf" { - t.Errorf("Create response webhook_url: got %q, want %q (BUG #6: store doesn't persist webhook_url)", - wf.WebhookURL, "https://hooks.example.com/wf") - } - - // Also verify via GET - resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil) - json.Unmarshal(resp.Body.Bytes(), &wf) - if wf.WebhookURL != "https://hooks.example.com/wf" { - t.Errorf("GET webhook_url: got %q, want %q (BUG #6: store doesn't SELECT webhook_url)", - wf.WebhookURL, "https://hooks.example.com/wf") - } - - // BUG #7: PATCH webhook_url should work - resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ - "webhook_url": "https://hooks.example.com/updated", - }) - if resp.Code != http.StatusOK { - t.Fatalf("PATCH webhook_url: %d: %s", resp.Code, resp.Body.String()) - } - json.Unmarshal(resp.Body.Bytes(), &wf) - if wf.WebhookURL != "https://hooks.example.com/updated" { - t.Errorf("PATCH webhook_url: got %q, want %q (BUG #7: Update() ignores webhook_url)", - wf.WebhookURL, "https://hooks.example.com/updated") - } -} - -// ═══════════════════════════════════════════════════════════ -// #18 — ListMine should include unassigned-for-my-teams -// EXPECTED TO FAIL until ListMine query is fixed. -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowAssignment_ListMineIncludesTeamUnassigned verifies that -// GET /workflow-assignments/mine returns both claimed assignments AND -// unassigned assignments for the user's teams. -func TestWorkflowAssignment_ListMineIncludesTeamUnassigned(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - // Create a team and add a member - memberID := seedInsertReturningID(t, - `INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - "wf-member", "wf-member@test.com", "$2a$10$test", "user", "wf-member", "builtin", - ) - memberToken := makeToken(memberID, "wf-member@test.com", "user") - - resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{ - "name": "Review Team", "description": "Test team", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String()) - } - var team map[string]interface{} - decode(resp, &team) - teamID := team["id"].(string) - - h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken, - map[string]string{"user_id": memberID, "role": "member"}) - - // Create a workflow with an assignment stage - wfResp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{ - "name": "Assignment WF", - "entry_mode": "team_only", - }) - var wf struct{ ID string `json:"id"` } - json.Unmarshal(wfResp.Body.Bytes(), &wf) - - h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Intake", "ordinal": 0, "history_mode": "full", - }) - h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{ - "name": "Review", - "ordinal": 1, - "history_mode": "full", - "assignment_team_id": teamID, - }) - h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{ - "is_active": true, - }) - h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil) - - // Start instance and advance to stage 1 (creates an assignment) - resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/start", h.adminToken, nil) - var start struct{ ChannelID string `json:"channel_id"` } - json.Unmarshal(resp.Body.Bytes(), &start) - - h.request("POST", "/api/v1/channels/"+start.ChannelID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"intake": "done"}, - }) - - // BUG #18: ListMine should include the unassigned assignment for - // the member's team, but currently only returns claimed assignments. - resp = h.request("GET", "/api/v1/workflow-assignments/mine", memberToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("ListMine: %d: %s", resp.Code, resp.Body.String()) - } - var listResp struct { - Data []struct { - ID string `json:"id"` - Status string `json:"status"` - TeamID string `json:"team_id"` - To *string `json:"assigned_to"` - } `json:"data"` - } - json.Unmarshal(resp.Body.Bytes(), &listResp) - - if len(listResp.Data) == 0 { - t.Error("ListMine returned 0 assignments — BUG #18: should include unassigned assignments for user's teams") - } - - // If we do get results, verify the shape - for _, a := range listResp.Data { - if a.TeamID != teamID { - t.Errorf("Assignment team_id: got %q, want %q", a.TeamID, teamID) - } - } -} - -// ═══════════════════════════════════════════════════════════ -// Assignment Claim + Complete (basic happy path) -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowAssignment_ClaimAndComplete exercises the claim → complete flow. -func TestWorkflowAssignment_ClaimAndComplete(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - // Seed assignment directly (simulates the advance handler creating one) - wfID, _ := h.createPublishedWorkflow("Assign WF", 2) - - // Start + advance to create assignment stage - resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil) - var start struct{ ChannelID string `json:"channel_id"` } - json.Unmarshal(resp.Body.Bytes(), &start) - - // Manually insert an assignment (since our stages don't have assignment_team_id) - teamID := seedInsertReturningID(t, - `INSERT INTO teams (name, description, created_by) VALUES ($1, $2, $3) RETURNING id`, - "Claim Team", "Test", h.adminID, - ) - assignID := seedInsertReturningID(t, - `INSERT INTO workflow_assignments (channel_id, stage, team_id) VALUES ($1, $2, $3) RETURNING id`, - start.ChannelID, 0, teamID, - ) - - // Claim - resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Claim: got %d, body: %s", resp.Code, resp.Body.String()) - } - var claimResp struct { - Claimed bool `json:"claimed"` - AssignmentID string `json:"assignment_id"` - } - json.Unmarshal(resp.Body.Bytes(), &claimResp) - if !claimResp.Claimed { - t.Error("Claim: claimed should be true") - } - - // Double-claim should conflict - resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/claim", h.adminToken, nil) - if resp.Code != http.StatusConflict { - t.Errorf("Double claim: got %d, want 409", resp.Code) - } - - // Complete - resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("Complete: got %d, body: %s", resp.Code, resp.Body.String()) - } - - // Double-complete should conflict - resp = h.request("POST", "/api/v1/workflow-assignments/"+assignID+"/complete", h.adminToken, nil) - if resp.Code != http.StatusConflict { - t.Errorf("Double complete: got %d, want 409", resp.Code) - } -} - -// ═══════════════════════════════════════════════════════════ -// #15 — Status on non-workflow channel returns 404 -// ═══════════════════════════════════════════════════════════ - -func TestWorkflowGetStatus_NonWorkflowChannel(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - resp := h.request("GET", "/api/v1/channels/00000000-0000-0000-0000-000000000000/workflow/status", h.adminToken, nil) - if resp.Code != http.StatusNotFound { - t.Errorf("Status on non-existent channel: got %d, want 404", resp.Code) - } -} - -// ═══════════════════════════════════════════════════════════ -// #15 — Data merge across multiple advances -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowAdvance_DataMerge verifies that stage data accumulates -// across multiple advance calls (merge, not replace). -func TestWorkflowAdvance_DataMerge(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - wfID, _ := h.createPublishedWorkflow("Merge Test", 3) - - resp := h.request("POST", "/api/v1/workflows/"+wfID+"/start", h.adminToken, nil) - var start struct{ ChannelID string `json:"channel_id"` } - json.Unmarshal(resp.Body.Bytes(), &start) - chID := start.ChannelID - - // Advance stage 0 → 1 with key "a" - h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"a": "1"}, - }) - - // Advance stage 1 → 2 with key "b" - h.request("POST", "/api/v1/channels/"+chID+"/workflow/advance", h.adminToken, map[string]interface{}{ - "data": map[string]string{"b": "2"}, - }) - - // Check accumulated data contains both keys - resp = h.request("GET", "/api/v1/channels/"+chID+"/workflow/status", h.adminToken, nil) - var status struct{ StageData json.RawMessage `json:"stage_data"` } - json.Unmarshal(resp.Body.Bytes(), &status) - var merged map[string]string - json.Unmarshal(status.StageData, &merged) - - if merged["a"] != "1" { - t.Errorf("Merged data missing key 'a': got %v", merged) - } - if merged["b"] != "2" { - t.Errorf("Merged data missing key 'b': got %v", merged) - } -} - -// ═══════════════════════════════════════════════════════════ -// #17 — PATCH non-existent workflow -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowPatchNonExistent verifies PATCH to a non-existent ID returns 404. -// Documents the confusing code path: Update affects 0 rows (no error), -// then GetByID returns sql.ErrNoRows → 404. -func TestWorkflowPatchNonExistent(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - resp := h.request("PATCH", "/api/v1/workflows/00000000-0000-0000-0000-000000000000", h.adminToken, map[string]interface{}{ - "name": "ghost", - }) - if resp.Code != http.StatusNotFound { - t.Errorf("PATCH non-existent: got %d, want 404", resp.Code) - } -} - -// ═══════════════════════════════════════════════════════════ -// #19 — ListForTeam with ?status= filter -// ═══════════════════════════════════════════════════════════ - -// TestWorkflowAssignment_ListForTeamStatusFilter verifies the ?status= -// query param on GET /teams/:teamId/assignments. -func TestWorkflowAssignment_ListForTeamStatusFilter(t *testing.T) { - h := setupWorkflowInstanceHarness(t) - - // Create team - resp := h.request("POST", "/api/v1/admin/teams", h.adminToken, map[string]string{ - "name": "Filter Team", "description": "Test", - }) - if resp.Code != http.StatusCreated { - t.Fatalf("Create team: %d: %s", resp.Code, resp.Body.String()) - } - var team map[string]interface{} - decode(resp, &team) - teamID := team["id"].(string) - - // Add self as member (needed for access) - h.request("POST", fmt.Sprintf("/api/v1/admin/teams/%s/members", teamID), h.adminToken, - map[string]string{"user_id": h.adminID, "role": "admin"}) - - // Create real channels for FK satisfaction - ch1ID := database.SeedTestChannel(t, h.adminID, "filter-ch-1") - ch2ID := database.SeedTestChannel(t, h.adminID, "filter-ch-2") - - // Seed two assignments: one unassigned, one claimed - seedExec(t, - `INSERT INTO workflow_assignments (channel_id, stage, team_id, status) VALUES ($1, $2, $3, $4)`, - ch1ID, 0, teamID, "unassigned", - ) - seedExec(t, - `INSERT INTO workflow_assignments (channel_id, stage, team_id, status, assigned_to) VALUES ($1, $2, $3, $4, $5)`, - ch2ID, 1, teamID, "claimed", h.adminID, - ) - - // Default (unassigned) - resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments", h.adminToken, nil) - if resp.Code != http.StatusOK { - t.Fatalf("ListForTeam default: %d: %s", resp.Code, resp.Body.String()) - } - var lr struct { - Data []struct{ Status string `json:"status"` } `json:"data"` - } - json.Unmarshal(resp.Body.Bytes(), &lr) - if len(lr.Data) != 1 { - t.Errorf("Default filter: expected 1 unassigned, got %d", len(lr.Data)) - } else if lr.Data[0].Status != "unassigned" { - t.Errorf("Default filter: expected status 'unassigned', got %q", lr.Data[0].Status) - } - - // Explicit ?status=claimed - resp = h.request("GET", "/api/v1/teams/"+teamID+"/assignments?status=claimed", h.adminToken, nil) - json.Unmarshal(resp.Body.Bytes(), &lr) - if len(lr.Data) != 1 { - t.Errorf("?status=claimed: expected 1, got %d", len(lr.Data)) - } else if lr.Data[0].Status != "claimed" { - t.Errorf("?status=claimed: expected status 'claimed', got %q", lr.Data[0].Status) - } -} diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go deleted file mode 100644 index 89e7591..0000000 --- a/server/handlers/workflow_instances.go +++ /dev/null @@ -1,551 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/events" - "switchboard-core/models" - "switchboard-core/notifications" - "switchboard-core/sandbox" - "switchboard-core/store" - "switchboard-core/tools" - "switchboard-core/workflow" -) - -// ── Workflow Instance Handler ─────────────── -// Manages the runtime lifecycle of workflow channels: starting instances, -// advancing/rejecting stages, and querying status. - -type WorkflowInstanceHandler struct { - stores store.Stores - hub *events.Hub - notifSvc *notifications.Service - runner *sandbox.Runner -} - -func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service, runner *sandbox.Runner) *WorkflowInstanceHandler { - return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc, runner: runner} -} - -// ── Start ─────────────────────────────────── - -// Start creates a new workflow channel from a published workflow version. -// POST /api/v1/workflows/:id/start -func (h *WorkflowInstanceHandler) Start(c *gin.Context) { - ctx := c.Request.Context() - wfID := c.Param("id") - userID := c.GetString("user_id") - - wf, err := h.stores.Workflows.GetByID(ctx, wfID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) - return - } - if !wf.IsActive { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is not active"}) - return - } - - ver, err := h.stores.Workflows.GetLatestVersion(ctx, wfID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version — publish first"}) - return - } - - stages, err := h.stores.Workflows.ListStages(ctx, wfID) - if err != nil || len(stages) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"}) - return - } - - // Create the workflow channel - ch := &models.Channel{ - UserID: userID, - Title: wf.Name, - Description: wf.Description, - Type: "workflow", - TeamID: wf.TeamID, - } - if err := h.stores.Channels.Create(ctx, ch); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel: " + err.Error()}) - return - } - - // Set workflow-specific columns (not part of base Channel.Create) - allowAnon := wf.EntryMode == "public_link" - err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wfID, ver.VersionNumber, []byte("{}"), "active") - if err != nil { - log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err) - } - _ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{ - "allow_anonymous": allowAnon, - "ai_mode": "auto", - }) - - // Add caller as channel owner - _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ - ChannelID: ch.ID, - ParticipantType: "user", - ParticipantID: userID, - Role: "owner", - }) - - // Bind stage 0 persona as participant - firstStage := stages[0] - if firstStage.PersonaID != nil { - _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ - ChannelID: ch.ID, - ParticipantType: "persona", - ParticipantID: *firstStage.PersonaID, - Role: "member", - }) - } - - c.JSON(http.StatusCreated, gin.H{ - "channel_id": ch.ID, - "workflow_id": wfID, - "workflow_version": ver.VersionNumber, - "current_stage": 0, - "stage": firstStage, - }) -} - -// ── Status ────────────────────────────────── - -// GetStatus returns the workflow state for a channel. -// GET /api/v1/channels/:id/workflow/status -func (h *WorkflowInstanceHandler) GetStatus(c *gin.Context) { - channelID := c.Param("id") - ws, err := h.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID) - if err != nil || ws == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - c.JSON(http.StatusOK, ws) -} - -// ── Advance ───────────────────────────────── - -// Advance moves the workflow to the next stage. -// POST /api/v1/channels/:id/workflow/advance -func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { - ctx := c.Request.Context() - channelID := c.Param("id") - - workflowID, currentStage, status, err := h.readWorkflowState(ctx, channelID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - if status != "active" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot advance"}) - return - } - - stages, err := h.stores.Workflows.ListStages(ctx, workflowID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) - return - } - - var body struct { - Data json.RawMessage `json:"data,omitempty"` - } - _ = c.ShouldBindJSON(&body) - - mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data) - nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData)) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "routing error: " + err.Error()}) - return - } - - if nextStage >= len(stages) { - // Workflow complete - err = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"}) - return - } - tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") - - // v0.27.0: Emit workflow.completed WS event - h.emitWorkflowEvent("workflow.completed", channelID, map[string]any{ - "channel_id": channelID, "workflow_id": workflowID, "stage": nextStage, - }) - - // v0.27.0: on_complete chaining — trigger target workflow if configured - h.triggerOnComplete(ctx, workflowID, channelID, mergedData) - - c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage}) - return - } - - // Advance to next stage - err = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"}) - return - } - - tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") - - // v0.35.0: Fire on_advance hook (can enrich stage_data) - currentStageDef := stages[currentStage] - if hookResult := FireOnAdvanceHook(ctx, h.stores, h.runner, currentStageDef.TransitionRules, - channelID, currentStage, nextStage, json.RawMessage(mergedData)); hookResult != nil { - if hookResult.Error != "" { - log.Printf("[workflow] on_advance hook rejected: %s", hookResult.Error) - } else if hookResult.EnrichedData != nil { - _ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, hookResult.EnrichedData) - } - } - - nextStageDef := stages[nextStage] - - // Bind next persona (skip for form_only — no LLM needed) - if nextStageDef.StageMode != models.StageModeFormOnly && nextStageDef.PersonaID != nil { - alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID) - if !alreadyIn { - _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ - ChannelID: channelID, - ParticipantType: "persona", - ParticipantID: *nextStageDef.PersonaID, - Role: "member", - }) - } - } - - // v0.27.0: Assignment + round-robin + WS notifications - if nextStageDef.AssignmentTeamID != nil { - assignmentID := tools.CreateWorkflowAssignment(ctx, h.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID) - - // Round-robin auto-assignment if configured - assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID) - - // Notify team members about new assignment - h.notifyAssignment(ctx, *nextStageDef.AssignmentTeamID, channelID, nextStageDef.Name, assignedTo) - } - - // v0.27.0: Emit workflow.advanced WS event - h.emitWorkflowEvent("workflow.advanced", channelID, map[string]any{ - "channel_id": channelID, "workflow_id": workflowID, - "stage": nextStage, "stage_name": nextStageDef.Name, - }) - - c.JSON(http.StatusOK, gin.H{ - "status": "active", - "current_stage": nextStage, - "stage": nextStageDef, - }) -} - -// ── Reject ────────────────────────────────── - -// Reject returns the workflow to the previous stage with a reason. -// POST /api/v1/channels/:id/workflow/reject -func (h *WorkflowInstanceHandler) Reject(c *gin.Context) { - ctx := c.Request.Context() - channelID := c.Param("id") - - var body struct { - Reason string `json:"reason"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.Reason == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "reason is required"}) - return - } - - _, currentStage, status, err := h.readWorkflowState(ctx, channelID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - if status != "active" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot reject"}) - return - } - if currentStage <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "cannot reject from the first stage"}) - return - } - - prevStage := currentStage - 1 - err = h.stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject stage"}) - return - } - - // Persist rejection as system message - if h.stores.Messages != nil { - _ = h.stores.Messages.Create(ctx, &models.Message{ - ChannelID: channelID, - Role: "system", - Content: "Stage rejected: " + body.Reason, - ParticipantType: "user", - ParticipantID: c.GetString("user_id"), - }) - } - - c.JSON(http.StatusOK, gin.H{ - "status": "active", - "current_stage": prevStage, - "reason": body.Reason, - }) -} - -// ── Cancel (v0.37.15) ──────────────────────── - -// CancelInstance cancels a workflow instance and all its open assignments. -// POST /api/v1/channels/:id/workflow/cancel -func (h *WorkflowInstanceHandler) CancelInstance(c *gin.Context) { - ctx := c.Request.Context() - channelID := c.Param("id") - userID := c.GetString("user_id") - role, _ := c.Get("role") - - workflowID, _, status, err := h.readWorkflowState(ctx, channelID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - if status != "active" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"}) - return - } - - // Auth: instance owner, team admin, or global admin - if role != "admin" { - ch, err := h.stores.Channels.GetByID(ctx, channelID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) - return - } - isOwner := ch.UserID == userID - isTeamAdmin := false - if ch.TeamID != nil && h.stores.Teams != nil { - isTeamAdmin, _ = h.stores.Teams.IsTeamAdmin(ctx, *ch.TeamID, userID) - } - if !isOwner && !isTeamAdmin { - c.JSON(http.StatusForbidden, gin.H{"error": "only instance owner, team admin, or global admin can cancel"}) - return - } - } - - // Cancel the workflow instance - if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"}) - return - } - - // Cancel all open assignments - cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID) - - // Emit WS event - h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{ - "channel_id": channelID, - "workflow_id": workflowID, - "cancelled_by": userID, - "assignments_cancelled": cancelled, - }) - - c.JSON(http.StatusOK, gin.H{ - "cancelled": true, - "channel_id": channelID, - "assignments_cancelled": cancelled, - }) -} - -// CancelTeamInstance cancels a workflow instance via the team-admin monitor. -// POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel -func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) { - teamID := c.Param("teamId") - channelID := c.Param("channelId") - userID := c.GetString("user_id") - role, _ := c.Get("role") - - ctx := c.Request.Context() - - // Auth: team admin or global admin - if role != "admin" { - if h.stores.Teams == nil { - c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"}) - return - } - isTA, _ := h.stores.Teams.IsTeamAdmin(ctx, teamID, userID) - if !isTA { - c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"}) - return - } - } - - // Verify the channel belongs to this team - ch, err := h.stores.Channels.GetByID(ctx, channelID) - if err != nil || ch.TeamID == nil || *ch.TeamID != teamID { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow instance not found for this team"}) - return - } - - _, _, status, err := h.readWorkflowState(ctx, channelID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"}) - return - } - if status != "active" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"}) - return - } - - if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"}) - return - } - - cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID) - - h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{ - "channel_id": channelID, - "cancelled_by": userID, - "assignments_cancelled": cancelled, - }) - - c.JSON(http.StatusOK, gin.H{ - "cancelled": true, - "channel_id": channelID, - "assignments_cancelled": cancelled, - }) -} - -// ── Helpers ───────────────────────────────── - -func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) { - ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil { - return "", 0, "", fmt.Errorf("workflow channel not found") - } - if ws.WorkflowID != nil { - workflowID = *ws.WorkflowID - } - return workflowID, ws.CurrentStage, ws.Status, nil -} - -// emitWorkflowEvent pushes a workflow event to all user participants in the channel. -// Uses PublishToUser (not room-scoped Bus.Publish) because room subscriptions are -// not yet wired on the client side. See websocket.md § Room Model. -func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) { - if h.hub == nil { - return - } - payload, _ := json.Marshal(data) - evt := events.Event{ - Label: label, - Payload: payload, - Ts: time.Now().UnixMilli(), - } - - // Send to all user participants in the channel - pids, err := h.stores.Channels.ListUserParticipantIDs(context.Background(), channelID, "") - if err != nil { - log.Printf("[ws] %s: failed to query participants for channel %s: %v", label, channelID[:min(8, len(channelID))], err) - return - } - for _, uid := range pids { - h.hub.PublishToUser(uid, evt) - } -} - -// triggerOnComplete checks if the workflow has an on_complete chain config -// and starts the target workflow if so. -func (h *WorkflowInstanceHandler) triggerOnComplete(ctx context.Context, workflowID, channelID, mergedData string) { - // v0.27.3: Delegate to shared implementation (also handles webhooks) - go tools.TriggerWorkflowOnComplete(ctx, h.stores, workflowID, channelID, mergedData) -} - -// tryRoundRobin checks if the stage has auto_assign:"round_robin" in -// transition_rules and assigns the newly created assignment to the -// least-recently-assigned team member. Returns the assigned user ID or "". -func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage models.WorkflowStage, assignmentID string) string { - if assignmentID == "" || stage.AssignmentTeamID == nil { - return "" - } - - // Check transition_rules for auto_assign - var rules struct { - AutoAssign string `json:"auto_assign"` - } - if len(stage.TransitionRules) > 0 { - _ = json.Unmarshal(stage.TransitionRules, &rules) - } - if rules.AutoAssign != "round_robin" { - return "" - } - - assignedTo, err := h.stores.Workflows.TryRoundRobin(ctx, *stage.AssignmentTeamID, assignmentID) - if err != nil { - log.Printf("[workflow] round-robin: failed to auto-assign %s: %v", assignmentID, err) - return "" - } - if assignedTo != "" { - log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, assignedTo) - } - return assignedTo -} - -// notifyAssignment sends notifications to team members about a new workflow assignment. -func (h *WorkflowInstanceHandler) notifyAssignment(ctx context.Context, teamID, channelID, stageName, assignedTo string) { - if h.notifSvc == nil || h.stores.Teams == nil { - return - } - - members, err := h.stores.Teams.ListMembers(ctx, teamID) - if err != nil { - return - } - - for _, m := range members { - title := "New workflow assignment" - body := "Stage '" + stageName + "' needs review" - if assignedTo != "" && m.UserID == assignedTo { - title = "Workflow assigned to you" - body = "Stage '" + stageName + "' has been assigned to you (round-robin)" - } - - n := &models.Notification{ - UserID: m.UserID, - Type: "workflow.assigned", - Title: title, - Body: body, - ResourceType: models.ResourceTypeChannel, - ResourceID: channelID, - } - if err := h.notifSvc.Notify(ctx, n); err != nil { - log.Printf("[workflow] notify assignment: %v", err) - } - } - - // Also emit targeted WS event for immediate UI update - if h.hub != nil { - payload, _ := json.Marshal(map[string]any{ - "channel_id": channelID, "team_id": teamID, - "stage_name": stageName, "assigned_to": assignedTo, - }) - for _, m := range members { - h.hub.PublishToUser(m.UserID, events.Event{ - Label: "workflow.assigned", - Payload: payload, - Ts: time.Now().UnixMilli(), - }) - } - } -} - -// createStageNote and mergeStageData are now in tools/workflow.go -// (shared between handler and workflow_advance tool). diff --git a/server/handlers/workflow_monitor.go b/server/handlers/workflow_monitor.go deleted file mode 100644 index 1619baa..0000000 --- a/server/handlers/workflow_monitor.go +++ /dev/null @@ -1,239 +0,0 @@ -package handlers - -// workflow_monitor.go — v0.35.0 Monitoring Dashboard -// -// Provides admin and team-scoped views of active workflow instances, -// stage funnels, and stale instance detection for SLA tracking. - -import ( - "context" - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - - "switchboard-core/store" -) - -// ── Workflow Monitor Handler ───────────────── - -type WorkflowMonitorHandler struct { - stores store.Stores -} - -func NewWorkflowMonitorHandler(stores store.Stores) *WorkflowMonitorHandler { - return &WorkflowMonitorHandler{stores: stores} -} - -// ── Instance types ────────────────────────── - -// MonitorInstance is a single active workflow instance for the monitoring dashboard. -type MonitorInstance struct { - ChannelID string `json:"channel_id"` - ChannelTitle string `json:"channel_title"` - WorkflowID string `json:"workflow_id"` - WorkflowName string `json:"workflow_name"` - CurrentStage int `json:"current_stage"` - StageName string `json:"stage_name"` - AssignedTo *string `json:"assigned_to,omitempty"` - AgeSeconds int64 `json:"age_seconds"` - StageAgeSeconds int64 `json:"stage_age_seconds"` - SLASeconds *int `json:"sla_seconds,omitempty"` - SLARemaining *int64 `json:"sla_remaining_seconds,omitempty"` - SLABreached bool `json:"sla_breached"` - LastActivityAt string `json:"last_activity_at"` -} - -// FunnelEntry is a count of instances at a given stage. -type FunnelEntry struct { - StageOrdinal int `json:"stage_ordinal"` - StageName string `json:"stage_name"` - Count int `json:"count"` -} - -// ── Endpoints ─────────────────────────────── - -// ListActiveInstances returns all active workflow instances. -// GET /api/v1/admin/workflows/monitor/instances -func (h *WorkflowMonitorHandler) ListActiveInstances(c *gin.Context) { - ctx := c.Request.Context() - - instances, err := h.queryActiveInstances(ctx, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"data": instances}) -} - -// ListTeamActiveInstances returns active workflow instances for a team. -// GET /api/v1/teams/:teamId/workflows/monitor/instances -func (h *WorkflowMonitorHandler) ListTeamActiveInstances(c *gin.Context) { - teamID := c.Param("teamId") - ctx := c.Request.Context() - - instances, err := h.queryActiveInstances(ctx, teamID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"data": instances}) -} - -// GetFunnel returns stage-by-stage instance counts for a workflow. -// GET /api/v1/admin/workflows/monitor/funnel/:id -func (h *WorkflowMonitorHandler) GetFunnel(c *gin.Context) { - workflowID := c.Param("id") - ctx := c.Request.Context() - - stages, err := h.stores.Workflows.ListStages(ctx, workflowID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"}) - return - } - - instances, err := h.queryActiveInstances(ctx, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"}) - return - } - - // Count instances at each stage - counts := make(map[int]int) - for _, inst := range instances { - if inst.WorkflowID == workflowID { - counts[inst.CurrentStage]++ - } - } - - funnel := make([]FunnelEntry, len(stages)) - for i, s := range stages { - funnel[i] = FunnelEntry{ - StageOrdinal: s.Ordinal, - StageName: s.Name, - Count: counts[s.Ordinal], - } - } - - c.JSON(http.StatusOK, gin.H{"data": funnel}) -} - -// ListStaleInstances returns workflow instances that haven't been touched recently. -// GET /api/v1/admin/workflows/monitor/stale -func (h *WorkflowMonitorHandler) ListStaleInstances(c *gin.Context) { - thresholdHours, _ := strconv.Atoi(c.DefaultQuery("threshold_hours", "48")) - if thresholdHours <= 0 { - thresholdHours = 48 - } - - ctx := c.Request.Context() - instances, err := h.queryActiveInstances(ctx, "") - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"}) - return - } - - now := time.Now().UTC() - threshold := time.Duration(thresholdHours) * time.Hour - var stale []MonitorInstance - - for _, inst := range instances { - if inst.AgeSeconds > 0 && time.Duration(inst.AgeSeconds)*time.Second > threshold { - stale = append(stale, inst) - } else { - // Also check last_activity_at - if t, err := time.Parse(time.RFC3339, inst.LastActivityAt); err == nil { - if now.Sub(t) > threshold { - stale = append(stale, inst) - } - } - } - } - - if stale == nil { - stale = []MonitorInstance{} - } - c.JSON(http.StatusOK, gin.H{"data": stale, "threshold_hours": thresholdHours}) -} - -// ── Helpers ───────────────────────────────── - -func (h *WorkflowMonitorHandler) queryActiveInstances(ctx context.Context, teamIDFilter string) ([]MonitorInstance, error) { - // Query all active workflow channels via store - // This is a new query we build from existing store methods - channels, err := h.stores.Channels.ListByType(ctx, "workflow") - if err != nil { - return nil, err - } - - now := time.Now().UTC() - var result []MonitorInstance - - for _, ch := range channels { - ws, err := h.stores.Channels.GetWorkflowStatus(ctx, ch.ID) - if err != nil || ws == nil || ws.WorkflowID == nil || ws.Status != "active" { - continue - } - - // Team filter - if teamIDFilter != "" { - if ch.TeamID == nil || *ch.TeamID != teamIDFilter { - continue - } - } - - wf, err := h.stores.Workflows.GetByID(ctx, *ws.WorkflowID) - if err != nil || wf == nil { - continue - } - - stages, _ := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID) - var stageName string - var slaSeconds *int - if ws.CurrentStage < len(stages) { - stageName = stages[ws.CurrentStage].Name - slaSeconds = stages[ws.CurrentStage].SLASeconds - } - - inst := MonitorInstance{ - ChannelID: ch.ID, - ChannelTitle: ch.Title, - WorkflowID: *ws.WorkflowID, - WorkflowName: wf.Name, - CurrentStage: ws.CurrentStage, - StageName: stageName, - SLASeconds: slaSeconds, - LastActivityAt: "", - } - - // Compute ages - inst.AgeSeconds = int64(now.Sub(ch.CreatedAt).Seconds()) - - if ws.LastActivityAt != nil { - inst.LastActivityAt = *ws.LastActivityAt - } - - // Stage age from stage_entered_at - if ws.StageEnteredAt != nil { - if t, err := time.Parse(time.RFC3339, *ws.StageEnteredAt); err == nil { - inst.StageAgeSeconds = int64(now.Sub(t).Seconds()) - - // SLA computation - if slaSeconds != nil { - remaining := int64(*slaSeconds) - inst.StageAgeSeconds - inst.SLARemaining = &remaining - inst.SLABreached = remaining < 0 - } - } - } - - result = append(result, inst) - } - - if result == nil { - result = []MonitorInstance{} - } - return result, nil -} - diff --git a/server/handlers/workflow_packages.go b/server/handlers/workflow_packages.go index 4f5d6d9..68e6f89 100644 --- a/server/handlers/workflow_packages.go +++ b/server/handlers/workflow_packages.go @@ -219,7 +219,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st SurfacePkgID: s.SurfacePkgID, } if st.StageMode == "" { - st.StageMode = models.StageModeChatOnly + st.StageMode = models.StageModeCustom } if st.HistoryMode == "" { st.HistoryMode = "full" diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index b795443..c4afc27 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -195,10 +195,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) { return } if st.StageMode == "" { - st.StageMode = models.StageModeChatOnly + st.StageMode = models.StageModeCustom } if !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"}) return } if st.Ordinal == 0 { @@ -227,7 +227,7 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) { return } if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { - c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"}) return } if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { diff --git a/server/health/accumulator.go b/server/health/accumulator.go deleted file mode 100644 index 49a54c6..0000000 --- a/server/health/accumulator.go +++ /dev/null @@ -1,447 +0,0 @@ -// Package health provides in-memory accumulation of provider health -// metrics with periodic flush to the database. The accumulator is -// goroutine-safe and designed for high-throughput recording from -// concurrent completion handlers. -package health - -import ( - "context" - "log" - "sync" - "time" - - "switchboard-core/metrics" - "switchboard-core/models" -) - -// ── Thresholds ────────────────────────────── - -const ( - // DegradedThreshold — error rate above this = degraded. - DegradedThreshold = 0.05 // 5% - // DownThreshold — error rate above this = down. - DownThreshold = 0.25 // 25% - // FlushInterval — how often in-memory counters are flushed to DB. - FlushInterval = 60 * time.Second - // PruneAge — health windows older than this are deleted. - PruneAge = 7 * 24 * time.Hour -) - -// ── Store Interface ───────────────────────── - -// Store is the persistence layer for health data. -type Store interface { - // UpsertWindow merges in-memory counters into the hourly bucket row. - UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error - - // GetCurrentWindow returns the current hourly bucket for a provider. - GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) - - // ListWindows returns the last N hourly buckets for a provider, newest first. - ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) - - // ListAllCurrent returns the current-hour bucket for every provider. - ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) - - // Prune deletes rows older than the given time. - Prune(ctx context.Context, before time.Time) (int64, error) - - // ── Tool Health (v0.22.4) ────────────── - // UpsertToolWindow merges tool health counters into the hourly bucket. - UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error - - // ListAllToolCurrent returns the current-hour bucket for every tool. - ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) -} - -// ── In-Memory Bucket ──────────────────────── - -// bucket holds counters for one provider within one flush interval. -type bucket struct { - providerConfigID string - requestCount int - errorCount int - timeoutCount int - rateLimitCount int - totalLatencyMs int64 - maxLatencyMs int - lastError string - lastErrorAt time.Time -} - -// ── Accumulator ───────────────────────────── - -// Accumulator collects health metrics in memory and periodically -// flushes them to the database. One instance per process. -type Accumulator struct { - mu sync.Mutex - buckets map[string]*bucket // keyed by provider_config_id - - // Tool health (v0.22.4) - toolMu sync.Mutex - toolBuckets map[string]*toolBucket // keyed by tool_name - - store Store - stopCh chan struct{} - wg sync.WaitGroup - - // Auto-disable (v0.22.4): if set, providers that are "down" for - // N consecutive windows get auto-deactivated. - autoDisabler AutoDisabler - autoDisableThreshold int // consecutive down windows to trigger (0 = disabled) -} - -// AutoDisabler marks a provider config as inactive. Implemented by the -// provider store so the accumulator doesn't need to import the store package. -type AutoDisabler interface { - DeactivateProvider(ctx context.Context, configID string) error -} - -// toolBucket holds counters for one tool within one flush interval. -type toolBucket struct { - toolName string - requestCount int - errorCount int - totalLatencyMs int64 - maxLatencyMs int - lastError string - lastErrorAt time.Time -} - -// NewAccumulator creates and starts the background flush loop. -func NewAccumulator(store Store) *Accumulator { - a := &Accumulator{ - buckets: make(map[string]*bucket), - toolBuckets: make(map[string]*toolBucket), - store: store, - stopCh: make(chan struct{}), - } - a.wg.Add(1) - go a.flushLoop() - return a -} - -// SetAutoDisable configures the auto-disable policy. When a provider -// has been "down" for N consecutive hourly windows, it is deactivated. -// N=0 disables the feature. -func (a *Accumulator) SetAutoDisable(disabler AutoDisabler, threshold int) { - a.autoDisabler = disabler - a.autoDisableThreshold = threshold -} - -// RecordToolSuccess records a successful tool execution. -func (a *Accumulator) RecordToolSuccess(toolName string, latencyMs int) { - a.toolMu.Lock() - defer a.toolMu.Unlock() - b := a.getToolBucket(toolName) - b.requestCount++ - b.totalLatencyMs += int64(latencyMs) - if latencyMs > b.maxLatencyMs { - b.maxLatencyMs = latencyMs - } -} - -// RecordToolError records a failed tool execution. -func (a *Accumulator) RecordToolError(toolName string, latencyMs int, errMsg string) { - a.toolMu.Lock() - defer a.toolMu.Unlock() - b := a.getToolBucket(toolName) - b.requestCount++ - b.errorCount++ - b.totalLatencyMs += int64(latencyMs) - if latencyMs > b.maxLatencyMs { - b.maxLatencyMs = latencyMs - } - b.lastError = errMsg - b.lastErrorAt = time.Now().UTC() -} - -// RecordSuccess records a successful provider call. -func (a *Accumulator) RecordSuccess(providerConfigID string, latencyMs int) { - a.mu.Lock() - defer a.mu.Unlock() - b := a.getBucket(providerConfigID) - b.requestCount++ - b.totalLatencyMs += int64(latencyMs) - if latencyMs > b.maxLatencyMs { - b.maxLatencyMs = latencyMs - } -} - -// RecordError records a failed provider call. -func (a *Accumulator) RecordError(providerConfigID string, latencyMs int, errMsg string) { - a.mu.Lock() - defer a.mu.Unlock() - b := a.getBucket(providerConfigID) - b.requestCount++ - b.errorCount++ - b.totalLatencyMs += int64(latencyMs) - if latencyMs > b.maxLatencyMs { - b.maxLatencyMs = latencyMs - } - b.lastError = errMsg - b.lastErrorAt = time.Now().UTC() -} - -// RecordTimeout records a timed-out provider call (counted as both error and timeout). -func (a *Accumulator) RecordTimeout(providerConfigID string, latencyMs int, errMsg string) { - a.mu.Lock() - defer a.mu.Unlock() - b := a.getBucket(providerConfigID) - b.requestCount++ - b.errorCount++ - b.timeoutCount++ - b.totalLatencyMs += int64(latencyMs) - if latencyMs > b.maxLatencyMs { - b.maxLatencyMs = latencyMs - } - b.lastError = errMsg - b.lastErrorAt = time.Now().UTC() -} - -// RecordRateLimit records a rate-limited provider call (HTTP 429). -// Counted as both an error and a rate limit event. -func (a *Accumulator) RecordRateLimit(providerConfigID string, latencyMs int, errMsg string) { - a.mu.Lock() - defer a.mu.Unlock() - b := a.getBucket(providerConfigID) - b.requestCount++ - b.errorCount++ - b.rateLimitCount++ - b.totalLatencyMs += int64(latencyMs) - if latencyMs > b.maxLatencyMs { - b.maxLatencyMs = latencyMs - } - b.lastError = errMsg - b.lastErrorAt = time.Now().UTC() -} - -// IsRateLimitError returns true if the error message indicates an HTTP 429. -func IsRateLimitError(errMsg string) bool { - return len(errMsg) > 0 && (contains(errMsg, "HTTP 429") || contains(errMsg, "rate limit") || contains(errMsg, "too many requests")) -} - -func contains(s, sub string) bool { - return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsLower(s, sub)) -} - -func containsLower(s, sub string) bool { - // Simple case-insensitive contains for short substrings. - for i := 0; i <= len(s)-len(sub); i++ { - match := true - for j := 0; j < len(sub); j++ { - sc, tc := s[i+j], sub[j] - if sc >= 'A' && sc <= 'Z' { sc += 32 } - if tc >= 'A' && tc <= 'Z' { tc += 32 } - if sc != tc { match = false; break } - } - if match { return true } - } - return false -} - -// Stop halts the flush loop and performs a final flush. -func (a *Accumulator) Stop() { - close(a.stopCh) - a.wg.Wait() -} - -// DeriveStatus computes the provider status from an error rate. -func DeriveStatus(errorRate float64, requestCount int) models.ProviderStatus { - if requestCount == 0 { - return models.StatusUnknown - } - if errorRate >= DownThreshold { - return models.StatusDown - } - if errorRate >= DegradedThreshold { - return models.StatusDegraded - } - return models.StatusHealthy -} - -// ── Internal ──────────────────────────────── - -func (a *Accumulator) getBucket(providerConfigID string) *bucket { - b, ok := a.buckets[providerConfigID] - if !ok { - b = &bucket{providerConfigID: providerConfigID} - a.buckets[providerConfigID] = b - } - return b -} - -func (a *Accumulator) getToolBucket(toolName string) *toolBucket { - b, ok := a.toolBuckets[toolName] - if !ok { - b = &toolBucket{toolName: toolName} - a.toolBuckets[toolName] = b - } - return b -} - -func (a *Accumulator) flushLoop() { - defer a.wg.Done() - ticker := time.NewTicker(FlushInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - a.flush() - a.flushTools() - a.checkAutoDisable() - case <-a.stopCh: - a.flush() // final flush - a.flushTools() - return - } - } -} - -func (a *Accumulator) flush() { - a.mu.Lock() - if len(a.buckets) == 0 { - a.mu.Unlock() - return - } - // Swap out current buckets so we don't hold the lock during DB writes. - snap := a.buckets - a.buckets = make(map[string]*bucket, len(snap)) - a.mu.Unlock() - - windowStart := hourFloor(time.Now().UTC()) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - for _, b := range snap { - if b.requestCount == 0 { - continue - } - w := &models.ProviderHealthWindow{ - ProviderConfigID: b.providerConfigID, - WindowStart: windowStart, - RequestCount: b.requestCount, - ErrorCount: b.errorCount, - TimeoutCount: b.timeoutCount, - RateLimitCount: b.rateLimitCount, - TotalLatencyMs: b.totalLatencyMs, - MaxLatencyMs: b.maxLatencyMs, - } - if b.lastError != "" { - w.LastError = &b.lastError - ts := b.lastErrorAt.Format(time.RFC3339) - w.LastErrorAt = &ts - } - if err := a.store.UpsertWindow(ctx, w); err != nil { - log.Printf("⚠ health: flush failed for provider %s: %v", b.providerConfigID, err) - } - - // v0.33.0: Update Prometheus provider status gauge - errorRate := float64(0) - if b.requestCount > 0 { - errorRate = float64(b.errorCount) / float64(b.requestCount) - } - status := DeriveStatus(errorRate, b.requestCount) - var statusVal float64 - switch status { - case models.StatusHealthy: - statusVal = 1 - case models.StatusDegraded: - statusVal = 2 - case models.StatusDown: - statusVal = 3 - default: - statusVal = 0 - } - metrics.ProviderStatus.WithLabelValues(b.providerConfigID).Set(statusVal) - } -} - -// flushTools writes tool health buckets to the database. -func (a *Accumulator) flushTools() { - a.toolMu.Lock() - if len(a.toolBuckets) == 0 { - a.toolMu.Unlock() - return - } - snap := a.toolBuckets - a.toolBuckets = make(map[string]*toolBucket, len(snap)) - a.toolMu.Unlock() - - windowStart := hourFloor(time.Now().UTC()) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - for _, b := range snap { - if b.requestCount == 0 { - continue - } - w := &models.ToolHealthWindow{ - ToolName: b.toolName, - WindowStart: windowStart, - RequestCount: b.requestCount, - ErrorCount: b.errorCount, - TotalLatencyMs: b.totalLatencyMs, - MaxLatencyMs: b.maxLatencyMs, - } - if b.lastError != "" { - w.LastError = &b.lastError - ts := b.lastErrorAt.Format(time.RFC3339) - w.LastErrorAt = &ts - } - if err := a.store.UpsertToolWindow(ctx, w); err != nil { - log.Printf("⚠ health: tool flush failed for %s: %v", b.toolName, err) - } - } -} - -// checkAutoDisable inspects recent health windows and deactivates providers -// that have been "down" for too many consecutive hours. -func (a *Accumulator) checkAutoDisable() { - if a.autoDisabler == nil || a.autoDisableThreshold <= 0 { - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Get current windows for all providers - windows, err := a.store.ListAllCurrent(ctx) - if err != nil { - return - } - - for _, w := range windows { - status := DeriveStatus(w.ErrorRate(), w.RequestCount) - if status != models.StatusDown { - continue - } - - // Check last N windows — are they all down? - recent, err := a.store.ListWindows(ctx, w.ProviderConfigID, a.autoDisableThreshold) - if err != nil || len(recent) < a.autoDisableThreshold { - continue // not enough data - } - - allDown := true - for _, rw := range recent { - if DeriveStatus(rw.ErrorRate(), rw.RequestCount) != models.StatusDown { - allDown = false - break - } - } - - if allDown { - log.Printf("⚠ health: auto-disabling provider %s — down for %d consecutive windows", w.ProviderConfigID, a.autoDisableThreshold) - if err := a.autoDisabler.DeactivateProvider(ctx, w.ProviderConfigID); err != nil { - log.Printf("⚠ health: auto-disable failed for %s: %v", w.ProviderConfigID, err) - } - } - } -} - -// hourFloor truncates a time to the start of its hour. -func hourFloor(t time.Time) time.Time { - return t.Truncate(time.Hour) -} diff --git a/server/health/accumulator_test.go b/server/health/accumulator_test.go deleted file mode 100644 index 5489bfa..0000000 --- a/server/health/accumulator_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package health - -import ( - "context" - "sync" - "testing" - "time" - - "switchboard-core/models" -) - -// ── Mock Store ────────────────────────────── - -type mockStore struct { - mu sync.Mutex - windows map[string]*models.ProviderHealthWindow // keyed by provider_config_id -} - -func newMockStore() *mockStore { - return &mockStore{windows: make(map[string]*models.ProviderHealthWindow)} -} - -func (m *mockStore) UpsertWindow(_ context.Context, w *models.ProviderHealthWindow) error { - m.mu.Lock() - defer m.mu.Unlock() - existing, ok := m.windows[w.ProviderConfigID] - if ok { - existing.RequestCount += w.RequestCount - existing.ErrorCount += w.ErrorCount - existing.TimeoutCount += w.TimeoutCount - existing.TotalLatencyMs += w.TotalLatencyMs - if w.MaxLatencyMs > existing.MaxLatencyMs { - existing.MaxLatencyMs = w.MaxLatencyMs - } - if w.LastError != nil { - existing.LastError = w.LastError - existing.LastErrorAt = w.LastErrorAt - } - } else { - cp := *w - m.windows[w.ProviderConfigID] = &cp - } - return nil -} - -func (m *mockStore) GetCurrentWindow(_ context.Context, id string) (*models.ProviderHealthWindow, error) { - m.mu.Lock() - defer m.mu.Unlock() - w, ok := m.windows[id] - if !ok { - return nil, nil - } - cp := *w - return &cp, nil -} - -func (m *mockStore) ListWindows(_ context.Context, _ string, _ int) ([]models.ProviderHealthWindow, error) { - return nil, nil -} - -func (m *mockStore) ListAllCurrent(_ context.Context) ([]models.ProviderHealthWindow, error) { - m.mu.Lock() - defer m.mu.Unlock() - var result []models.ProviderHealthWindow - for _, w := range m.windows { - result = append(result, *w) - } - return result, nil -} - -func (m *mockStore) Prune(_ context.Context, _ time.Time) (int64, error) { return 0, nil } - -func (m *mockStore) UpsertToolWindow(_ context.Context, _ *models.ToolHealthWindow) error { - return nil -} - -func (m *mockStore) ListAllToolCurrent(_ context.Context) ([]models.ToolHealthWindow, error) { - return nil, nil -} - -// ── Tests ─────────────────────────────────── - -func TestRecordSuccess(t *testing.T) { - ms := newMockStore() - a := &Accumulator{ - buckets: make(map[string]*bucket), - store: ms, - stopCh: make(chan struct{}), - } - - a.RecordSuccess("prov-1", 100) - a.RecordSuccess("prov-1", 200) - a.RecordSuccess("prov-2", 50) - - a.flush() - - w1, _ := ms.GetCurrentWindow(context.Background(), "prov-1") - if w1 == nil { - t.Fatal("expected prov-1 window") - } - if w1.RequestCount != 2 { - t.Fatalf("expected 2 requests, got %d", w1.RequestCount) - } - if w1.ErrorCount != 0 { - t.Fatalf("expected 0 errors, got %d", w1.ErrorCount) - } - if w1.TotalLatencyMs != 300 { - t.Fatalf("expected 300ms total latency, got %d", w1.TotalLatencyMs) - } - if w1.MaxLatencyMs != 200 { - t.Fatalf("expected 200ms max latency, got %d", w1.MaxLatencyMs) - } - - w2, _ := ms.GetCurrentWindow(context.Background(), "prov-2") - if w2 == nil { - t.Fatal("expected prov-2 window") - } - if w2.RequestCount != 1 { - t.Fatalf("expected 1 request, got %d", w2.RequestCount) - } -} - -func TestRecordError(t *testing.T) { - ms := newMockStore() - a := &Accumulator{ - buckets: make(map[string]*bucket), - store: ms, - stopCh: make(chan struct{}), - } - - a.RecordSuccess("prov-1", 100) - a.RecordError("prov-1", 500, "connection refused") - a.RecordSuccess("prov-1", 150) - - a.flush() - - w, _ := ms.GetCurrentWindow(context.Background(), "prov-1") - if w.RequestCount != 3 { - t.Fatalf("expected 3 requests, got %d", w.RequestCount) - } - if w.ErrorCount != 1 { - t.Fatalf("expected 1 error, got %d", w.ErrorCount) - } - if w.LastError == nil || *w.LastError != "connection refused" { - t.Fatalf("expected last_error='connection refused', got %v", w.LastError) - } -} - -func TestRecordTimeout(t *testing.T) { - ms := newMockStore() - a := &Accumulator{ - buckets: make(map[string]*bucket), - store: ms, - stopCh: make(chan struct{}), - } - - a.RecordTimeout("prov-1", 30000, "context deadline exceeded") - a.flush() - - w, _ := ms.GetCurrentWindow(context.Background(), "prov-1") - if w.RequestCount != 1 { - t.Fatalf("expected 1 request, got %d", w.RequestCount) - } - if w.ErrorCount != 1 { - t.Fatalf("expected 1 error, got %d", w.ErrorCount) - } - if w.TimeoutCount != 1 { - t.Fatalf("expected 1 timeout, got %d", w.TimeoutCount) - } -} - -func TestDeriveStatus(t *testing.T) { - tests := []struct { - rate float64 - count int - expected models.ProviderStatus - }{ - {0, 0, models.StatusUnknown}, - {0, 100, models.StatusHealthy}, - {0.03, 100, models.StatusHealthy}, - {0.05, 100, models.StatusDegraded}, - {0.15, 100, models.StatusDegraded}, - {0.25, 100, models.StatusDown}, - {0.50, 100, models.StatusDown}, - {1.0, 1, models.StatusDown}, - } - for _, tt := range tests { - got := DeriveStatus(tt.rate, tt.count) - if got != tt.expected { - t.Errorf("DeriveStatus(%v, %d) = %s, want %s", tt.rate, tt.count, got, tt.expected) - } - } -} - -func TestFlushClearsBuckets(t *testing.T) { - ms := newMockStore() - a := &Accumulator{ - buckets: make(map[string]*bucket), - store: ms, - stopCh: make(chan struct{}), - } - - a.RecordSuccess("prov-1", 100) - a.flush() - - // After flush, buckets should be empty - a.mu.Lock() - count := len(a.buckets) - a.mu.Unlock() - if count != 0 { - t.Fatalf("expected 0 buckets after flush, got %d", count) - } -} - -func TestConcurrentRecording(t *testing.T) { - ms := newMockStore() - a := &Accumulator{ - buckets: make(map[string]*bucket), - store: ms, - stopCh: make(chan struct{}), - } - - var wg sync.WaitGroup - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - a.RecordSuccess("prov-1", 50) - }() - } - wg.Wait() - a.flush() - - w, _ := ms.GetCurrentWindow(context.Background(), "prov-1") - if w.RequestCount != 100 { - t.Fatalf("expected 100 concurrent requests, got %d", w.RequestCount) - } -} - -func TestHourFloor(t *testing.T) { - ts := time.Date(2026, 3, 1, 14, 37, 42, 0, time.UTC) - floor := hourFloor(ts) - expected := time.Date(2026, 3, 1, 14, 0, 0, 0, time.UTC) - if !floor.Equal(expected) { - t.Fatalf("expected %v, got %v", expected, floor) - } -} diff --git a/server/main.go b/server/main.go index 3eec642..feb7938 100644 --- a/server/main.go +++ b/server/main.go @@ -17,36 +17,21 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "switchboard-core/auth" - "switchboard-core/compaction" "switchboard-core/config" "switchboard-core/crypto" "switchboard-core/database" "switchboard-core/events" - "switchboard-core/extraction" - "switchboard-core/filters" "switchboard-core/sandbox" "switchboard-core/handlers" - "switchboard-core/health" "switchboard-core/logging" "switchboard-core/metrics" - "switchboard-core/knowledge" - "switchboard-core/memory" "switchboard-core/middleware" "switchboard-core/notifications" "switchboard-core/pages" - "switchboard-core/providers" - "switchboard-core/retention" - "switchboard-core/roles" - "switchboard-core/routing" - "switchboard-core/scheduler" "switchboard-core/storage" "switchboard-core/store" postgres "switchboard-core/store/postgres" sqliteStore "switchboard-core/store/sqlite" - "switchboard-core/tools" - "switchboard-core/tools/search" - "switchboard-core/treepath" - "switchboard-core/workspace" ) // v0.33.0: Embedded OpenAPI spec and Swagger UI for /api/docs. @@ -76,12 +61,9 @@ func main() { // log output goes through slog. logging.Init(cfg.LogFormat, cfg.LogLevel) - // Register LLM providers - providers.Init() // v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.) if cfg.ProviderTypesFile != "" { - n, err := providers.LoadCustomTypes(cfg.ProviderTypesFile) if err != nil { log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err) } else if n > 0 { @@ -93,8 +75,6 @@ func main() { uekCache := crypto.NewUEKCache() var keyResolver *crypto.KeyResolver var objStore storage.ObjectStore - var healthAccum *health.Accumulator - var healthStore health.Store if err := database.Connect(cfg); err != nil { log.Printf("⚠ Database unavailable: %v", err) @@ -134,109 +114,30 @@ func main() { stores = postgres.NewStores(database.DB) } - // v0.29.0: Wire store layer into treepath for backward compat. - // New code should call stores.Messages.* directly. - treepath.Stores = &stores // Provider health accumulator (v0.22.0) if database.IsSQLite() { - healthStore = sqliteStore.NewHealthStore() } else { - healthStore = postgres.NewHealthStore(database.DB) } - healthAccum = health.NewAccumulator(healthStore) - defer healthAccum.Stop() // v0.33.0: Start Prometheus DB pool collector metrics.StartDBCollector(database.DB, 15*time.Second) - // Auto-disable: deactivate providers that are "down" for N consecutive - // hourly windows. Configured via PROVIDER_AUTO_DISABLE_THRESHOLD env var. - // Default: 3 (3 consecutive "down" hours triggers deactivation). Set to 0 to disable. - autoDisableThreshold := 3 - if v := cfg.ProviderAutoDisableThreshold; v >= 0 { - autoDisableThreshold = v - } - if autoDisableThreshold > 0 { - if ad, ok := healthStore.(health.AutoDisabler); ok { - healthAccum.SetAutoDisable(ad, autoDisableThreshold) - log.Printf(" 🛡️ Provider auto-disable: %d consecutive down windows", autoDisableThreshold) - } - } - - // Background health cleanup: prune windows older than 7 days + // Kernel maintenance: prune stale data every 6 hours go func() { ticker := time.NewTicker(6 * time.Hour) defer ticker.Stop() for range ticker.C { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - if n, err := healthStore.Prune(ctx, time.Now().UTC().Add(-health.PruneAge)); err != nil { - log.Printf("⚠ health prune failed: %v", err) + if n, err := stores.Health.Prune(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil { + log.Printf("⚠ maintenance prune failed: %v", err) } else if n > 0 { - log.Printf("🧹 health: pruned %d old windows", n) + log.Printf("🧹 maintenance: pruned %d stale rows", n) } cancel() } }() - // Background session cleanup (v0.26.0): remove expired anonymous sessions - if cfg.SessionExpiryDays > 0 { - go func() { - ticker := time.NewTicker(6 * time.Hour) - defer ticker.Stop() - for { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - cutoff := time.Now().UTC().AddDate(0, 0, -cfg.SessionExpiryDays) - if n, err := stores.Sessions.DeleteExpired(ctx, cutoff); err != nil { - log.Printf("⚠ session cleanup failed: %v", err) - } else if n > 0 { - log.Printf("🧹 sessions: cleaned up %d expired sessions", n) - } - cancel() - <-ticker.C - } - }() - } - - // Background workflow staleness sweep (v0.26.2): mark idle instances as stale - // + v0.27.0: retention enforcement — archive/delete completed instances per workflow policy - if cfg.WorkflowStaleHours > 0 { - go func() { - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - for { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - - // Staleness: mark idle active instances as stale - cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour) - n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff) - if err != nil { - log.Printf("⚠ workflow staleness sweep failed: %v", err) - } else if n > 0 { - log.Printf("🧹 workflows: marked %d instances as stale", n) - } - - // v0.27.0: Retention enforcement — delete completed workflow channels - // where the parent workflow has retention.mode="delete" and - // retention.delete_after_days has elapsed since completion. - n2, err := stores.Channels.EnforceWorkflowRetention(ctx) - if err != nil { - // SQLite doesn't support JSON operators — skip retention on SQLite - if !database.IsSQLite() { - log.Printf("⚠ workflow retention enforcement failed: %v", err) - } - } else if n2 > 0 { - log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n2) - } - - cancel() - <-ticker.C - } - }() - } - - // v0.37.14: Channel retention scanner — purges archived channels past their TTL - retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{}) retScanner.Start() defer retScanner.Stop() @@ -259,7 +160,6 @@ func main() { } defer database.Close() - // ── File Storage ───────────────────────── // Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND // overrides auto-detection. nil objStore = storage features disabled. var s3Cfg *storage.S3Config @@ -285,22 +185,12 @@ func main() { } handlers.SetStorageConfigured(objStore != nil) - // ── Extraction Queue ──────────────────── // Filesystem-based queue for document text extraction (PDF, DOCX, etc.) // Nil if storage is disabled. - var extQueue *extraction.Queue if objStore != nil && cfg.StoragePath != "" { - q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency) if err != nil { log.Printf("⚠ Extraction queue init failed: %v", err) } else { - extQueue = q - // Recover items stuck in "processing" from previous crash - if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil { - log.Printf("⚠ Extraction recovery failed: %v", err) - } else if recovered > 0 { - log.Printf(" 📋 Recovered %d stale extraction items", recovered) - } } } @@ -311,94 +201,15 @@ func main() { // No-op when running SQLite — in-process Bus is sufficient for single-pod. events.StartPGBroadcast(bus) - // ── Workspace FS (v0.21.0) ────────────── - // Provides file operations for workspace storage primitive. - // Nil-safe: handler checks wfs != nil before operating. - var wfs *workspace.FS if cfg.StoragePath != "" { - wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces) if err := wfs.Init(); err != nil { log.Printf("⚠ Workspace FS init failed: %v", err) - wfs = nil } else { log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath) } } - // Register workspace tools (late registration — needs stores + wfs) - if wfs != nil { - tools.RegisterWorkspaceTools(stores, wfs) - } - // Role resolver for model role dispatch (needs stores + vault + bus) - roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus) - - // ── Knowledge Base Pipeline ───────────── - // Embedder + ingester for RAG document processing (v0.14.0). - // Nil-safe: handler checks embedder.IsConfigured() before accepting uploads. - kbEmbedder := knowledge.NewEmbedder(roleResolver).WithStores(stores) - kbIngester := knowledge.NewIngester(stores, kbEmbedder, objStore, knowledge.DefaultConcurrency) - defer kbIngester.Wait() // drain in-flight ingestions on shutdown - - // Register kb_search tool (late registration — needs stores + embedder) - tools.RegisterKBSearch(stores, kbEmbedder) - - // Register note tools (late registration — needs stores + embedder for semantic search) - tools.RegisterNoteTools(stores, kbEmbedder) - - // ── Workspace Indexer (v0.21.2) ─────────── - // Background indexing pipeline for workspace files. - // Shares the embedder with KB ingestion; uses its own concurrency semaphore. - var wsIndexer *workspace.Indexer - if wfs != nil { - idxSem := make(chan struct{}, cfg.WorkspaceIndexConcurrency) - wsIndexer = workspace.NewIndexer(stores, kbEmbedder, wfs, idxSem, cfg.WorkspaceIndexingEnabled) - wfs.SetIndexer(wsIndexer) // hook into write path - defer wsIndexer.Wait() - if cfg.WorkspaceIndexingEnabled { - log.Printf(" 📑 Workspace indexer enabled (concurrency=%d)", cfg.WorkspaceIndexConcurrency) - } else { - log.Printf(" 📑 Workspace indexer disabled (WORKSPACE_INDEXING_ENABLED=false)") - } - } - - // Register workspace_search tool (needs embedder for query embedding) - tools.RegisterWorkspaceSearchTool(stores, kbEmbedder) - - // ── Git Integration (v0.21.4) ──────────── - // GitOps wraps exec-based git operations with vault credential injection. - var gitOps *workspace.GitOps - if wfs != nil { - gitOps = workspace.NewGitOps(wfs, stores, keyResolver, wsIndexer) - tools.RegisterGitTools(gitOps) - log.Println(" 🔀 Git integration enabled") - } - - // Register context recall tools (v0.15.1) - tools.RegisterFileRecall(stores, objStore) - tools.RegisterConversationSearch(stores) - - // Memory tools + extraction scanner (v0.18.0) - tools.RegisterMemoryTools(stores, kbEmbedder) - - // Workflow tools (v0.26.4 — AI-triggered stage advancement) - tools.RegisterWorkflowTools(stores) - - // v0.27.3: task_create tool (AI spawns sub-tasks) - tools.RegisterTaskTools(stores) - memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder) - memCompactor := memory.NewCompactor(stores, roleResolver) - memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{}) - memScanner.Start() - defer memScanner.Stop() - - // ── Pre-completion filter chain (v0.29.0) ── - // Built-in filters register here. Starlark extension filters will - // register at package install time (CS3). - filterChain := filters.NewChain() - filterChain.Register(filters.NewKBInjectFilter(stores)) - - // ── Starlark Runner (v0.29.0 CS3) ── // Sandboxed interpreter for extension scripts. Runner assembles // modules based on granted permissions. Notifier attached below // after notification service init. @@ -406,8 +217,6 @@ func main() { sandbox.New(sandbox.DefaultConfig()), stores, ) - // v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig - starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver)) // v0.38.1: connections module — extension connection resolution starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver)) // v0.29.2: db module — extension namespaced table access @@ -422,10 +231,6 @@ func main() { log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed") } - // Discover and register active Starlark pre-completion filters - filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner) - - log.Printf(" 🔗 Pre-completion filter chain: %d filters", filterChain.Len()) r := gin.New() r.Use(middleware.RequestID()) @@ -475,16 +280,6 @@ func main() { } // v0.27.2: Task scheduler with executor — needs hub + notification service - if stores.Tasks != nil { - // v0.28.6: Register built-in system task functions - scheduler.RegisterBuiltins() - - exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum) - exec.SetRunner(starlarkRunner) - taskSched := scheduler.New(stores, exec) - go taskSched.Run() - log.Println(" ⏰ Task scheduler started (with executor)") - } // Health check (k8s probes hit this directly) base.GET("/health", func(c *gin.Context) { @@ -587,7 +382,6 @@ func main() { "schema_version": database.SchemaVersion(), "database": database.IsConnected(), "database_name": database.Name(), - "providers": providers.List(), } if database.IsConnected() { info["registration_enabled"] = handlers.IsRegistrationEnabled(stores) @@ -633,13 +427,6 @@ func main() { }) // Channels - channels := handlers.NewChannelHandler(stores) - protected.GET("/channels", channels.ListChannels) - protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel) - protected.GET("/channels/:id", channels.GetChannel) - protected.PUT("/channels/:id", channels.UpdateChannel) - protected.DELETE("/channels/:id", channels.DeleteChannel) - protected.POST("/channels/:id/mark-read", channels.MarkRead) // Typing indicator broadcast (v0.23.2) protected.POST("/channels/:id/typing", func(c *gin.Context) { @@ -657,8 +444,6 @@ func main() { if displayName == "" { displayName = userID[:8] } - // Broadcast to other user participants - pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID) if err == nil { payload, _ := json.Marshal(map[string]any{ "channel_id": channelID, @@ -678,7 +463,6 @@ func main() { }) // Chat Folders (v0.23.1) - folders := handlers.NewFolderHandler(stores) protected.GET("/folders", folders.List) protected.POST("/folders", folders.Create) protected.PUT("/folders/:id", folders.Update) @@ -693,7 +477,6 @@ func main() { protected.GET("/users/search", presence.SearchUsers) // Persona groups (v0.23.2 — roster templates for group chats) - pgH := handlers.NewPersonaGroupHandler(stores) protected.GET("/persona-groups", pgH.List) protected.POST("/persona-groups", pgH.Create) protected.GET("/persona-groups/:id", pgH.Get) @@ -717,52 +500,20 @@ func main() { protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) - // Workflow instances (v0.26.2 — runtime lifecycle) - wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner) - protected.POST("/workflows/:id/start", wfInstH.Start) - protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) - protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) - protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) - protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15 // Workflow assignments (v0.26.4 — team assignment queue) - wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) - protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) - protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) - protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) - protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment) - protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment) - protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim) // v0.37.15 - protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign) // v0.37.15 - protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15 // Tasks (v0.27.1, permissions v0.27.2) - taskH := handlers.NewTaskHandler(stores) - protected.GET("/tasks", taskH.ListMine) - protected.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Create) - protected.GET("/tasks/:id", taskH.Get) - protected.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Update) - protected.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Delete) - protected.GET("/tasks/:id/runs", taskH.ListRuns) - protected.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.RunNow) - protected.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.KillRun) // Channel models (v0.20.0 — multi-model @mention routing) - chModelH := handlers.NewChannelModelHandler(stores) protected.GET("/channels/:id/models", chModelH.List) protected.POST("/channels/:id/models", chModelH.Add) protected.PATCH("/channels/:id/models/:modelId", chModelH.Update) protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete) // Channel participants (v0.23.0 — ICD §3.7) - partH := handlers.NewParticipantHandler(stores) - protected.GET("/channels/:id/participants", partH.List) - protected.POST("/channels/:id/participants", middleware.RequirePermission(auth.PermChannelInvite, stores), partH.Add) - protected.PATCH("/channels/:id/participants/:participantId", partH.Update) - protected.DELETE("/channels/:id/participants/:participantId", partH.Remove) // Messages - msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) protected.GET("/channels/:id/messages", msgs.ListMessages) protected.POST("/channels/:id/messages", msgs.CreateMessage) @@ -774,18 +525,6 @@ func main() { protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage) - // Chat Completions - comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) - if healthAccum != nil { - comp.SetHealthRecorder(healthAccum) - comp.SetHealthStore(healthStore) - } - comp.SetRoutingEvaluator(routing.NewEvaluator()) - comp.SetFilterChain(filterChain) - comp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch - protected.POST("/chat/completions", middleware.RequirePermission(auth.PermModelUse, stores), comp.Complete) - protected.GET("/tools", comp.ListTools) - // Surface discovery (v0.25.0, v0.28.7: unified packages) pkgH := handlers.NewPackageHandler(stores) protected.GET("/surfaces", pkgH.ListEnabledSurfaces) @@ -800,24 +539,9 @@ func main() { protected.POST("/packages/install", userPkgH.InstallPersonalPackage) protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage) - // Summarize & Continue (backed by compaction service) - compactionSvc := compaction.NewService(stores, roleResolver) - summarize := handlers.NewSummarizeHandler(stores, compactionSvc) - protected.POST("/channels/:id/summarize", middleware.RequirePermission(auth.PermModelUse, stores), summarize.Summarize) - // Auto-title generation (utility role) - titleH := handlers.NewTitleHandler(stores, roleResolver) - protected.POST("/channels/:id/generate-title", middleware.RequirePermission(auth.PermModelUse, stores), titleH.GenerateTitle) // Provider Configs (user-facing — replaces /api-configs) - provCfg := handlers.NewProviderConfigHandler(stores, keyResolver) - protected.GET("/api-configs", provCfg.ListConfigs) // backward compat - protected.POST("/api-configs", provCfg.CreateConfig) - protected.GET("/api-configs/:id", provCfg.GetConfig) - protected.PUT("/api-configs/:id", provCfg.UpdateConfig) - protected.DELETE("/api-configs/:id", provCfg.DeleteConfig) - protected.GET("/api-configs/:id/models", provCfg.ListModels) - protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels) // Connection Type Discovery (v0.38.4) connTypeH := handlers.NewConnectionTypeHandler(stores) @@ -833,14 +557,8 @@ func main() { protected.DELETE("/connections/:id", connH.DeleteConnection) // Models (unified resolver — replaces scattered endpoints) - modelH := handlers.NewModelHandler(stores) - if healthStore != nil { - modelH.SetHealthStore(healthStore) - } - protected.GET("/models/enabled", modelH.ListEnabledModels) // Model Preferences - modelPrefs := handlers.NewModelPrefsHandler(stores) protected.GET("/models/preferences", modelPrefs.GetPreferences) protected.PUT("/models/preferences", modelPrefs.SetPreference) protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) @@ -864,24 +582,11 @@ func main() { protected.GET("/profile/bootstrap", bootH.GetBootstrap) // Usage (personal) - usage := handlers.NewUsageHandler(stores) protected.GET("/usage", usage.PersonalUsage) // Personas - personas := handlers.NewPersonaHandler(stores) - protected.GET("/personas", personas.ListUserPersonas) - protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona) - protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona) - protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona) - protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar) - protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar) - protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 - protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 - protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 - protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0 // Notes - notes := handlers.NewNoteHandler(stores) protected.GET("/notes", notes.List) protected.POST("/notes", notes.Create) protected.GET("/notes/search", notes.Search) @@ -895,28 +600,6 @@ func main() { protected.GET("/notes/:id/backlinks", notes.Backlinks) // Projects (v0.19.0) - projectH := handlers.NewProjectHandler(stores) - protected.GET("/projects", projectH.List) - protected.POST("/projects", projectH.Create) - protected.GET("/projects/:id", projectH.Get) - protected.PUT("/projects/:id", projectH.Update) - protected.DELETE("/projects/:id", projectH.Delete) - protected.POST("/projects/:id/channels", projectH.AddChannel) - protected.DELETE("/projects/:id/channels/:channelId", projectH.RemoveChannel) - protected.GET("/projects/:id/channels", projectH.ListChannels) - protected.PUT("/projects/:id/channels/reorder", projectH.ReorderChannels) - protected.POST("/projects/:id/knowledge-bases", projectH.AddKB) - protected.DELETE("/projects/:id/knowledge-bases/:kbId", projectH.RemoveKB) - protected.GET("/projects/:id/knowledge-bases", projectH.ListKBs) - protected.POST("/projects/:id/notes", projectH.AddNote) - protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote) - protected.GET("/projects/:id/notes", projectH.ListNotes) - - // Project files (v0.22.4, reworked v0.37.17 — workspace-backed) - projFileH := handlers.NewFileHandler(stores, objStore, extQueue) - if wfs != nil { - projFileH.SetWorkspaceFS(wfs) - } protected.POST("/projects/:id/files", projFileH.UploadToProject) protected.GET("/projects/:id/files", projFileH.ListByProject) protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile) @@ -938,96 +621,20 @@ func main() { protected.PUT("/notifications/preferences/:type", notifH.SetPreference) protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) - // Workspaces (v0.21.0) - if wfs != nil { - wsH := handlers.NewWorkspaceHandler(stores, wfs) - protected.POST("/workspaces", wsH.Create) - protected.GET("/workspaces", wsH.List) - protected.GET("/workspaces/default", wsH.GetDefault) // v0.37.18: before :id param - protected.GET("/workspaces/:id", wsH.Get) - protected.PATCH("/workspaces/:id", wsH.Update) - protected.DELETE("/workspaces/:id", wsH.Delete) - protected.GET("/workspaces/:id/files", wsH.ListFiles) - protected.GET("/workspaces/:id/files/read", wsH.ReadFile) - protected.PUT("/workspaces/:id/files/write", wsH.WriteFile) - protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler) - protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir) - protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive) - protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive) - protected.POST("/workspaces/:id/reconcile", wsH.Reconcile) - protected.GET("/workspaces/:id/stats", wsH.Stats) - protected.GET("/workspaces/:id/index-status", wsH.IndexStatus) - - // Git operations (v0.21.4) - if gitOps != nil { - gitH := handlers.NewGitHandler(stores, gitOps) - protected.POST("/workspaces/:id/git/clone", gitH.Clone) - protected.POST("/workspaces/:id/git/pull", gitH.Pull) - protected.POST("/workspaces/:id/git/push", gitH.Push) - protected.GET("/workspaces/:id/git/status", gitH.Status) - protected.GET("/workspaces/:id/git/diff", gitH.Diff) - protected.POST("/workspaces/:id/git/commit", gitH.Commit) - protected.GET("/workspaces/:id/git/log", gitH.Log) - protected.GET("/workspaces/:id/git/branches", gitH.Branches) - protected.POST("/workspaces/:id/git/checkout", gitH.Checkout) - } - } - // Git credentials (v0.21.4) — user-scoped, independent of workspace - gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver) - 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) - // Files (upload/download) - fileH := handlers.NewFileHandler(stores, objStore, extQueue) - protected.POST("/channels/:id/files", fileH.Upload) - protected.GET("/channels/:id/files", fileH.ListByChannel) - protected.GET("/files", fileH.ListByUser) - protected.GET("/files/:id", fileH.GetMetadata) - protected.GET("/files/:id/download", fileH.Download) - protected.DELETE("/files/:id", fileH.Delete) - protected.GET("/messages/:id/files", fileH.ListByMessage) // Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion - exportH := handlers.NewExportHandler() - protected.POST("/export", exportH.Convert) // Data portability (v0.34.0) — user data export/import - dataExport := handlers.NewDataExportHandler(stores, objStore) protected.GET("/export/me", dataExport.ExportMyData) - dataImport := handlers.NewDataImportHandler(stores, objStore) protected.POST("/import/me", dataImport.ImportMyData) protected.POST("/import/chatgpt", dataImport.ImportChatGPT) - gdprH := handlers.NewGDPRHandler(stores) - protected.DELETE("/me", gdprH.DeleteMyAccount) // Hook: clean up storage files when channels are deleted - handlers.SetChannelDeleteHook(fileH.CleanupChannelStorage) - // Knowledge Bases (RAG — v0.14.0) - kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder) - protected.POST("/knowledge-bases", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB) - protected.GET("/knowledge-bases", kbH.ListKBs) - protected.GET("/knowledge-bases/:id", kbH.GetKB) - protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UpdateKB) - protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteKB) - protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UploadDocument) - protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments) - protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus) - protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteDocument) - protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(auth.PermKBRead, stores), kbH.SearchKB) - protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.RebuildKB) - protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs) - protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs) - protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0 - protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0 (admin only enforced in handler) // Memory management (v0.18.0) - memH := handlers.NewMemoryHandler(stores) - memH.SetCompactor(memCompactor) protected.GET("/memories", memH.ListMyMemories) protected.PUT("/memories/:id", memH.UpdateMemory) protected.DELETE("/memories/:id", memH.DeleteMemory) @@ -1052,7 +659,6 @@ func main() { teamScoped.POST("/members", teams.AddMember) teamScoped.PUT("/members/:memberId", teams.UpdateMember) teamScoped.DELETE("/members/:memberId", teams.RemoveMember) - teamScoped.GET("/models", teams.ListAvailableModels) // Team groups (team admins manage team-scoped groups) teamScoped.GET("/groups", groupH.ListTeamGroups) @@ -1080,11 +686,9 @@ func main() { teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) // Team usage (team admins only — usage against team-owned providers) - teamUsage := handlers.NewUsageHandler(stores) teamScoped.GET("/usage", teamUsage.TeamUsage) // Team personas - teamPersonas := handlers.NewPersonaHandler(stores) teamScoped.GET("/personas", teamPersonas.ListTeamPersonas) teamScoped.POST("/personas", teamPersonas.CreateTeamPersona) teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona) @@ -1096,15 +700,11 @@ func main() { teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0 teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // v0.28.0 - // Team role overrides - teamRoles := handlers.NewRolesHandler(stores, roleResolver) teamScoped.GET("/roles", teamRoles.ListTeamRoles) teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) // Team workflow assignments (v0.26.4) - teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) - teamScoped.GET("/assignments", teamAssignH.ListForTeam) // Team workflows — self-service (v0.31.2) teamWfH := handlers.NewWorkflowHandler(stores) @@ -1122,27 +722,15 @@ func main() { teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion) // Team workflow monitoring (v0.35.0) - teamWfMon := handlers.NewWorkflowMonitorHandler(stores) - teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances) - // Team workflow instance cancel (v0.37.15) - teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner) - teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance) // Team tasks — admin CRUD (v0.27.5) - teamTaskH := handlers.NewTaskHandler(stores) - teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask) - teamScoped.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Update) - teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete) - teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow) - teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun) } // Team task viewing for all members (v0.27.5) teamMemberRoutes := protected.Group("/teams/:teamId") teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams)) { - teamMemberTaskH := handlers.NewTaskHandler(stores) teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks) teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns) } @@ -1185,10 +773,6 @@ func main() { admin.GET("/stats", adm.GetStats) // Global Provider Configs - admin.GET("/configs", adm.ListGlobalConfigs) - admin.POST("/configs", adm.CreateGlobalConfig) - admin.PUT("/configs/:id", adm.UpdateGlobalConfig) - admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) // Global Connections (v0.38.1) admin.GET("/connections", adm.ListGlobalConnections) @@ -1197,14 +781,8 @@ func main() { admin.DELETE("/connections/:id", adm.DeleteGlobalConnection) // Model Catalog - admin.GET("/models", adm.ListModelConfigs) - admin.POST("/models/fetch", adm.FetchModels) - admin.PUT("/models/bulk", adm.BulkUpdateModels) - admin.PUT("/models/:id", adm.UpdateModelConfig) - admin.DELETE("/models/:id", adm.DeleteModelConfig) // Personas (admin global) - personaAdm := handlers.NewPersonaHandler(stores) admin.GET("/personas", personaAdm.ListAdminPersonas) admin.POST("/personas", personaAdm.CreateAdminPersona) admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona) @@ -1217,7 +795,6 @@ func main() { admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0 // Admin memory review (v0.18.0) - adminMemH := handlers.NewMemoryHandler(stores) admin.GET("/memories/pending", adminMemH.ListPendingReview) admin.POST("/memories/bulk-approve", adminMemH.BulkApprove) @@ -1234,8 +811,6 @@ func main() { admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) // Team data export/import (v0.34.0) - teamExport := handlers.NewDataExportHandler(stores, objStore) - teamImport := handlers.NewDataImportHandler(stores, objStore) admin.GET("/teams/:id/export", teamExport.ExportTeam) admin.POST("/teams/:id/import", teamImport.ImportTeam) @@ -1267,26 +842,16 @@ func main() { admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) // Projects (admin — v0.19.0) - adminProjH := handlers.NewProjectHandler(stores) admin.GET("/projects", adminProjH.AdminList) admin.DELETE("/projects/:id", adminProjH.Delete) admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) - // Model Roles - rolesH := handlers.NewRolesHandler(stores, roleResolver) admin.GET("/roles", rolesH.ListRoles) admin.GET("/roles/:role", rolesH.GetRole) admin.PUT("/roles/:role", rolesH.UpdateRole) admin.POST("/roles/:role/test", rolesH.TestRole) // Usage & Pricing - usageH := handlers.NewUsageHandler(stores) - admin.GET("/usage", usageH.AdminUsage) - admin.GET("/usage/users/:id", usageH.AdminUserUsage) - admin.GET("/usage/teams/:id", usageH.AdminTeamUsage) - admin.GET("/pricing", usageH.ListPricing) - admin.PUT("/pricing", usageH.UpsertPricing) - admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing) // Storage status storageH := handlers.NewStorageHandler(objStore) @@ -1299,15 +864,11 @@ func main() { emailAdm := handlers.NewAdminEmailHandler(stores) admin.POST("/notifications/test-email", emailAdm.TestEmail) - // Storage management (orphan cleanup) - fileAdm := handlers.NewFileHandler(stores, objStore, extQueue) admin.GET("/storage/orphans", fileAdm.OrphanCount) admin.POST("/storage/cleanup", fileAdm.CleanupOrphans) admin.GET("/storage/extraction", fileAdm.ExtractionStatus) // Archived channels (admin — v0.23.2) - admin.GET("/channels/archived", adm.ListArchivedChannels) - admin.DELETE("/channels/:id/purge", adm.PurgeChannel) // Extensions (admin) extAdm := handlers.NewExtensionHandler(stores) @@ -1330,17 +891,12 @@ func main() { admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets) admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets) - // Admin Dashboard (v0.33.0) - dashAdm := handlers.NewDashboardAdminHandler(stores, healthStore, hub) admin.GET("/dashboard", dashAdm.GetDashboard) - // Provider Health (admin — v0.22.0) - healthAdm := handlers.NewHealthAdminHandler(healthStore, stores) admin.GET("/providers/health", healthAdm.GetAllProviderHealth) admin.GET("/providers/:id/health", healthAdm.GetProviderHealth) // Capability Overrides (admin — v0.22.0) - capAdm := handlers.NewCapOverrideAdminHandler(stores) admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities) admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability) admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability) @@ -1349,9 +905,6 @@ func main() { // Provider Types (admin — v0.22.1) admin.GET("/provider-types", handlers.GetProviderTypes) - // Routing Policies (admin — v0.22.2) - routingEval := routing.NewEvaluator() - routingAdm := handlers.NewRoutingAdminHandler(stores, routingEval, healthStore) admin.GET("/routing/policies", routingAdm.ListPolicies) admin.GET("/routing/policies/:id", routingAdm.GetPolicy) admin.POST("/routing/policies", routingAdm.CreatePolicy) @@ -1366,7 +919,6 @@ func main() { } pkgAdm := handlers.NewPackageHandler(stores, packagesDir) pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations - pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool // Package registry — must be registered before /packages/:id (v0.30.0) registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm) @@ -1387,7 +939,6 @@ func main() { admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2 // Package export (v0.30.0) - pkgExport := handlers.NewPackageExportHandler(stores, packagesDir) admin.GET("/packages/:id/export", pkgExport.ExportPackage) // Workflow package export (v0.30.2) @@ -1395,10 +946,6 @@ func main() { admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage) // Workflow monitoring (v0.35.0) - wfMonH := handlers.NewWorkflowMonitorHandler(stores) - admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances) - admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel) - admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances) // Surface aliases (backward compat — same handlers) admin.GET("/surfaces", pkgAdm.ListPackages) @@ -1409,7 +956,6 @@ func main() { admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage) // Task management — admin (v0.27.1, extended v0.27.2) - taskAdm := handlers.NewTaskHandler(stores) admin.GET("/tasks", taskAdm.ListAll) admin.POST("/tasks/:id/run", taskAdm.RunNow) admin.POST("/tasks/:id/kill", taskAdm.KillRun) @@ -1472,17 +1018,11 @@ func main() { wfAPI := base.Group("/api/v1/w") wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) { - wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) wfAPI.POST("/:id/messages", wfMsgs.CreateMessage) wfAPI.GET("/:id/messages", wfMsgs.ListMessages) - wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) - wfComp.SetFilterChain(filterChain) - wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch wfAPI.POST("/:id/completions", wfComp.Complete) - // v0.29.3: Workflow form endpoints - wfForms := handlers.NewWorkflowFormHandler(stores, starlarkRunner, hub) wfAPI.GET("/:id/form", wfForms.GetFormTemplate) wfAPI.POST("/:id/form-submit", wfForms.SubmitForm) } @@ -1491,12 +1031,9 @@ func main() { // NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie // conflicts with /api/v1/w/:id/messages (different wildcard names at // same path position). Separate namespace avoids the collision. - wfEntry := handlers.NewWorkflowEntryHandler(stores) base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor) // v0.28.0: Webhook trigger endpoint (token-based auth, no JWT) - triggerH := handlers.NewTriggerHandler(stores) - base.POST("/api/v1/hooks/t/:token", triggerH.Handle) bp := cfg.BasePath if bp == "" { @@ -1505,19 +1042,12 @@ func main() { log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) log.Printf(" Base path: %s", bp) log.Printf(" Schema: %s", database.SchemaVersion()) - log.Printf(" Providers: %v", providers.List()) if objStore != nil { log.Printf(" Storage: %s", objStore.Backend()) - if extQueue != nil { - log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency) - } else { - log.Printf(" Extraction: disabled") - } } else { log.Printf(" Storage: disabled") } log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) - log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge) log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces())) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) @@ -1528,22 +1058,6 @@ func main() { // loadSearchConfig reads search provider config from global_config and applies it. // Falls back to DuckDuckGo (the default set in search package init) if not configured. -func loadSearchConfig(stores store.Stores) { - raw, err := stores.GlobalConfig.Get(context.Background(), "search_config") - if err != nil || raw == nil { - log.Println("🔍 Search: using default provider (DuckDuckGo)") - return - } - b, _ := json.Marshal(raw) - var cfg search.Config - if err := json.Unmarshal(b, &cfg); err != nil { - log.Printf("⚠️ Failed to parse search config: %v", err) - return - } - if err := search.ApplyConfig(cfg); err != nil { - log.Printf("⚠️ Failed to apply search config: %v", err) - } -} func runVaultCommand(subcmd string) { cfg := config.Load() diff --git a/server/middleware/session_auth.go b/server/middleware/session_auth.go deleted file mode 100644 index 99ab12f..0000000 --- a/server/middleware/session_auth.go +++ /dev/null @@ -1,110 +0,0 @@ -package middleware - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - - "switchboard-core/auth" - "switchboard-core/config" - "switchboard-core/database" - "switchboard-core/store" -) - -// AuthOrSession returns middleware that accepts either a normal JWT or a -// session cookie. Authenticated users get the standard context values -// (user_id, email, role, auth_type="user"). Session visitors get -// session_id, channel_id, and auth_type="session". -// -// Session auth is only valid for workflow channels with allow_anonymous=true. -func AuthOrSession(cfg *config.Config, stores store.Stores, cache *UserStatusCache) gin.HandlerFunc { - return func(c *gin.Context) { - // Skip auth when running without a database - if !database.IsConnected() { - c.Next() - return - } - - // ── Try normal JWT auth first ── - tokenString := extractBearerToken(c) - if tokenString != "" { - if claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret); ok { - role, valid := verifyUser(c, claims, stores.Users, cache) - if !valid { - return - } - c.Set("user_id", claims.UserID) - c.Set("email", claims.Email) - c.Set("role", role) - c.Set("auth_type", "user") - c.Next() - return - } - } - - // ── Try sb_token cookie (page auth pattern) ── - if cookie, err := c.Cookie("sb_token"); err == nil && cookie != "" { - if claims, ok := parseAndValidateJWT(cookie, cfg.JWTSecret); ok { - role, valid := verifyUser(c, claims, stores.Users, cache) - if !valid { - return - } - c.Set("user_id", claims.UserID) - c.Set("email", claims.Email) - c.Set("role", role) - c.Set("auth_type", "user") - c.Next() - return - } - } - - // ── Fall back to session auth ── - channelID := c.Param("id") // channel routes use :id - if channelID == "" { - channelID = c.Param("channelId") - } - if channelID == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - // Verify channel exists, is workflow type, and allows anonymous - chType, allowAnon, err := stores.Channels.GetTypeAndAllowAnonymous(c.Request.Context(), channelID) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - if chType != "workflow" || !allowAnon { - c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "anonymous access not permitted on this channel"}) - return - } - - session, err := auth.CreateOrResumeSession(c, stores, channelID, cfg) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session creation failed"}) - return - } - - c.Set("session_id", session.ID) - c.Set("session_token", session.SessionToken) - c.Set("channel_id", channelID) - c.Set("auth_type", "session") - c.Next() - } -} - -// extractBearerToken gets the JWT from Authorization header or ?token= query param. -func extractBearerToken(c *gin.Context) string { - header := c.GetHeader("Authorization") - if header == "" { - if qToken := c.Query("token"); qToken != "" { - return qToken - } - return "" - } - if strings.HasPrefix(header, "Bearer ") { - return strings.TrimPrefix(header, "Bearer ") - } - return "" -} diff --git a/server/models/models.go b/server/models/models.go index bfa681d..0af7906 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -369,25 +369,9 @@ type ResourceGrant struct { // ProviderStatus represents the derived health state. // AvgLatencyMs returns the average latency, or 0 if no requests. -func (w *ProviderHealthWindow) AvgLatencyMs() int { - if w.RequestCount == 0 { - return 0 - } - return int(w.TotalLatencyMs / int64(w.RequestCount)) -} // ErrorRate returns the error fraction, or 0 if no requests. -func (w *ProviderHealthWindow) ErrorRate() float64 { - if w.RequestCount == 0 { - return 0 - } - return float64(w.ErrorCount) / float64(w.RequestCount) -} -// ProviderHealthSummary is the API response for a provider's current health. -// ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.) -// in hourly buckets, analogous to ProviderHealthWindow. -// ToolHealthSummary is the API response for a tool's health. // CAPABILITY OVERRIDES (v0.22.0) // CapabilityOverride is an admin correction for a model's capabilities. diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go index 1b4078d..fc6060d 100644 --- a/server/models/models_extension_perm.go +++ b/server/models/models_extension_perm.go @@ -28,11 +28,9 @@ var ValidPackageStatuses = map[string]bool{ const ( ExtPermSecretsRead = "secrets.read" ExtPermNotificationsSend = "notifications.send" - ExtPermFiltersPreCompletion = "filters.pre_completion" ExtPermDBRead = "db.read" ExtPermDBWrite = "db.write" ExtPermAPIHTTP = "api.http" - ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution @@ -42,11 +40,9 @@ const ( var ValidExtensionPermissions = map[string]bool{ ExtPermSecretsRead: true, ExtPermNotificationsSend: true, - ExtPermFiltersPreCompletion: true, ExtPermDBRead: true, ExtPermDBWrite: true, ExtPermAPIHTTP: true, - ExtPermProviderComplete: true, ExtPermFormValidate: true, ExtPermWorkflowAccess: true, ExtPermConnectionsRead: true, diff --git a/server/models/models_notification.go b/server/models/models_notification.go index d119962..2d7031c 100644 --- a/server/models/models_notification.go +++ b/server/models/models_notification.go @@ -27,7 +27,6 @@ const ( NotifTypeKBReady = "kb.ready" NotifTypeKBError = "kb.error" NotifTypeGrantChanged = "grant.changed" - NotifTypeMemoryExtracted = "memory.extracted" NotifTypeUserMentioned = "user.mentioned" NotifTypeWorkflowAssign = "workflow.assigned" NotifTypeWorkflowClaimed = "workflow.claimed" diff --git a/server/models/models_task.go b/server/models/models_task.go deleted file mode 100644 index 7cc1dc4..0000000 --- a/server/models/models_task.go +++ /dev/null @@ -1,99 +0,0 @@ -package models - -import ( - "encoding/json" - "time" -) - -// Task is a scheduled, one-shot, or webhook-triggered job that creates a -// service channel and runs a completion (prompt task), instantiates a -// workflow, relays a payload without LLM involvement (action task), or -// executes a built-in Go function (system task). -type Task struct { - ID string `json:"id" db:"id"` - OwnerID string `json:"owner_id" db:"owner_id"` - TeamID *string `json:"team_id,omitempty" db:"team_id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Scope string `json:"scope" db:"scope"` // personal | team | global - - // What to run - TaskType string `json:"task_type" db:"task_type"` // prompt | workflow | action | system - SystemFunction string `json:"system_function,omitempty" db:"system_function"` - PersonaID *string `json:"persona_id,omitempty" db:"persona_id"` - ModelID string `json:"model_id" db:"model_id"` - SystemPrompt string `json:"system_prompt" db:"system_prompt"` - UserPrompt string `json:"user_prompt" db:"user_prompt"` - WorkflowID *string `json:"workflow_id,omitempty" db:"workflow_id"` - ToolGrants json.RawMessage `json:"tool_grants,omitempty" db:"tool_grants"` - - // Schedule - Schedule string `json:"schedule" db:"schedule"` // cron expression, "once", or "webhook" - Timezone string `json:"timezone" db:"timezone"` - IsActive bool `json:"is_active" db:"is_active"` - - // Webhook trigger (inbound) - TriggerToken string `json:"trigger_token,omitempty" db:"trigger_token"` - - // Execution policy - MaxTokens int `json:"max_tokens" db:"max_tokens"` - MaxToolCalls int `json:"max_tool_calls" db:"max_tool_calls"` - MaxWallClock int `json:"max_wall_clock" db:"max_wall_clock"` // seconds - OutputMode string `json:"output_mode" db:"output_mode"` // channel | note | webhook - OutputChannelID *string `json:"output_channel_id,omitempty" db:"output_channel_id"` - WebhookURL string `json:"webhook_url" db:"webhook_url"` - WebhookSecret string `json:"webhook_secret,omitempty" db:"webhook_secret"` - - // Provider routing - ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"` - - // Notifications - NotifyOnComplete bool `json:"notify_on_complete" db:"notify_on_complete"` - NotifyOnFailure bool `json:"notify_on_failure" db:"notify_on_failure"` - - // Bookkeeping - LastRunAt *time.Time `json:"last_run_at,omitempty" db:"last_run_at"` - NextRunAt *time.Time `json:"next_run_at,omitempty" db:"next_run_at"` - RunCount int `json:"run_count" db:"run_count"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` -} - -// TaskPatch contains optional fields for updating a task. -type TaskPatch struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - PersonaID *string `json:"persona_id,omitempty"` - ModelID *string `json:"model_id,omitempty"` - SystemPrompt *string `json:"system_prompt,omitempty"` - UserPrompt *string `json:"user_prompt,omitempty"` - WorkflowID *string `json:"workflow_id,omitempty"` - ToolGrants *json.RawMessage `json:"tool_grants,omitempty"` - Schedule *string `json:"schedule,omitempty"` - Timezone *string `json:"timezone,omitempty"` - IsActive *bool `json:"is_active,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - MaxToolCalls *int `json:"max_tool_calls,omitempty"` - MaxWallClock *int `json:"max_wall_clock,omitempty"` - OutputMode *string `json:"output_mode,omitempty"` - OutputChannelID *string `json:"output_channel_id,omitempty"` - WebhookURL *string `json:"webhook_url,omitempty"` - ProviderConfigID *string `json:"provider_config_id,omitempty"` - NotifyOnComplete *bool `json:"notify_on_complete,omitempty"` - NotifyOnFailure *bool `json:"notify_on_failure,omitempty"` -} - -// TaskRun records a single execution of a task. -type TaskRun struct { - ID string `json:"id" db:"id"` - TaskID string `json:"task_id" db:"task_id"` - ChannelID *string `json:"channel_id,omitempty" db:"channel_id"` - Status string `json:"status" db:"status"` // queued | running | completed | failed | budget_exceeded | cancelled - TriggerPayload string `json:"trigger_payload,omitempty" db:"trigger_payload"` - StartedAt time.Time `json:"started_at" db:"started_at"` - CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` - TokensUsed int `json:"tokens_used" db:"tokens_used"` - ToolCalls int `json:"tool_calls" db:"tool_calls"` - WallClock int `json:"wall_clock" db:"wall_clock"` // seconds - Error string `json:"error,omitempty" db:"error"` -} diff --git a/server/models/workflow.go b/server/models/workflow.go index aa3ac16..93c6b47 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -57,7 +57,7 @@ type WorkflowStage struct { PersonaID *string `json:"persona_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"` FormTemplate json.RawMessage `json:"form_template"` - StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat | review + StageMode string `json:"stage_mode"` // form_only | form_chat | review | custom HistoryMode string `json:"history_mode"` // full | summary | fresh AutoTransition bool `json:"auto_transition"` TransitionRules json.RawMessage `json:"transition_rules"` @@ -69,7 +69,7 @@ type WorkflowStage struct { // ── Stage Mode Constants ──────────────────── const ( - StageModeChatOnly = "chat_only" + StageModeCustom = "custom" StageModeFormOnly = "form_only" StageModeFormChat = "form_chat" StageModeReview = "review" @@ -77,7 +77,7 @@ const ( // ValidStageModes is the set of valid stage_mode values. var ValidStageModes = map[string]bool{ - StageModeChatOnly: true, + StageModeCustom: true, StageModeFormOnly: true, StageModeFormChat: true, StageModeReview: true, diff --git a/server/notifications/sources.go b/server/notifications/sources.go index 168f31a..7f36985 100644 --- a/server/notifications/sources.go +++ b/server/notifications/sources.go @@ -145,30 +145,6 @@ func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) { } } -// ── Memory Extraction ────────────────────── -// Called from memory/extractor.go after successful extraction. - -// NotifyMemoryExtracted creates a notification when new memories are extracted. -func NotifyMemoryExtracted(svc *Service, userID, channelID string, count int) { - if svc == nil { - return - } - title := fmt.Sprintf("%d new memories extracted", count) - if count == 1 { - title = "1 new memory extracted" - } - n := models.Notification{ - UserID: userID, - Type: models.NotifTypeMemoryExtracted, - Title: title, - ResourceType: models.ResourceTypeChannel, - ResourceID: channelID, - } - if err := svc.Notify(context.Background(), &n); err != nil { - log.Printf("[notifications] memory.extracted failed for user %s: %v", userID, err) - } -} - // ── User Mention ─────────────────────────── // Called from handlers/completion.go when an @mention targets a human user. diff --git a/server/pages/loaders.go b/server/pages/loaders.go index 642c246..cfe86cd 100644 --- a/server/pages/loaders.go +++ b/server/pages/loaders.go @@ -7,7 +7,6 @@ import ( "github.com/gin-gonic/gin" - "switchboard-core/providers" "switchboard-core/store" ) @@ -137,7 +136,6 @@ func (e *Engine) registerLoaders() { e.RegisterLoader("projects", e.projectsLoader) } -// ListDataProviders returns the keys of all registered data providers. // Used for manifest validation — DataRequires entries must match a key here. func (e *Engine) ListDataProviders() []string { keys := make([]string, 0, len(e.loaders)) @@ -331,7 +329,6 @@ func sectionCategory(section string) string { // loadProviderTypes returns the registered provider type metadata. func loadProviderTypes() []ProviderTypeOption { - types := providers.ListTypes() out := make([]ProviderTypeOption, 0, len(types)) for _, t := range types { out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name}) diff --git a/server/pages/pages.go b/server/pages/pages.go index 721e742..f7eb8bf 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -624,9 +624,9 @@ type WorkflowPageData struct { ChannelDescription string SessionID string SessionName string - StageMode string // chat_only | form_only | form_chat | review + StageMode string // custom | form_only | form_chat | review StageName string - FormTemplateJSON string // typed form template JSON (empty if chat_only) + FormTemplateJSON string // typed form template JSON (empty if custom) TotalStages int CurrentStage int SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode) @@ -648,7 +648,7 @@ type WorkflowLandingPageData struct { PersonaName string PersonaIcon string StageCount int - FirstStageMode string // chat_only | form_only | form_chat (v0.29.3) + FirstStageMode string // custom | form_only | form_chat (v0.29.3) ResumeURL string // non-empty if visitor has an active session } @@ -661,61 +661,17 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc { // Load channel metadata var title, description string - if e.stores.Channels != nil && channelID != "" { - ch, err := e.stores.Channels.GetByID(c.Request.Context(), channelID) - if err == nil { - title = ch.Title - description = ch.Description - } - } if title == "" { title = "Workflow" } // Load session display name sessionName := "Visitor" - if e.stores.Sessions != nil && sessionID != "" { - sp, err := e.stores.Sessions.GetByID(c.Request.Context(), sessionID) - if err == nil { - sessionName = sp.DisplayName - } - } // Load workflow stage info for form rendering (v0.29.3) var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string var totalStages, currentStage int - stageMode = "chat_only" // default - if e.stores.Channels != nil && channelID != "" { - ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID) - if wsErr == nil && ws != nil && ws.WorkflowID != nil { - currentStage = ws.CurrentStage - - // v0.35.0: Load workflow branding - if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil { - if len(wf.Branding) > 0 && string(wf.Branding) != "{}" { - brandingJSON = string(wf.Branding) - } - } - - if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 { - totalStages = len(stages) - if currentStage < len(stages) { - stg := stages[currentStage] - stageMode = stg.StageMode - stageName = stg.Name - if stageMode == "" { - stageMode = "chat_only" - } - if stageMode != "chat_only" { - formTplJSON = string(stg.FormTemplate) - } - if stg.SurfacePkgID != nil { - surfacePkgID = *stg.SurfacePkgID - } - } - } - } - } + stageMode = "custom" // default instanceName, _, _ := e.loadBranding() @@ -791,7 +747,7 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc { if len(stages) > 0 { data.FirstStageMode = stages[0].StageMode if data.FirstStageMode == "" { - data.FirstStageMode = "chat_only" + data.FirstStageMode = "custom" } if stages[0].PersonaID != nil { if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil { @@ -803,12 +759,6 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc { // Check for existing active session sbSession, err := c.Cookie("sb_session") - if err == nil && sbSession != "" && e.stores.Sessions != nil { - sess, err := e.stores.Sessions.GetByToken(ctx, sbSession) - if err == nil && sess != nil { - data.ResumeURL = "/w/" + sess.ChannelID - } - } instanceName, _, _ := e.loadBranding() diff --git a/server/sandbox/provider_module.go b/server/sandbox/provider_module.go deleted file mode 100644 index 674cf07..0000000 --- a/server/sandbox/provider_module.go +++ /dev/null @@ -1,241 +0,0 @@ -// Package sandbox — provider_module.go -// -// v0.29.1 CS2: Provider module for Starlark extensions. -// Requires permission: provider.complete -// -// Starlark API: -// -// resp = provider.complete( -// messages=[{"role": "user", "content": "Hello"}], -// model="claude-3-haiku", # optional — uses default from BYOK chain -// max_tokens=1024, # optional — default 4096 -// temperature=0.7, # optional — provider default -// ) -// -// # resp = { -// # "content": "Hi there!", -// # "model": "claude-3-haiku-20240307", -// # "finish_reason": "stop", -// # "input_tokens": 10, -// # "output_tokens": 15, -// # } -// -// Provider resolution uses the existing BYOK chain via the -// ProviderResolver interface (implemented by handlers package). -// This avoids a circular dependency (sandbox → handlers → sandbox). -package sandbox - -import ( - "context" - "fmt" - - "go.starlark.net/starlark" - "go.starlark.net/starlarkstruct" - - "switchboard-core/providers" -) - -// ─── Provider Resolution Interface ────────── - -// ProviderResolution holds the resolved provider and config. -// Mirrors handlers.ProviderResolution without importing it. -type ProviderResolution struct { - Provider providers.Provider - Config providers.ProviderConfig - ProviderID string - Model string - ConfigID string -} - -// ProviderResolver resolves a provider configuration from the BYOK chain. -// Implemented by handlers via a thin adapter (set on Runner at startup). -type ProviderResolver interface { - Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*ProviderResolution, error) -} - -// ─── Configuration ────────────────────────── - -const providerDefaultMaxTokens = 4096 - -// ProviderModuleConfig holds per-package provider settings parsed from -// the manifest's requires_provider field. -type ProviderModuleConfig struct { - // ProviderConfigID pins the extension to a specific provider config. - // Empty string means use the BYOK resolution chain. - ProviderConfigID string - - // DefaultModel is the model to use when the script doesn't specify one. - DefaultModel string -} - -// ParseRequiresProvider extracts ProviderModuleConfig from a manifest. -// -// Accepted formats: -// -// true → empty config (BYOK default) -// {"model": "claude-3-haiku"} → default model -// {"provider_config_id": "uuid", ...} → pinned provider -func ParseRequiresProvider(manifest map[string]any) (ProviderModuleConfig, bool) { - raw, ok := manifest["requires_provider"] - if !ok { - return ProviderModuleConfig{}, false - } - - // Boolean shorthand - if b, ok := raw.(bool); ok { - return ProviderModuleConfig{}, b - } - - // Object form - m, ok := raw.(map[string]any) - if !ok { - return ProviderModuleConfig{}, false - } - - cfg := ProviderModuleConfig{} - if v, ok := m["provider_config_id"].(string); ok { - cfg.ProviderConfigID = v - } - if v, ok := m["model"].(string); ok { - cfg.DefaultModel = v - } - - return cfg, true -} - -// ─── Module Builder ───────────────────────── - -// BuildProviderModule creates the "provider" Starlark module. Each call -// to provider.complete() resolves a provider via the BYOK chain and -// makes a synchronous (non-streaming) LLM completion call. -func BuildProviderModule( - ctx context.Context, - resolver ProviderResolver, - userID string, - manifestCfg ProviderModuleConfig, -) *starlarkstruct.Module { - return MakeModule("provider", starlark.StringDict{ - "complete": starlark.NewBuiltin("provider.complete", func( - thread *starlark.Thread, b *starlark.Builtin, - args starlark.Tuple, kwargs []starlark.Tuple, - ) (starlark.Value, error) { - var messagesList *starlark.List - var model string - var maxTokens int = providerDefaultMaxTokens - var temperature starlark.Value = starlark.None - - if err := starlark.UnpackArgs(b.Name(), args, kwargs, - "messages", &messagesList, - "model?", &model, - "max_tokens?", &maxTokens, - "temperature?", &temperature, - ); err != nil { - return nil, err - } - - // Apply default model from manifest - if model == "" { - model = manifestCfg.DefaultModel - } - - // Convert Starlark messages to provider messages - msgs, err := starlarkToMessages(messagesList) - if err != nil { - return nil, fmt.Errorf("provider.complete: %w", err) - } - if len(msgs) == 0 { - return nil, fmt.Errorf("provider.complete: messages list is empty") - } - - // Resolve provider via BYOK chain - res, err := resolver.Resolve(ctx, userID, "", - manifestCfg.ProviderConfigID, model) - if err != nil { - return nil, fmt.Errorf("provider.complete: %w", err) - } - - // Build request - req := providers.CompletionRequest{ - Model: res.Model, - Messages: msgs, - MaxTokens: maxTokens, - Stream: false, - } - - // Set temperature if provided - if temperature != starlark.None { - if f, ok := starlark.AsFloat(temperature); ok { - req.Temperature = &f - } - } - - // Make synchronous completion call - resp, err := res.Provider.ChatCompletion(ctx, res.Config, req) - if err != nil { - return nil, fmt.Errorf("provider.complete: LLM call failed: %w", err) - } - - return completionToStarlark(resp) - }), - }) -} - -// ─── Message Conversion ───────────────────── - -// starlarkToMessages converts a Starlark list of dicts to provider Messages. -// Each dict must have "role" (string) and "content" (string). -func starlarkToMessages(list *starlark.List) ([]providers.Message, error) { - if list == nil { - return nil, nil - } - - msgs := make([]providers.Message, 0, list.Len()) - 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 messages 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("message '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("message 'content' must be a string") - } - - msgs = append(msgs, providers.Message{ - Role: role, - Content: content, - }) - } - - return msgs, nil -} - -// ─── Response Conversion ──────────────────── - -// completionToStarlark converts a provider CompletionResponse to a Starlark dict. -func completionToStarlark(resp *providers.CompletionResponse) (starlark.Value, error) { - d := starlark.NewDict(5) - _ = d.SetKey(starlark.String("content"), starlark.String(resp.Content)) - _ = d.SetKey(starlark.String("model"), starlark.String(resp.Model)) - _ = d.SetKey(starlark.String("finish_reason"), starlark.String(resp.FinishReason)) - _ = d.SetKey(starlark.String("input_tokens"), starlark.MakeInt(resp.InputTokens)) - _ = d.SetKey(starlark.String("output_tokens"), starlark.MakeInt(resp.OutputTokens)) - return d, nil -} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index dd38974..5e5adf4 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -44,8 +44,7 @@ type RunContext struct { // UserID is the acting user for provider resolution (BYOK chain). UserID string - // ChannelID is the channel context, if any. Used for provider - // resolution when a channel has a pinned provider config. + // ChannelID is the context identifier, if any. ChannelID string } @@ -55,7 +54,6 @@ type Runner struct { stores store.Stores packagesDir string // v0.38.0: disk path for load() support notifier NotificationSender // nil = notifications module unavailable - resolver ProviderResolver // nil = provider module unavailable connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1) db *sql.DB // nil = db module unavailable dbPostgres bool // true = use $N placeholders; false = use ? @@ -75,11 +73,6 @@ func (r *Runner) SetNotifier(n NotificationSender) { r.notifier = n } -// SetProviderResolver attaches the provider resolver for the provider.complete module. -func (r *Runner) SetProviderResolver(pr ProviderResolver) { - r.resolver = pr -} - // SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1). func (r *Runner) SetConnectionResolver(cr ConnectionResolver) { r.connResolver = cr @@ -315,11 +308,6 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m httpCfg.AllowPrivateIPs = r.allowPrivateIPs modules["http"] = BuildHTTPModule(ctx, httpCfg) - case models.ExtPermProviderComplete: - if r.resolver != nil && rc != nil && rc.UserID != "" { - provCfg, ok := ParseRequiresProvider(manifest) - if ok { - modules["provider"] = BuildProviderModule(ctx, r.resolver, rc.UserID, provCfg) } } diff --git a/server/sandbox/workflow_module.go b/server/sandbox/workflow_module.go index 4f92e9e..2fd4458 100644 --- a/server/sandbox/workflow_module.go +++ b/server/sandbox/workflow_module.go @@ -28,10 +28,6 @@ import ( func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module { return MakeModule("workflow", starlark.StringDict{ "get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)), - "get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)), - "advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)), - "reject": starlark.NewBuiltin("workflow.reject", workflowReject(ctx, stores)), - "route": starlark.NewBuiltin("workflow.route", workflowRoute(ctx, stores)), }) } @@ -82,135 +78,5 @@ func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thr } } -func workflowGetStageData(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { - return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var channelID string - if err := starlark.UnpackPositionalArgs("workflow.get_stage_data", args, kwargs, 1, &channelID); err != nil { - return nil, err - } - - ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil { - return starlark.None, fmt.Errorf("workflow.get_stage_data: channel not found or not a workflow") - } - - result := starlark.NewDict(8) - result.SetKey(starlark.String("current_stage"), starlark.MakeInt(ws.CurrentStage)) - result.SetKey(starlark.String("status"), starlark.String(ws.Status)) - - if ws.StageData != nil { - var data map[string]any - if json.Unmarshal(ws.StageData, &data) == nil { - for k, v := range data { - sv, err := goToStarlark(v) - if err == nil { - result.SetKey(starlark.String(k), sv) - } - } - } - } - - return result, nil - } -} - -func workflowAdvance(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { - return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var channelID string - if err := starlark.UnpackPositionalArgs("workflow.advance", args, kwargs, 1, &channelID); err != nil { - return nil, err - } - - ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil || ws.WorkflowID == nil { - return starlark.None, fmt.Errorf("workflow.advance: channel not found or not a workflow") - } - - stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID) - if err != nil { - return starlark.None, fmt.Errorf("workflow.advance: %w", err) - } - - nextStage, err := workflow.ResolveNextStage(stages, ws.CurrentStage, ws.StageData) - if err != nil { - return starlark.None, fmt.Errorf("workflow.advance: routing error: %w", err) - } - if nextStage >= len(stages) { - // Complete the workflow - if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil { - return starlark.None, fmt.Errorf("workflow.advance: %w", err) - } - return starlark.String("completed"), nil - } - - if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, ws.StageData); err != nil { - return starlark.None, fmt.Errorf("workflow.advance: %w", err) - } - return starlark.String("advanced"), nil - } -} - -func workflowReject(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { - return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var channelID, reason string - if err := starlark.UnpackPositionalArgs("workflow.reject", args, kwargs, 2, &channelID, &reason); err != nil { - return nil, err - } - - ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil { - return starlark.None, fmt.Errorf("workflow.reject: channel not found or not a workflow") - } - - if ws.CurrentStage <= 0 { - return starlark.None, fmt.Errorf("workflow.reject: already at stage 0") - } - - prevStage := ws.CurrentStage - 1 - if err := stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage); err != nil { - return starlark.None, fmt.Errorf("workflow.reject: %w", err) - } - return starlark.String("rejected"), nil - } -} - // workflowRoute routes the workflow to a named stage (v0.35.0). // Starlark: workflow.route(channel_id, target_stage, reason) -func workflowRoute(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { - return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var channelID, targetStage, reason string - if err := starlark.UnpackPositionalArgs("workflow.route", args, kwargs, 3, &channelID, &targetStage, &reason); err != nil { - return nil, err - } - - ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID) - if err != nil || ws == nil || ws.WorkflowID == nil { - return starlark.None, fmt.Errorf("workflow.route: channel not found or not a workflow") - } - if ws.Status != "active" { - return starlark.None, fmt.Errorf("workflow.route: workflow is %s", ws.Status) - } - - stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID) - if err != nil { - return starlark.None, fmt.Errorf("workflow.route: %w", err) - } - - targetOrdinal, err := workflow.ResolveStageByName(stages, targetStage) - if err != nil { - return starlark.None, fmt.Errorf("workflow.route: %w", err) - } - - if targetOrdinal >= len(stages) { - if err := stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, ws.StageData); err != nil { - return starlark.None, fmt.Errorf("workflow.route: %w", err) - } - return starlark.String("completed"), nil - } - - if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, ws.StageData); err != nil { - return starlark.None, fmt.Errorf("workflow.route: %w", err) - } - return starlark.String("routed"), nil - } -} diff --git a/server/scheduler/executor.go b/server/scheduler/executor.go deleted file mode 100644 index d25100d..0000000 --- a/server/scheduler/executor.go +++ /dev/null @@ -1,531 +0,0 @@ -// Package scheduler — executor.go -// -// v0.27.2: Headless task execution via coreToolLoop. -// v0.28.0: Action task type (no LLM), trigger payload passthrough, -// webhook payload shape fix (D1). -// -// The Executor bridges the task scheduler with the completion pipeline. -// It resolves providers, builds tool definitions, runs the core tool loop -// with budget enforcement, persists results, and sends notifications. -package scheduler - -import ( - "context" - "encoding/json" - "log" - "time" - - capspkg "switchboard-core/capabilities" - "switchboard-core/crypto" - "switchboard-core/events" - "switchboard-core/handlers" - "switchboard-core/models" - "switchboard-core/notifications" - "switchboard-core/providers" - "switchboard-core/sandbox" - "switchboard-core/store" - "switchboard-core/taskutil" - "switchboard-core/tools" - "switchboard-core/webhook" -) - -// Executor runs task completions headlessly (no HTTP client). -type Executor struct { - stores store.Stores - vault *crypto.KeyResolver - hub *events.Hub - health handlers.HealthRecorder - runner *sandbox.Runner // v0.29.0: Starlark task execution -} - -// NewExecutor creates a task executor. All fields are optional except stores. -func NewExecutor(stores store.Stores, vault *crypto.KeyResolver, hub *events.Hub, health handlers.HealthRecorder) *Executor { - return &Executor{ - stores: stores, - vault: vault, - hub: hub, - health: health, - } -} - -// SetRunner attaches the Starlark sandbox runner for starlark task execution. -func (e *Executor) SetRunner(r *sandbox.Runner) { - e.runner = r -} - -// Execute runs a single task to completion. -// Called from the scheduler's execute() goroutine. -func (e *Executor) Execute(ctx context.Context, task models.Task, run *models.TaskRun, channelID string) { - startTime := time.Now() - - // v0.28.6: System tasks run a built-in Go function. No LLM, no provider, no channel. - if task.TaskType == "system" { - e.executeSystem(ctx, task, run, startTime) - return - } - - // v0.28.0: Action tasks skip the LLM pipeline entirely. - if task.TaskType == "action" { - e.executeAction(ctx, task, run, channelID, startTime) - return - } - - // v0.29.0: Starlark tasks run a sandboxed extension script. - if task.TaskType == "starlark" { - e.executeStarlark(ctx, task, run, channelID, startTime) - return - } - - // ── 1. Resolve provider ──────────────────── - providerConfigID := "" - if task.ProviderConfigID != nil { - providerConfigID = *task.ProviderConfigID - } - res, err := handlers.ResolveProviderConfig(e.stores, e.vault, task.OwnerID, channelID, providerConfigID, task.ModelID) - if err != nil { - e.failRun(ctx, task, run, "provider resolution failed: "+err.Error()) - return - } - - // v0.27.4: Enforce personal_require_byok — personal tasks must use BYOK provider - if task.Scope == "personal" && res.ProviderScope != "personal" { - cfg := taskutil.LoadTaskConfig(ctx, e.stores.GlobalConfig) - if cfg.PersonalRequireBYOK { - e.failRun(ctx, task, run, "personal tasks require a BYOK provider — add an API key in Settings → Providers") - return - } - } - - provider, err := providers.Get(res.ProviderID) - if err != nil { - e.failRun(ctx, task, run, "provider unavailable: "+err.Error()) - return - } - - // ── 2. Build messages ────────────────────── - messages := make([]providers.Message, 0, 3) - - // System prompt: task-level > persona > empty - systemPrompt := task.SystemPrompt - if systemPrompt == "" && task.PersonaID != nil && e.stores.Personas != nil { - if persona, err := e.stores.Personas.GetByID(ctx, *task.PersonaID); err == nil { - systemPrompt = persona.SystemPrompt - } - } - if systemPrompt != "" { - messages = append(messages, providers.Message{ - Role: "system", - Content: systemPrompt, - }) - } - - // User prompt — with optional trigger payload prepended - userContent := task.UserPrompt - if run.TriggerPayload != "" && userContent != "" { - userContent = "[Webhook trigger data]\n```json\n" + run.TriggerPayload + "\n```\n\n[Task instructions]\n" + userContent - } else if run.TriggerPayload != "" { - userContent = run.TriggerPayload - } - if userContent != "" { - messages = append(messages, providers.Message{ - Role: "user", - Content: userContent, - }) - } - - // ── 3. Build tool definitions ────────────── - caps := capspkg.InferCapabilities(res.Model) - caps.MaxOutputTokens = capspkg.ResolveMaxOutput(res.Model, caps) - - var toolDefs []providers.ToolDef - if caps.ToolCalling { - personaID := "" - if task.PersonaID != nil { - personaID = *task.PersonaID - } - tctx := tools.ToolContext{ - ChannelType: "service", - PersonaID: personaID, - } - // No browser tools for headless execution - toolDefs = handlers.BuildToolDefs(ctx, e.stores, task.OwnerID, false, nil, tctx, personaID) - - // Apply task-level tool grants - if len(task.ToolGrants) > 0 { - var grants []string - if json.Unmarshal(task.ToolGrants, &grants) == nil && len(grants) > 0 { - toolDefs = handlers.FilterToolDefsByGrants(toolDefs, grants) - } - } - } - - // ── 4. Build completion request ──────────── - req := providers.CompletionRequest{ - Model: res.Model, - Messages: messages, - Tools: toolDefs, - } - if task.MaxTokens > 0 { - req.MaxTokens = task.MaxTokens - } else if caps.MaxOutputTokens > 0 { - req.MaxTokens = caps.MaxOutputTokens - } - - // Apply provider-specific request hooks - if hooks := providers.GetHooks(res.ProviderID); hooks != nil { - hooks.PreRequest(res.Config, &req) - } - - // ── 5. Execute via core tool loop ────────── - personaID := "" - if task.PersonaID != nil { - personaID = *task.PersonaID - } - - extTools := handlers.BuildExtToolMap(ctx, e.stores, task.OwnerID) - sink := handlers.NewHeadlessSink(task.ID) - result := handlers.CoreToolLoop(ctx, handlers.LoopConfig{ - Provider: provider, - Cfg: res.Config, - Req: &req, - Model: res.Model, - ProviderType: res.ProviderID, - ExecCtx: tools.ExecutionContext{ - UserID: task.OwnerID, - ChannelID: channelID, - PersonaID: personaID, - }, - Hub: e.hub, - Health: e.health, - ConfigID: res.ConfigID, - Budget: handlers.LoopBudget{ - MaxRounds: 0, // use default - MaxToolCalls: task.MaxToolCalls, - MaxTokens: task.MaxTokens, - }, - Streaming: false, // headless — use ChatCompletion - Runner: e.runner, - ExtTools: extTools, - }, sink) - - // ── 6. Persist output based on output_mode ── - wallClock := int(time.Since(startTime).Seconds()) - - if result.Content != "" { - switch task.OutputMode { - case "note": - // v0.27.4: Save output as a note - if e.stores.Notes != nil { - noteTitle := task.Name + " — " + time.Now().Format("2006-01-02 15:04") - _ = e.stores.Notes.Create(ctx, &models.Note{ - UserID: task.OwnerID, - Title: noteTitle, - Content: result.Content, - SourceChannelID: &channelID, - TeamID: task.TeamID, - Tags: []string{"task-output"}, - }) - } - case "webhook": - // Webhook delivery handled in step 10 below - default: // "channel" - if e.stores.Messages != nil { - _ = e.stores.Messages.Create(ctx, &models.Message{ - ChannelID: channelID, - Role: "assistant", - Content: result.Content, - Model: res.Model, - }) - } - } - } - - // ── 7. Determine terminal status ─────────── - status := "completed" - errMsg := "" - - if result.Error != nil { - status = "failed" - errMsg = result.Error.Error() - } else if result.BudgetExceeded != "" { - status = "budget_exceeded" - errMsg = "budget exceeded: " + result.BudgetExceeded - } - - tokensUsed := result.InputTokens + result.OutputTokens - - // ── 8. Update run record ─────────────────── - _ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, - tokensUsed, result.ToolCallCount, wallClock, errMsg) - _ = e.stores.Tasks.IncrementRunCount(ctx, task.ID) - - log.Printf("[executor] Task %s (%s) → %s (tokens=%d, tools=%d, wall=%ds)", - task.ID, task.Name, status, tokensUsed, result.ToolCallCount, wallClock) - - // ── 9. Owner notification ────────────────── - e.notifyOwner(ctx, task, status, errMsg) - - // ── 10. Webhook delivery (v0.27.3) ───────── - if task.WebhookURL != "" { - go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ - TaskID: task.ID, - RunID: run.ID, - TaskName: task.Name, - ChannelID: channelID, - Status: status, - CompletedAt: time.Now().UTC(), - Output: result.Content, - TokensUsed: tokensUsed, - Error: errMsg, - }) - } -} - -// executeStarlark runs a sandboxed Starlark extension script. -// The task's system_function field holds the package ID. -// The script's on_run(ctx) entry point is called with task context. -func (e *Executor) executeStarlark(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) { - if e.runner == nil { - e.failRun(ctx, task, run, "starlark runner not configured") - return - } - - packageID := task.SystemFunction - if packageID == "" { - e.failRun(ctx, task, run, "starlark task missing package_id (system_function field)") - return - } - - // Load the package - pkg, err := e.stores.Packages.Get(ctx, packageID) - if err != nil || pkg == nil { - e.failRun(ctx, task, run, "package not found: "+packageID) - return - } - - // Run the script and call on_run entry point - // Build a context dict with task info - rc := &sandbox.RunContext{UserID: task.OwnerID, ChannelID: channelID} - val, output, err := e.runner.CallEntryPoint(ctx, pkg, "on_run", nil, nil, rc) - - wallClock := int(time.Since(startTime).Seconds()) - status := "completed" - errMsg := "" - result := "" - - if err != nil { - status = "failed" - errMsg = err.Error() - } else if val != nil { - result = val.String() - } - - // Append print output to result - if output != "" { - if result != "" { - result = result + "\n---\n" + output - } else { - result = output - } - } - - // Persist to channel if output_mode == "channel" - if status == "completed" && task.OutputMode == "channel" && e.stores.Messages != nil && result != "" { - _ = e.stores.Messages.Create(ctx, &models.Message{ - ChannelID: channelID, - Role: "system", - Content: "Starlark task output:\n```\n" + result + "\n```", - }) - } - - _ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg) - _ = e.stores.Tasks.IncrementRunCount(ctx, task.ID) - - log.Printf("[executor] Starlark task %s (%s → %s) → %s (wall=%ds)", task.ID, task.Name, packageID, status, wallClock) - - e.notifyOwner(ctx, task, status, errMsg) - - if task.WebhookURL != "" { - go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ - TaskID: task.ID, - RunID: run.ID, - TaskName: task.Name, - ChannelID: channelID, - Status: status, - CompletedAt: time.Now().UTC(), - Output: result, - Error: errMsg, - }) - } -} - -// executeSystem runs a built-in Go function from the system registry. -// No LLM, no provider resolution, no channel needed. -func (e *Executor) executeSystem(ctx context.Context, task models.Task, run *models.TaskRun, startTime time.Time) { - fn, ok := taskutil.GetSystemFunc(task.SystemFunction) - if !ok { - e.failRun(ctx, task, run, "unknown system function: "+task.SystemFunction) - return - } - - result, err := fn(ctx, e.stores) - - wallClock := int(time.Since(startTime).Seconds()) - status := "completed" - errMsg := "" - - if err != nil { - status = "failed" - errMsg = err.Error() - } - - // Store result as the run's output (tokens=0, tools=0 for system tasks) - _ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, errMsg) - _ = e.stores.Tasks.IncrementRunCount(ctx, task.ID) - - log.Printf("[executor] System task %s (%s → %s) → %s (wall=%ds, result=%s)", - task.ID, task.Name, task.SystemFunction, status, wallClock, truncate(result, 200)) - - e.notifyOwner(ctx, task, status, errMsg) - - // Outbound webhook with result - if task.WebhookURL != "" { - go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ - TaskID: task.ID, - RunID: run.ID, - TaskName: task.Name, - Status: status, - CompletedAt: time.Now().UTC(), - Output: result, - Error: errMsg, - }) - } -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "…" -} - -// executeAction handles non-LLM action tasks. -// Skips provider resolution and completion entirely. -func (e *Executor) executeAction(ctx context.Context, task models.Task, run *models.TaskRun, channelID string, startTime time.Time) { - wallClock := int(time.Since(startTime).Seconds()) - status := "completed" - - // v0.28.0: Action tasks relay trigger payload to outbound webhook. - // No LLM execution — the value is in the automation plumbing. - output := run.TriggerPayload - if output == "" { - output = "{}" - } - - // Persist to channel if output_mode == "channel" - if task.OutputMode == "channel" && e.stores.Messages != nil { - _ = e.stores.Messages.Create(ctx, &models.Message{ - ChannelID: channelID, - Role: "system", - Content: "Action task executed. Trigger payload:\n```json\n" + output + "\n```", - }) - } - - _ = e.stores.Tasks.UpdateRun(ctx, run.ID, status, 0, 0, wallClock, "") - _ = e.stores.Tasks.IncrementRunCount(ctx, task.ID) - - log.Printf("[executor] Action task %s (%s) → %s (wall=%ds)", task.ID, task.Name, status, wallClock) - - e.notifyOwner(ctx, task, status, "") - - // Fire outbound webhook with trigger payload - if task.WebhookURL != "" { - go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ - TaskID: task.ID, - RunID: run.ID, - TaskName: task.Name, - ChannelID: channelID, - Status: status, - CompletedAt: time.Now().UTC(), - Output: output, - }) - } -} - -// failRun marks a run as failed before completion was attempted. -func (e *Executor) failRun(ctx context.Context, task models.Task, run *models.TaskRun, errMsg string) { - log.Printf("[executor] Task %s (%s) pre-execution failure: %s", task.ID, task.Name, errMsg) - _ = e.stores.Tasks.UpdateRun(ctx, run.ID, "failed", 0, 0, 0, errMsg) - e.notifyOwner(ctx, task, "failed", errMsg) - if task.WebhookURL != "" { - go webhook.Deliver(task.WebhookURL, task.WebhookSecret, webhook.Payload{ - TaskID: task.ID, - RunID: run.ID, - TaskName: task.Name, - Status: "failed", - CompletedAt: time.Now().UTC(), - Error: errMsg, - }) - } -} - -// notifyOwner sends a notification based on task outcome and preferences. -func (e *Executor) notifyOwner(ctx context.Context, task models.Task, status, errMsg string) { - notifSvc := notifications.Default() - if notifSvc == nil { - return - } - - switch status { - case "completed": - if !task.NotifyOnComplete { - return - } - _ = notifSvc.Notify(ctx, &models.Notification{ - UserID: task.OwnerID, - Type: "task.completed", - Title: "Task completed: " + task.Name, - Body: "Scheduled task finished successfully.", - ResourceType: "channel", - ResourceID: stringVal(task.OutputChannelID), - }) - - case "failed": - if !task.NotifyOnFailure { - return - } - body := "Scheduled task failed." - if errMsg != "" { - body = errMsg - if len(body) > 200 { - body = body[:200] + "…" - } - } - _ = notifSvc.Notify(ctx, &models.Notification{ - UserID: task.OwnerID, - Type: "task.failed", - Title: "Task failed: " + task.Name, - Body: body, - ResourceType: "channel", - ResourceID: stringVal(task.OutputChannelID), - }) - - case "budget_exceeded": - // Always notify on budget breach regardless of preference - _ = notifSvc.Notify(ctx, &models.Notification{ - UserID: task.OwnerID, - Type: "task.budget_exceeded", - Title: "Task budget exceeded: " + task.Name, - Body: errMsg, - ResourceType: "channel", - ResourceID: stringVal(task.OutputChannelID), - }) - } -} - -func stringVal(s *string) string { - if s == nil { - return "" - } - return *s -} diff --git a/server/scheduler/scheduler.go b/server/scheduler/scheduler.go deleted file mode 100644 index 252ef6e..0000000 --- a/server/scheduler/scheduler.go +++ /dev/null @@ -1,251 +0,0 @@ -// Package scheduler runs the task polling loop. It checks for due tasks -// every 30 seconds, creates service channels, and dispatches execution. -// -// v0.27.1: Foundation — scheduler loop + service channel creation. -// v0.27.2: Adds global config checks (enabled, max_concurrent), full cron -// parsing via robfig/cron/v3, and completion invocation via executor. -// v0.28.0: Adopt queued runs (webhook triggers), action tasks, C3/C4 audit fixes. -// v0.32.0: SKIP LOCKED atomic claim — every replica polls, PG serializes. -// Replaces ListDue with ClaimDueTask, CreateRunExclusive for -// belt-and-suspenders uniqueness. Startup jitter staggers replicas. -package scheduler - -import ( - "context" - "database/sql" - "errors" - "log" - "math/rand" - "time" - - "switchboard-core/models" - "switchboard-core/store" - "switchboard-core/taskutil" -) - -// Scheduler polls for due tasks and dispatches execution. -type Scheduler struct { - stores store.Stores - executor *Executor - interval time.Duration - stop chan struct{} - running bool -} - -// New creates a task scheduler. Call Run() in a goroutine to start. -// The executor is optional — if nil, tasks create channels and persist -// prompts but do not invoke completions (v0.27.1 behavior). -func New(stores store.Stores, executor *Executor) *Scheduler { - return &Scheduler{ - stores: stores, - executor: executor, - interval: 30 * time.Second, - stop: make(chan struct{}), - } -} - -// Run starts the scheduler loop. Blocks until Stop() is called. -func (s *Scheduler) Run() { - if s.stores.Tasks == nil { - log.Println("[scheduler] TaskStore not available — scheduler disabled") - return - } - s.running = true - - // v0.32.0: Startup jitter — stagger replica polling to reduce lock contention. - // Not strictly necessary with SKIP LOCKED but reduces unnecessary work. - jitter := time.Duration(rand.Intn(15000)) * time.Millisecond - time.Sleep(jitter) - log.Printf("[scheduler] Started (jitter=%s, interval=%s)", jitter, s.interval) - - ticker := time.NewTicker(s.interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - s.poll() - case <-s.stop: - log.Println("[scheduler] Stopped") - return - } - } -} - -// Stop signals the scheduler to exit. -func (s *Scheduler) Stop() { - if s.running { - close(s.stop) - s.running = false - } -} - -// poll claims due tasks one at a time and dispatches them. -// v0.32.0: Each call to ClaimDueTask atomically locks and claims one task -// via FOR UPDATE SKIP LOCKED (PG). Multiple replicas poll concurrently; -// PG ensures each task is handed to exactly one replica. -func (s *Scheduler) poll() { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - // v0.32.0: Piggyback cleanup of expired tickets and stale rate limit - // counters on the scheduler tick. Cheap no-ops when tables are empty. - if s.stores.Tickets != nil { - if n, err := s.stores.Tickets.Reap(ctx); err == nil && n > 0 { - log.Printf("[scheduler] Reaped %d expired WS tickets", n) - } - } - if s.stores.RateLimits != nil { - _ = s.stores.RateLimits.Cleanup(ctx, 5*time.Minute) - } - - // Check global config — tasks may be disabled at runtime. - cfg := taskutil.LoadTaskConfig(ctx, s.stores.GlobalConfig) - if !cfg.Enabled { - return - } - - // Claim tasks one at a time until none remain or max_concurrent reached. - claimed := 0 - for claimed < cfg.MaxConcurrent { - task, err := s.stores.Tasks.ClaimDueTask(ctx) - if err != nil { - // sql.ErrNoRows = nothing due; any other error = log and stop. - if !errors.Is(err, sql.ErrNoRows) { - log.Printf("[scheduler] ClaimDueTask error: %v", err) - } - break - } - claimed++ - go s.execute(task) - } -} - -// execute runs a single task. The task has already been claimed -// (next_run_at set to NULL by ClaimDueTask). -func (s *Scheduler) execute(task *models.Task) { - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(task.MaxWallClock)*time.Second) - defer cancel() - - // v0.28.0: Check for queued run (from webhook trigger) — adopt it instead - // of creating a new one so the trigger_payload is preserved. - run, _ := s.stores.Tasks.GetQueuedRun(ctx, task.ID) - if run != nil { - // Adopt: transition queued → running - _ = s.stores.Tasks.TransitionRunStatus(ctx, run.ID, "running") - run.Status = "running" - log.Printf("[scheduler] Adopting queued run %s for task %s (%s)", run.ID, task.ID, task.Name) - } else { - // v0.32.0: Conditional insert — prevents double-execution if another - // replica somehow also processes this task (belt-and-suspenders). - var err error - run, err = s.stores.Tasks.CreateRunExclusive(ctx, task.ID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - log.Printf("[scheduler] Skipping task %s (%s) — run already exists", task.ID, task.Name) - } else { - log.Printf("[scheduler] Failed to create run for task %s: %v", task.ID, err) - } - s.advanceNextRun(ctx, *task) - return - } - } - - log.Printf("[scheduler] Executing task %s (%s) type=%s", task.ID, task.Name, task.TaskType) - - // Mark execution start - _ = s.stores.Tasks.SetLastRun(ctx, task.ID) - - // Create or reuse service channel - channelID, err := s.ensureServiceChannel(ctx, *task) - if err != nil { - log.Printf("[scheduler] Failed to create service channel for task %s: %v", task.ID, err) - _ = s.stores.Tasks.UpdateRun(ctx, run.ID, "failed", 0, 0, 0, "channel creation failed: "+err.Error()) - s.advanceNextRun(ctx, *task) - return - } - - // Persist the user prompt as a message (prompt tasks only) - if task.TaskType == "prompt" && task.UserPrompt != "" && s.stores.Messages != nil { - content := task.UserPrompt - // v0.28.0: Prepend trigger payload as context if present - if run.TriggerPayload != "" { - content = "[Webhook trigger data]\n```json\n" + run.TriggerPayload + "\n```\n\n[Task instructions]\n" + task.UserPrompt - } - _ = s.stores.Messages.Create(ctx, &models.Message{ - ChannelID: channelID, - Role: "user", - Content: content, - }) - } - - // Invoke completion via executor - if s.executor != nil { - s.executor.Execute(ctx, *task, run, channelID) - } else { - // No executor — mark completed (channel + prompt persisted) - _ = s.stores.Tasks.UpdateRun(ctx, run.ID, "completed", 0, 0, 0, "") - _ = s.stores.Tasks.IncrementRunCount(ctx, task.ID) - } - - log.Printf("[scheduler] Task %s finished (channel %s)", task.ID, channelID) - - s.advanceNextRun(ctx, *task) -} - -// ensureServiceChannel creates a new service channel or reuses an existing one. -func (s *Scheduler) ensureServiceChannel(ctx context.Context, task models.Task) (string, error) { - // If output_channel_id is set and valid, reuse it - if task.OutputChannelID != nil && *task.OutputChannelID != "" { - return *task.OutputChannelID, nil - } - - // Create a new service channel - ch := &models.Channel{ - UserID: task.OwnerID, - Title: task.Name, - Description: "Task output: " + task.Description, - Type: "service", - TeamID: task.TeamID, - } - if err := s.stores.Channels.Create(ctx, ch); err != nil { - return "", err - } - - // C3 fix: Persist output_channel_id on the task so future runs reuse this channel. - channelID := ch.ID - _ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{ - OutputChannelID: &channelID, - }) - - return channelID, nil -} - -// advanceNextRun computes the next run time and updates the task. -func (s *Scheduler) advanceNextRun(ctx context.Context, task models.Task) { - if task.Schedule == "once" { - // One-shot task — deactivate after execution - isActive := false - _ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{IsActive: &isActive}) - _ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil) - return - } - - if task.Schedule == "webhook" { - // Webhook tasks have no cron schedule — clear next_run_at. - // They only fire when triggered externally. - _ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil) - return - } - - // Full cron parsing via robfig/cron/v3. - next := taskutil.NextRunFromSchedule(task.Schedule, task.Timezone) - if next == nil { - log.Printf("[scheduler] Failed to compute next run for task %s (schedule: %q) — deactivating", task.ID, task.Schedule) - isActive := false - _ = s.stores.Tasks.Update(ctx, task.ID, models.TaskPatch{IsActive: &isActive}) - _ = s.stores.Tasks.SetNextRun(ctx, task.ID, nil) - return - } - _ = s.stores.Tasks.SetNextRun(ctx, task.ID, next) -} diff --git a/server/scheduler/system_builtins.go b/server/scheduler/system_builtins.go deleted file mode 100644 index 1502c8f..0000000 --- a/server/scheduler/system_builtins.go +++ /dev/null @@ -1,99 +0,0 @@ -// Package scheduler — system_builtins.go -// -// v0.28.6: Built-in system functions registered at startup. -// v0.29.0: Raw SQL replaced with store methods. -package scheduler - -import ( - "context" - "fmt" - "log" - "time" - - "switchboard-core/store" - "switchboard-core/taskutil" -) - -// RegisterBuiltins registers all built-in system functions. -func RegisterBuiltins() { - taskutil.RegisterSystemFunc("session_cleanup", - "Remove expired anonymous visitor sessions", - sessionCleanup) - - taskutil.RegisterSystemFunc("staleness_check", - "Mark idle workflow instances as stale", - stalenessCheck) - - taskutil.RegisterSystemFunc("retention_sweep", - "Delete completed workflow instances past retention policy", - retentionSweep) - - taskutil.RegisterSystemFunc("health_prune", - "Prune provider health windows older than 7 days", - healthPrune) -} - -// ── session_cleanup ───────────────────────── - -func sessionCleanup(ctx context.Context, stores store.Stores) (string, error) { - if stores.Sessions == nil { - return "skipped: session store not available", nil - } - cutoff := time.Now().UTC().AddDate(0, 0, -7) - n, err := stores.Sessions.DeleteExpired(ctx, cutoff) - if err != nil { - return "", fmt.Errorf("session cleanup failed: %w", err) - } - msg := fmt.Sprintf("cleaned up %d expired sessions (cutoff: %s)", n, cutoff.Format(time.RFC3339)) - if n > 0 { - log.Printf("[system_task] %s", msg) - } - return msg, nil -} - -// ── staleness_check ───────────────────────── - -func stalenessCheck(ctx context.Context, stores store.Stores) (string, error) { - cutoff := time.Now().UTC().Add(-48 * time.Hour) - n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff) - if err != nil { - return "", fmt.Errorf("staleness sweep failed: %w", err) - } - msg := fmt.Sprintf("marked %d idle workflow instances as stale (cutoff: %s)", n, cutoff.Format(time.RFC3339)) - if n > 0 { - log.Printf("[system_task] %s", msg) - } - return msg, nil -} - -// ── retention_sweep ───────────────────────── - -func retentionSweep(ctx context.Context, stores store.Stores) (string, error) { - n, err := stores.Channels.EnforceWorkflowRetention(ctx) - if err != nil { - return "", fmt.Errorf("retention enforcement failed: %w", err) - } - msg := fmt.Sprintf("deleted %d expired workflow instances (retention policy)", n) - if n > 0 { - log.Printf("[system_task] %s", msg) - } - return msg, nil -} - -// ── health_prune ──────────────────────────── - -func healthPrune(ctx context.Context, stores store.Stores) (string, error) { - if stores.Health == nil { - return "skipped: health store not available", nil - } - cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour) - n, err := stores.Health.Prune(ctx, cutoff) - if err != nil { - return "", fmt.Errorf("health prune failed: %w", err) - } - msg := fmt.Sprintf("pruned %d old health windows (cutoff: %s)", n, cutoff.Format(time.RFC3339)) - if n > 0 { - log.Printf("[system_task] %s", msg) - } - return msg, nil -} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 6edeee0..52004d9 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -33,14 +33,12 @@ type Stores struct { ResourceGrants ResourceGrantStore Notifications NotificationStore NotifPrefs NotificationPreferenceStore - Sessions SessionStore Presence PresenceStore // v0.29.0: User online/offline status Health HealthStore // v0.29.0: Provider health window management Connections ConnectionStore // v0.38.1: Extension connection credentials Dependencies DependencyStore // v0.38.2: Library package dependency graph Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions) Workflows WorkflowStore // v0.26.1: Workflow definitions + stages - Tasks TaskStore // v0.27.1: Task scheduling + run history ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod) @@ -312,19 +310,6 @@ type NotificationPreferenceStore interface { // SESSION STORE (v0.24.3) // ========================================= -type SessionStore interface { - Create(ctx context.Context, s *models.SessionParticipant) error - GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) - GetByID(ctx context.Context, id string) (*models.SessionParticipant, error) - ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error) - CountForChannel(ctx context.Context, channelID string) (int, error) - Delete(ctx context.Context, id string) error - - // DeleteExpired removes sessions older than the given time that have - // no associated messages. Returns the number of deleted rows. - // Used by the background session cleanup job (v0.26.0). - DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) -} // ========================================= // HEALTH STORE (v0.29.0) diff --git a/server/store/postgres/health.go b/server/store/postgres/health.go index b33653a..a74d0aa 100644 --- a/server/store/postgres/health.go +++ b/server/store/postgres/health.go @@ -2,191 +2,42 @@ package postgres import ( "context" - "database/sql" "time" - "switchboard-core/models" + "switchboard-core/database" ) -// HealthStore implements health.Store for Postgres. -type HealthStore struct { - db *sql.DB -} +// ── HealthStore ───────────────────────────── -func NewHealthStore(db *sql.DB) *HealthStore { - return &HealthStore{db: db} -} +type HealthStore struct{} -func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error { - _, err := s.db.ExecContext(ctx, ` - INSERT INTO provider_health (provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now()) - ON CONFLICT (provider_config_id, window_start) DO UPDATE SET - request_count = provider_health.request_count + EXCLUDED.request_count, - error_count = provider_health.error_count + EXCLUDED.error_count, - timeout_count = provider_health.timeout_count + EXCLUDED.timeout_count, - rate_limit_count = provider_health.rate_limit_count + EXCLUDED.rate_limit_count, - total_latency_ms = provider_health.total_latency_ms + EXCLUDED.total_latency_ms, - max_latency_ms = GREATEST(provider_health.max_latency_ms, EXCLUDED.max_latency_ms), - last_error = COALESCE(EXCLUDED.last_error, provider_health.last_error), - last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at), - updated_at = now() - `, w.ProviderConfigID, w.WindowStart, - w.RequestCount, w.ErrorCount, w.TimeoutCount, w.RateLimitCount, - w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt, - ) - return err -} - -func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) { - windowStart := time.Now().UTC().Truncate(time.Hour) - var w models.ProviderHealthWindow - err := s.db.QueryRowContext(ctx, ` - SELECT id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at - FROM provider_health - WHERE provider_config_id = $1 AND window_start = $2 - `, providerConfigID, windowStart).Scan( - &w.ID, &w.ProviderConfigID, &w.WindowStart, - &w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount, - &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt, - ) - if err == sql.ErrNoRows { - return nil, nil - } - return &w, err -} - -func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at - FROM provider_health - WHERE provider_config_id = $1 - ORDER BY window_start DESC - LIMIT $2 - `, providerConfigID, hours) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProviderHealthWindow - for rows.Next() { - var w models.ProviderHealthWindow - if err := rows.Scan( - &w.ID, &w.ProviderConfigID, &w.WindowStart, - &w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount, - &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt, - ); err != nil { - return nil, err - } - result = append(result, w) - } - return result, rows.Err() -} - -func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) { - windowStart := time.Now().UTC().Truncate(time.Hour) - rows, err := s.db.QueryContext(ctx, ` - SELECT id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at - FROM provider_health - WHERE window_start = $1 - ORDER BY provider_config_id - `, windowStart) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.ProviderHealthWindow - for rows.Next() { - var w models.ProviderHealthWindow - if err := rows.Scan( - &w.ID, &w.ProviderConfigID, &w.WindowStart, - &w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount, - &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt, - ); err != nil { - return nil, err - } - result = append(result, w) - } - return result, rows.Err() -} +func NewHealthStore() *HealthStore { return &HealthStore{} } +// Prune deletes stale kernel data older than the given time. +// Cleans: expired ws_tickets, old rate_limit_counters, stale presence. func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) { - result, err := s.db.ExecContext(ctx, ` - DELETE FROM provider_health WHERE window_start < $1 - `, before) - if err != nil { - return 0, err + var total int64 + + res, _ := database.DB.ExecContext(ctx, + `DELETE FROM ws_tickets WHERE expires_at < $1`, before) + if res != nil { + n, _ := res.RowsAffected() + total += n } - // Also prune tool health - s.db.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < $1`, before) - return result.RowsAffected() -} -// ── Tool Health (v0.22.4) ──────────────────── - -func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error { - _, err := s.db.ExecContext(ctx, ` - INSERT INTO tool_health (tool_name, window_start, - request_count, error_count, total_latency_ms, max_latency_ms, - last_error, last_error_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) - ON CONFLICT (tool_name, window_start) DO UPDATE SET - request_count = tool_health.request_count + EXCLUDED.request_count, - error_count = tool_health.error_count + EXCLUDED.error_count, - total_latency_ms = tool_health.total_latency_ms + EXCLUDED.total_latency_ms, - max_latency_ms = GREATEST(tool_health.max_latency_ms, EXCLUDED.max_latency_ms), - last_error = COALESCE(EXCLUDED.last_error, tool_health.last_error), - last_error_at = COALESCE(EXCLUDED.last_error_at, tool_health.last_error_at), - updated_at = now() - `, w.ToolName, w.WindowStart, - w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs, - w.LastError, w.LastErrorAt, - ) - return err -} - -func (s *HealthStore) ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) { - windowStart := time.Now().UTC().Truncate(time.Hour) - rows, err := s.db.QueryContext(ctx, ` - SELECT id, tool_name, window_start, - request_count, error_count, total_latency_ms, max_latency_ms, - last_error, last_error_at - FROM tool_health - WHERE window_start = $1 - ORDER BY tool_name - `, windowStart) - if err != nil { - return nil, err + res, _ = database.DB.ExecContext(ctx, + `DELETE FROM rate_limit_counters WHERE window_start < $1`, before) + if res != nil { + n, _ := res.RowsAffected() + total += n } - defer rows.Close() - var result []models.ToolHealthWindow - for rows.Next() { - var w models.ToolHealthWindow - if err := rows.Scan( - &w.ID, &w.ToolName, &w.WindowStart, - &w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs, - &w.LastError, &w.LastErrorAt, - ); err != nil { - return nil, err - } - result = append(result, w) + res, _ = database.DB.ExecContext(ctx, + `DELETE FROM user_presence WHERE last_seen < $1 AND status = 'offline'`, before) + if res != nil { + n, _ := res.RowsAffected() + total += n } - return result, rows.Err() -} -// DeactivateProvider marks a provider config as inactive (for auto-disable). -func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error { - _, err := s.db.ExecContext(ctx, `UPDATE provider_configs SET is_active = false WHERE id = $1`, configID) - return err + return total, nil } diff --git a/server/store/postgres/sessions.go b/server/store/postgres/sessions.go deleted file mode 100644 index f418369..0000000 --- a/server/store/postgres/sessions.go +++ /dev/null @@ -1,112 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "time" - - "switchboard-core/models" -) - -// ── SessionStore ─────────────────────────── - -type SessionStore struct{} - -func NewSessionStore() *SessionStore { return &SessionStore{} } - -func (s *SessionStore) Create(ctx context.Context, sp *models.SessionParticipant) error { - return DB.QueryRowContext(ctx, ` - INSERT INTO session_participants (session_token, channel_id, display_name, fingerprint) - VALUES ($1, $2, $3, $4) - RETURNING id, created_at`, - sp.SessionToken, sp.ChannelID, sp.DisplayName, nullText(sp.Fingerprint), - ).Scan(&sp.ID, &sp.CreatedAt) -} - -func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) { - sp := &models.SessionParticipant{} - err := DB.QueryRowContext(ctx, ` - SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at - FROM session_participants WHERE session_token = $1`, token, - ).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt) - if err != nil { - return nil, err - } - return sp, nil -} - -func (s *SessionStore) GetByID(ctx context.Context, id string) (*models.SessionParticipant, error) { - sp := &models.SessionParticipant{} - err := DB.QueryRowContext(ctx, ` - SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at - FROM session_participants WHERE id = $1`, id, - ).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt) - if err != nil { - return nil, err - } - return sp, nil -} - -func (s *SessionStore) ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at - FROM session_participants WHERE channel_id = $1 - ORDER BY created_at ASC`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.SessionParticipant - for rows.Next() { - var sp models.SessionParticipant - if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt); err != nil { - return nil, err - } - result = append(result, sp) - } - return result, rows.Err() -} - -func (s *SessionStore) CountForChannel(ctx context.Context, channelID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM session_participants WHERE channel_id = $1`, channelID, - ).Scan(&count) - return count, err -} - -func (s *SessionStore) Delete(ctx context.Context, id string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM session_participants WHERE id = $1`, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} - -// DeleteExpired removes sessions created before olderThan whose channel -// has no messages. Returns the count of deleted rows. -func (s *SessionStore) DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) { - res, err := DB.ExecContext(ctx, ` - DELETE FROM session_participants sp - WHERE sp.created_at < $1 - AND NOT EXISTS ( - SELECT 1 FROM messages m WHERE m.channel_id = sp.channel_id - )`, olderThan) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -// nullText returns nil for empty strings. -func nullText(s string) interface{} { - if s == "" { - return nil - } - return s -} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index 5e2142b..95a0f53 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -19,14 +19,12 @@ func NewStores(db *sql.DB) store.Stores { ResourceGrants: NewResourceGrantStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), - Sessions: NewSessionStore(), Presence: NewPresenceStore(), Health: NewHealthStore(db), Connections: NewConnectionStore(), Dependencies: NewDependencyStore(), Packages: NewPackageStore(), Workflows: NewWorkflowStore(), - Tasks: NewTaskStore(), ExtPermissions: NewExtensionPermissionStore(db), ExtData: NewExtDataStore(db), Tickets: NewTicketStore(), diff --git a/server/store/postgres/tasks.go b/server/store/postgres/tasks.go deleted file mode 100644 index 15b981c..0000000 --- a/server/store/postgres/tasks.go +++ /dev/null @@ -1,325 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - - "switchboard-core/models" -) - -type TaskStore struct{} - -func NewTaskStore() *TaskStore { return &TaskStore{} } - -// ── Full column list (single source of truth) ────────────────── -// Used by GetByID, GetByTriggerToken, and list(). Every query must -// SELECT and Scan the same columns in the same order to prevent drift. -// -// scanTask is the ONLY function that maps columns → struct fields. -// If a column is added or reordered, update taskColumns + scanTask. - -// taskColumns is the canonical SELECT list for tasks. -// COALESCE wraps nullable columns that scan into non-pointer Go types -// (string, json.RawMessage). database/sql returns "unsupported Scan, -// storing driver.Value type " for these without COALESCE. -const taskColumns = `id, owner_id, team_id, name, description, scope, - task_type, COALESCE(system_function, '') AS system_function, - persona_id, model_id, system_prompt, user_prompt, - workflow_id, COALESCE(tool_grants, '[]'::jsonb) AS tool_grants, - schedule, timezone, is_active, - COALESCE(trigger_token, '') AS trigger_token, - max_tokens, max_tool_calls, max_wall_clock, output_mode, - output_channel_id, - COALESCE(webhook_url, '') AS webhook_url, - COALESCE(webhook_secret, '') AS webhook_secret, - provider_config_id, - notify_on_complete, notify_on_failure, - last_run_at, next_run_at, run_count, created_at, updated_at` - -// scanTask maps one row from taskColumns into a Task struct. -// Single source of truth — used by GetByID, GetByTriggerToken, and list(). -func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error { - return scanner.Scan( - &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, - &t.TaskType, &t.SystemFunction, - &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, - &t.WorkflowID, &t.ToolGrants, &t.Schedule, &t.Timezone, &t.IsActive, - &t.TriggerToken, - &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, - &t.OutputChannelID, &t.WebhookURL, &t.WebhookSecret, &t.ProviderConfigID, - &t.NotifyOnComplete, &t.NotifyOnFailure, - &t.LastRunAt, &t.NextRunAt, &t.RunCount, &t.CreatedAt, &t.UpdatedAt, - ) -} - -// ── CRUD ─────────────────────────────────────── - -func (s *TaskStore) Create(ctx context.Context, t *models.Task) error { - toolGrants := jsonOrNull(t.ToolGrants) - return DB.QueryRowContext(ctx, ` - INSERT INTO tasks (owner_id, team_id, name, description, scope, - task_type, system_function, - persona_id, model_id, system_prompt, user_prompt, - workflow_id, tool_grants, schedule, timezone, is_active, - trigger_token, - max_tokens, max_tool_calls, max_wall_clock, output_mode, - output_channel_id, webhook_url, webhook_secret, provider_config_id, - notify_on_complete, notify_on_failure, next_run_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28) - RETURNING id, created_at, updated_at`, - t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, - t.TaskType, t.SystemFunction, - t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, - t.WorkflowID, toolGrants, t.Schedule, t.Timezone, t.IsActive, - nilIfEmpty(t.TriggerToken), - t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, - t.OutputChannelID, t.WebhookURL, t.WebhookSecret, t.ProviderConfigID, - t.NotifyOnComplete, t.NotifyOnFailure, t.NextRunAt, - ).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt) -} - -func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) { - t := &models.Task{} - row := DB.QueryRowContext(ctx, - `SELECT `+taskColumns+` FROM tasks WHERE id = $1`, id, - ) - if err := scanTask(row, t); err != nil { - return nil, err - } - return t, nil -} - -func (s *TaskStore) GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) { - t := &models.Task{} - row := DB.QueryRowContext(ctx, - `SELECT `+taskColumns+` FROM tasks WHERE trigger_token = $1`, token, - ) - if err := scanTask(row, t); err != nil { - return nil, err - } - return t, nil -} - -func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) error { - // Dynamic PATCH: only update non-nil fields - q := "UPDATE tasks SET updated_at = NOW()" - args := []interface{}{} - i := 1 - if p.Name != nil { q += comma(i, "name"); args = append(args, *p.Name); i++ } - if p.Description != nil { q += comma(i, "description"); args = append(args, *p.Description); i++ } - if p.PersonaID != nil { q += comma(i, "persona_id"); args = append(args, *p.PersonaID); i++ } - if p.ModelID != nil { q += comma(i, "model_id"); args = append(args, *p.ModelID); i++ } - if p.SystemPrompt != nil { q += comma(i, "system_prompt"); args = append(args, *p.SystemPrompt); i++ } - if p.UserPrompt != nil { q += comma(i, "user_prompt"); args = append(args, *p.UserPrompt); i++ } - if p.WorkflowID != nil { q += comma(i, "workflow_id"); args = append(args, *p.WorkflowID); i++ } - if p.ToolGrants != nil { q += comma(i, "tool_grants"); args = append(args, jsonOrNull(*p.ToolGrants)); i++ } - if p.Schedule != nil { q += comma(i, "schedule"); args = append(args, *p.Schedule); i++ } - if p.Timezone != nil { q += comma(i, "timezone"); args = append(args, *p.Timezone); i++ } - if p.IsActive != nil { q += comma(i, "is_active"); args = append(args, *p.IsActive); i++ } - if p.MaxTokens != nil { q += comma(i, "max_tokens"); args = append(args, *p.MaxTokens); i++ } - if p.MaxToolCalls != nil { q += comma(i, "max_tool_calls"); args = append(args, *p.MaxToolCalls); i++ } - if p.MaxWallClock != nil { q += comma(i, "max_wall_clock"); args = append(args, *p.MaxWallClock); i++ } - if p.OutputMode != nil { q += comma(i, "output_mode"); args = append(args, *p.OutputMode); i++ } - if p.OutputChannelID != nil { q += comma(i, "output_channel_id"); args = append(args, *p.OutputChannelID); i++ } - if p.WebhookURL != nil { q += comma(i, "webhook_url"); args = append(args, *p.WebhookURL); i++ } - if p.ProviderConfigID != nil { q += comma(i, "provider_config_id"); args = append(args, *p.ProviderConfigID); i++ } - if p.NotifyOnComplete != nil { q += comma(i, "notify_on_complete"); args = append(args, *p.NotifyOnComplete); i++ } - if p.NotifyOnFailure != nil { q += comma(i, "notify_on_failure"); args = append(args, *p.NotifyOnFailure); i++ } - - q += pgWhere(i, "id") - args = append(args, id) - _, err := DB.ExecContext(ctx, q, args...) - return err -} - -func (s *TaskStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM tasks WHERE id = $1`, id) - return err -} - -func (s *TaskStore) ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error) { - return s.list(ctx, `WHERE owner_id = $1 ORDER BY created_at DESC`, ownerID) -} - -func (s *TaskStore) ListByTeam(ctx context.Context, teamID string) ([]models.Task, error) { - return s.list(ctx, `WHERE team_id = $1 ORDER BY created_at DESC`, teamID) -} - -func (s *TaskStore) ListAll(ctx context.Context) ([]models.Task, error) { - return s.list(ctx, `ORDER BY created_at DESC`) -} - -func (s *TaskStore) ClaimDueTask(ctx context.Context) (*models.Task, error) { - t := &models.Task{} - row := DB.QueryRowContext(ctx, ` - UPDATE tasks SET next_run_at = NULL - WHERE id = ( - SELECT id FROM tasks - WHERE is_active = true AND next_run_at <= NOW() - ORDER BY next_run_at ASC - LIMIT 1 - FOR UPDATE SKIP LOCKED - ) - RETURNING `+taskColumns) - if err := scanTask(row, t); err != nil { - return nil, err - } - return t, nil -} - -// ── Scheduler bookkeeping ────────────────────── - -func (s *TaskStore) SetNextRun(ctx context.Context, id string, nextRun interface{}) error { - _, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2`, nextRun, id) - return err -} - -func (s *TaskStore) SetLastRun(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `UPDATE tasks SET last_run_at = NOW(), updated_at = NOW() WHERE id = $1`, id) - return err -} - -func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `UPDATE tasks SET run_count = run_count + 1, updated_at = NOW() WHERE id = $1`, id) - return err -} - -// ── Run History ───────────────────────────────── - -func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error { - triggerPayload := nilIfEmpty(r.TriggerPayload) - return DB.QueryRowContext(ctx, ` - INSERT INTO task_runs (task_id, channel_id, status, trigger_payload) - VALUES ($1, $2, $3, $4) RETURNING id, started_at`, - r.TaskID, r.ChannelID, r.Status, triggerPayload, - ).Scan(&r.ID, &r.StartedAt) -} - -func (s *TaskStore) CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) { - r := &models.TaskRun{} - err := DB.QueryRowContext(ctx, ` - INSERT INTO task_runs (task_id, status) - SELECT $1, 'running' - WHERE NOT EXISTS ( - SELECT 1 FROM task_runs - WHERE task_id = $1 AND status IN ('running', 'queued') - ) - RETURNING id, started_at`, taskID, - ).Scan(&r.ID, &r.StartedAt) - if err != nil { - return nil, err - } - r.TaskID = taskID - r.Status = "running" - return r, nil -} - -func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE task_runs SET status = $1, tokens_used = $2, tool_calls = $3, - wall_clock = $4, error = $5, completed_at = NOW() - WHERE id = $6`, status, tokensUsed, toolCalls, wallClock, errMsg, id) - return err -} - -func (s *TaskStore) TransitionRunStatus(ctx context.Context, id string, status string) error { - _, err := DB.ExecContext(ctx, `UPDATE task_runs SET status = $1 WHERE id = $2`, status, id) - return err -} - -func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) { - r := &models.TaskRun{} - err := DB.QueryRowContext(ctx, ` - SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at - FROM task_runs WHERE task_id = $1 AND status = 'running' - LIMIT 1`, taskID, - ).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, &r.StartedAt) - if err != nil { - return nil, err - } - return r, nil -} - -func (s *TaskStore) GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error) { - r := &models.TaskRun{} - err := DB.QueryRowContext(ctx, ` - SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at - FROM task_runs WHERE task_id = $1 AND status = 'queued' - ORDER BY started_at ASC LIMIT 1`, taskID, - ).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, &r.StartedAt) - if err != nil { - return nil, err - } - return r, nil -} - -func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), - started_at, completed_at, tokens_used, tool_calls, wall_clock, - COALESCE(error, '') - FROM task_runs WHERE task_id = $1 - ORDER BY started_at DESC LIMIT $2`, taskID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var runs []models.TaskRun - for rows.Next() { - var r models.TaskRun - if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, - &r.StartedAt, &r.CompletedAt, &r.TokensUsed, &r.ToolCalls, &r.WallClock, - &r.Error); err != nil { - continue - } - runs = append(runs, r) - } - return runs, nil -} - -// ── list helper (single source of truth for SELECT + Scan) ───── - -func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) { - q := `SELECT ` + taskColumns + ` FROM tasks ` + where - - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var tasks []models.Task - for rows.Next() { - var t models.Task - if err := scanTask(rows, &t); err != nil { - continue - } - tasks = append(tasks, t) - } - return tasks, nil -} - -// ── Helpers ───────────────────────────────────── - -// comma builds ", column = $N" -func comma(i int, col string) string { - return ", " + col + " = " + pgArg(i) -} - -// pgArg returns "$N" -func pgArg(i int) string { - return fmt.Sprintf("$%d", i) -} - -// pgWhere returns " WHERE col = $N" -func pgWhere(i int, col string) string { - return " WHERE " + col + " = " + pgArg(i) -} - -// nilIfEmpty returns nil for empty strings (prevents empty-string vs NULL issues). -func nilIfEmpty(s string) interface{} { - if s == "" { - return nil - } - return s -} diff --git a/server/store/postgres/workflows.go b/server/store/postgres/workflows.go index 323749d..daceb51 100644 --- a/server/store/postgres/workflows.go +++ b/server/store/postgres/workflows.go @@ -220,7 +220,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag transRules := jsonOrEmpty(st.TransitionRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeChatOnly + stageMode = models.StageModeCustom } return DB.QueryRowContext(ctx, ` INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id, @@ -267,7 +267,7 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag transRules := jsonOrEmpty(st.TransitionRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeChatOnly + stageMode = models.StageModeCustom } _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages @@ -377,207 +377,6 @@ func nullIfEmpty(s string) interface{} { // ── Assignments (v0.29.0-cs3) ─────────────────────────────────────────── -func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error { - a.ID = store.NewID() - _, err := DB.ExecContext(ctx, ` - INSERT INTO workflow_assignments (id, channel_id, stage, team_id) - VALUES ($1, $2, $3, $4) - `, a.ID, a.ChannelID, a.Stage, a.TeamID) - return err -} - -func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, channel_id, stage, team_id, assigned_to, status, - created_at, claimed_at, completed_at - FROM workflow_assignments - WHERE team_id = $1 AND status = $2 - ORDER BY created_at DESC - `, teamID, status) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAssignments(rows) -} - -func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status, - wa.created_at, wa.claimed_at, wa.completed_at - FROM workflow_assignments wa - LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1 - WHERE (wa.assigned_to = $2 AND wa.status = 'claimed') - OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL) - ORDER BY wa.created_at DESC - `, userID, userID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAssignments(rows) -} - -func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET assigned_to = $1, status = 'claimed', claimed_at = $2 - WHERE id = $3 AND status = 'unassigned' - `, userID, time.Now().UTC(), assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'completed', completed_at = $1 - WHERE id = $2 AND status = 'claimed' - `, time.Now().UTC(), assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) { - var channelID string - err := DB.QueryRowContext(ctx, - `SELECT channel_id FROM workflow_assignments WHERE id = $1`, assignmentID).Scan(&channelID) - return channelID, err -} - // ── Lifecycle operations (v0.37.15) ── -func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'cancelled' - WHERE channel_id = $1 AND status IN ('unassigned', 'claimed') - `, channelID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL - WHERE id = $1 AND status = 'claimed' - `, assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET assigned_to = $1, claimed_at = $2 - WHERE id = $3 AND status = 'claimed' - `, newUserID, time.Now().UTC(), assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'cancelled' - WHERE id = $1 AND status IN ('unassigned', 'claimed') - `, assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) { - // Find least-recently-assigned team member - rows, err := DB.QueryContext(ctx, ` - SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim - FROM team_members m - LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1 - WHERE m.team_id = $2 - GROUP BY m.user_id - ORDER BY last_claim ASC - LIMIT 1 - `, teamID, teamID) - if err != nil { - return "", err - } - defer rows.Close() - - if !rows.Next() { - return "", nil // no team members - } - var userID, lastClaim string - if err := rows.Scan(&userID, &lastClaim); err != nil { - return "", err - } - - // Claim for that user - _, err = DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET assigned_to = $1, status = 'claimed', claimed_at = $2 - WHERE id = $3 AND status = 'unassigned' - `, userID, time.Now().UTC(), assignmentID) - if err != nil { - return "", err - } - return userID, nil -} - -func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) { - var result []store.WorkflowAssignment - for rows.Next() { - var a store.WorkflowAssignment - if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, - &a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil { - return nil, err - } - result = append(result, a) - } - if result == nil { - result = []store.WorkflowAssignment{} - } - return result, rows.Err() -} - // ── Review Comments (v0.35.0) ─────────────────────────── - -func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) { - var a store.WorkflowAssignment - var rc []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, stage, team_id, assigned_to, status, - review_comments, created_at, claimed_at, completed_at - FROM workflow_assignments WHERE id = $1 - `, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status, - &rc, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt) - if err != nil { - return nil, err - } - a.ReviewComments = rc - return &a, nil -} - -func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error { - commentJSON, err := json.Marshal(comment) - if err != nil { - return err - } - _, err = DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET review_comments = review_comments || $1::jsonb - WHERE id = $2 - `, "["+string(commentJSON)+"]", assignmentID) - return err -} diff --git a/server/store/sqlite/health.go b/server/store/sqlite/health.go index 520af1c..6974dff 100644 --- a/server/store/sqlite/health.go +++ b/server/store/sqlite/health.go @@ -2,188 +2,42 @@ package sqlite import ( "context" - "database/sql" "time" - "switchboard-core/models" - "switchboard-core/store" + "switchboard-core/database" ) +// ── HealthStore ───────────────────────────── + type HealthStore struct{} func NewHealthStore() *HealthStore { return &HealthStore{} } -func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error { - id := store.NewID() - windowStr := w.WindowStart.Format(timeFmt) - _, err := DB.ExecContext(ctx, ` - INSERT INTO provider_health (id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT (provider_config_id, window_start) DO UPDATE SET - request_count = provider_health.request_count + excluded.request_count, - error_count = provider_health.error_count + excluded.error_count, - timeout_count = provider_health.timeout_count + excluded.timeout_count, - rate_limit_count = provider_health.rate_limit_count + excluded.rate_limit_count, - total_latency_ms = provider_health.total_latency_ms + excluded.total_latency_ms, - max_latency_ms = MAX(provider_health.max_latency_ms, excluded.max_latency_ms), - last_error = COALESCE(excluded.last_error, provider_health.last_error), - last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at), - updated_at = datetime('now') - `, id, w.ProviderConfigID, windowStr, - w.RequestCount, w.ErrorCount, w.TimeoutCount, w.RateLimitCount, - w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt, - ) - return err -} - -func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) { - windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt) - var w models.ProviderHealthWindow - var windowStartStr string - err := DB.QueryRowContext(ctx, ` - SELECT id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at - FROM provider_health - WHERE provider_config_id = ? AND window_start = ? - `, providerConfigID, windowStr).Scan( - &w.ID, &w.ProviderConfigID, &windowStartStr, - &w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount, - &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt, - ) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - w.WindowStart, _ = time.Parse(timeFmt, windowStartStr) - return &w, nil -} - -func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at - FROM provider_health - WHERE provider_config_id = ? - ORDER BY window_start DESC - LIMIT ? - `, providerConfigID, hours) - if err != nil { - return nil, err - } - defer rows.Close() - return scanHealthRows(rows) -} - -func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) { - windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt) - rows, err := DB.QueryContext(ctx, ` - SELECT id, provider_config_id, window_start, - request_count, error_count, timeout_count, rate_limit_count, - total_latency_ms, max_latency_ms, last_error, last_error_at - FROM provider_health - WHERE window_start = ? - ORDER BY provider_config_id - `, windowStr) - if err != nil { - return nil, err - } - defer rows.Close() - return scanHealthRows(rows) -} - +// Prune deletes stale kernel data older than the given time. func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) { - result, err := DB.ExecContext(ctx, ` - DELETE FROM provider_health WHERE window_start < ? - `, before.Format(timeFmt)) - if err != nil { - return 0, err + var total int64 + ts := before.UTC().Format("2006-01-02 15:04:05") + + res, _ := database.DB.ExecContext(ctx, + `DELETE FROM ws_tickets WHERE expires_at < ?`, ts) + if res != nil { + n, _ := res.RowsAffected() + total += n } - DB.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < ?`, before.Format(timeFmt)) - return result.RowsAffected() -} -// ── Tool Health (v0.22.4) ──────────────────── - -func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error { - id := store.NewID() - windowStr := w.WindowStart.Format(timeFmt) - _, err := DB.ExecContext(ctx, ` - INSERT INTO tool_health (id, tool_name, window_start, - request_count, error_count, total_latency_ms, max_latency_ms, - last_error, last_error_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT (tool_name, window_start) DO UPDATE SET - request_count = tool_health.request_count + excluded.request_count, - error_count = tool_health.error_count + excluded.error_count, - total_latency_ms = tool_health.total_latency_ms + excluded.total_latency_ms, - max_latency_ms = MAX(tool_health.max_latency_ms, excluded.max_latency_ms), - last_error = COALESCE(excluded.last_error, tool_health.last_error), - last_error_at = COALESCE(excluded.last_error_at, tool_health.last_error_at), - updated_at = datetime('now') - `, id, w.ToolName, windowStr, - w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs, - w.LastError, w.LastErrorAt, - ) - return err -} - -func (s *HealthStore) ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) { - windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt) - rows, err := DB.QueryContext(ctx, ` - SELECT id, tool_name, window_start, - request_count, error_count, total_latency_ms, max_latency_ms, - last_error, last_error_at - FROM tool_health - WHERE window_start = ? - ORDER BY tool_name - `, windowStr) - if err != nil { - return nil, err + res, _ = database.DB.ExecContext(ctx, + `DELETE FROM rate_limit_counters WHERE window_start < ?`, ts) + if res != nil { + n, _ := res.RowsAffected() + total += n } - defer rows.Close() - var result []models.ToolHealthWindow - for rows.Next() { - var w models.ToolHealthWindow - var windowStartStr string - if err := rows.Scan( - &w.ID, &w.ToolName, &windowStartStr, - &w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs, - &w.LastError, &w.LastErrorAt, - ); err != nil { - return nil, err - } - w.WindowStart, _ = time.Parse(timeFmt, windowStartStr) - result = append(result, w) + res, _ = database.DB.ExecContext(ctx, + `DELETE FROM user_presence WHERE last_seen < ? AND status = 'offline'`, ts) + if res != nil { + n, _ := res.RowsAffected() + total += n } - return result, rows.Err() -} -func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error { - _, err := DB.ExecContext(ctx, `UPDATE provider_configs SET is_active = 0 WHERE id = ?`, configID) - return err -} - -func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) { - var result []models.ProviderHealthWindow - for rows.Next() { - var w models.ProviderHealthWindow - var windowStartStr string - if err := rows.Scan( - &w.ID, &w.ProviderConfigID, &windowStartStr, - &w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount, - &w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt, - ); err != nil { - return nil, err - } - w.WindowStart, _ = time.Parse(timeFmt, windowStartStr) - result = append(result, w) - } - return result, rows.Err() + return total, nil } diff --git a/server/store/sqlite/sessions.go b/server/store/sqlite/sessions.go deleted file mode 100644 index 42fedfe..0000000 --- a/server/store/sqlite/sessions.go +++ /dev/null @@ -1,119 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -// ── SessionStore ─────────────────────────── - -type SessionStore struct{} - -func NewSessionStore() *SessionStore { return &SessionStore{} } - -func (s *SessionStore) Create(ctx context.Context, sp *models.SessionParticipant) error { - if sp.ID == "" { - sp.ID = store.NewID() - } - _, err := DB.ExecContext(ctx, ` - INSERT INTO session_participants (id, session_token, channel_id, display_name, fingerprint) - VALUES (?, ?, ?, ?, ?)`, - sp.ID, sp.SessionToken, sp.ChannelID, sp.DisplayName, nullText(sp.Fingerprint), - ) - if err != nil { - return err - } - return DB.QueryRowContext(ctx, `SELECT created_at FROM session_participants WHERE id = ?`, sp.ID). - Scan(st(&sp.CreatedAt)) -} - -func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) { - sp := &models.SessionParticipant{} - err := DB.QueryRowContext(ctx, ` - SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at - FROM session_participants WHERE session_token = ?`, token, - ).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt)) - if err != nil { - return nil, err - } - return sp, nil -} - -func (s *SessionStore) GetByID(ctx context.Context, id string) (*models.SessionParticipant, error) { - sp := &models.SessionParticipant{} - err := DB.QueryRowContext(ctx, ` - SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at - FROM session_participants WHERE id = ?`, id, - ).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt)) - if err != nil { - return nil, err - } - return sp, nil -} - -func (s *SessionStore) ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at - FROM session_participants WHERE channel_id = ? - ORDER BY created_at ASC`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - - var result []models.SessionParticipant - for rows.Next() { - var sp models.SessionParticipant - if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, st(&sp.CreatedAt)); err != nil { - return nil, err - } - result = append(result, sp) - } - return result, rows.Err() -} - -func (s *SessionStore) CountForChannel(ctx context.Context, channelID string) (int, error) { - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM session_participants WHERE channel_id = ?`, channelID, - ).Scan(&count) - return count, err -} - -func (s *SessionStore) Delete(ctx context.Context, id string) error { - res, err := DB.ExecContext(ctx, `DELETE FROM session_participants WHERE id = ?`, id) - if err != nil { - return err - } - n, _ := res.RowsAffected() - if n == 0 { - return sql.ErrNoRows - } - return nil -} - -// DeleteExpired removes sessions created before olderThan whose channel -// has no messages. Returns the count of deleted rows. -func (s *SessionStore) DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error) { - res, err := DB.ExecContext(ctx, ` - DELETE FROM session_participants - WHERE created_at < ? - AND NOT EXISTS ( - SELECT 1 FROM messages WHERE messages.channel_id = session_participants.channel_id - )`, olderThan) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func nullText(s string) interface{} { - if s == "" { - return nil - } - return s -} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 7aed8fc..2ed140a 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -19,14 +19,12 @@ func NewStores(db *sql.DB) store.Stores { ResourceGrants: NewResourceGrantStore(), Notifications: NewNotificationStore(), NotifPrefs: NewNotificationPreferenceStore(), - Sessions: NewSessionStore(), Presence: NewPresenceStore(), Health: NewHealthStore(), Connections: NewConnectionStore(), Dependencies: NewDependencyStore(), Packages: NewPackageStore(), Workflows: NewWorkflowStore(), - Tasks: NewTaskStore(), ExtPermissions: NewExtensionPermissionStore(), ExtData: NewExtDataStore(), Tickets: NewTicketStore(), diff --git a/server/store/sqlite/tasks.go b/server/store/sqlite/tasks.go deleted file mode 100644 index d93f069..0000000 --- a/server/store/sqlite/tasks.go +++ /dev/null @@ -1,362 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "time" - - "switchboard-core/models" - "switchboard-core/store" -) - -type TaskStore struct{} - -func NewTaskStore() *TaskStore { return &TaskStore{} } - -// ── Full column list (single source of truth) ────────────────── - -// taskColumns is the canonical SELECT list for tasks. -// COALESCE wraps nullable columns that scan into non-pointer Go types. -// tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL. -const taskColumns = `id, owner_id, team_id, name, description, scope, - task_type, COALESCE(system_function, '') AS system_function, - persona_id, model_id, system_prompt, user_prompt, - workflow_id, COALESCE(tool_grants, '[]') AS tool_grants, - schedule, timezone, is_active, - COALESCE(trigger_token, '') AS trigger_token, - max_tokens, max_tool_calls, max_wall_clock, output_mode, - output_channel_id, - COALESCE(webhook_url, '') AS webhook_url, - COALESCE(webhook_secret, '') AS webhook_secret, - provider_config_id, - notify_on_complete, notify_on_failure, - last_run_at, next_run_at, run_count, created_at, updated_at` - -// scanTask scans a full task row into the model. -// -// SQLite dialect issues handled here: -// - tool_grants: modernc driver returns TEXT as Go string, not []byte. -// json.RawMessage (named []byte) can't accept string via convertAssign. -// Scan into string intermediate, then cast. -// - is_active, notify_on_complete, notify_on_failure: stored as INTEGER -// (0/1). modernc returns int64, but convertAssign can't assign int64 -// to *bool. Scan into int intermediate, then convert. -// -// These match the patterns in store/sqlite/workflows.go. -func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error { - var toolGrantsStr string - var isActive, notifyComplete, notifyFailure int - err := scanner.Scan( - &t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope, - &t.TaskType, &t.SystemFunction, - &t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt, - &t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive, - &t.TriggerToken, - &t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode, - &t.OutputChannelID, &t.WebhookURL, &t.WebhookSecret, &t.ProviderConfigID, - ¬ifyComplete, ¬ifyFailure, - stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt), - ) - if err != nil { - return err - } - t.ToolGrants = json.RawMessage(toolGrantsStr) - t.IsActive = isActive != 0 - t.NotifyOnComplete = notifyComplete != 0 - t.NotifyOnFailure = notifyFailure != 0 - return nil -} - -// ── CRUD ─────────────────────────────────────── - -func (s *TaskStore) Create(ctx context.Context, t *models.Task) error { - t.ID = store.NewID() - now := time.Now().UTC() - t.CreatedAt = now - t.UpdatedAt = now - _, err := DB.ExecContext(ctx, ` - INSERT INTO tasks (id, owner_id, team_id, name, description, scope, - task_type, system_function, - persona_id, model_id, system_prompt, user_prompt, - workflow_id, tool_grants, schedule, timezone, is_active, - trigger_token, - max_tokens, max_tool_calls, max_wall_clock, output_mode, - output_channel_id, webhook_url, webhook_secret, provider_config_id, - notify_on_complete, notify_on_failure, next_run_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope, - t.TaskType, t.SystemFunction, - t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt, - t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive), - nilIfEmpty(t.TriggerToken), - t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode, - t.OutputChannelID, t.WebhookURL, t.WebhookSecret, t.ProviderConfigID, - boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), timeToSQLite(t.NextRunAt)) - return err -} - -func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) { - t := &models.Task{} - row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id = ?`, id) - if err := scanTask(row, t); err != nil { - return nil, err - } - return t, nil -} - -func (s *TaskStore) GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) { - t := &models.Task{} - row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE trigger_token = ?`, token) - if err := scanTask(row, t); err != nil { - return nil, err - } - return t, nil -} - -func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) error { - q := "UPDATE tasks SET updated_at = datetime('now')" - args := []interface{}{} - if p.Name != nil { q += ", name = ?"; args = append(args, *p.Name) } - if p.Description != nil { q += ", description = ?"; args = append(args, *p.Description) } - if p.PersonaID != nil { q += ", persona_id = ?"; args = append(args, *p.PersonaID) } - if p.ModelID != nil { q += ", model_id = ?"; args = append(args, *p.ModelID) } - if p.SystemPrompt != nil { q += ", system_prompt = ?"; args = append(args, *p.SystemPrompt) } - if p.UserPrompt != nil { q += ", user_prompt = ?"; args = append(args, *p.UserPrompt) } - if p.WorkflowID != nil { q += ", workflow_id = ?"; args = append(args, *p.WorkflowID) } - if p.ToolGrants != nil { q += ", tool_grants = ?"; args = append(args, nullableJSON(*p.ToolGrants)) } - if p.Schedule != nil { q += ", schedule = ?"; args = append(args, *p.Schedule) } - if p.Timezone != nil { q += ", timezone = ?"; args = append(args, *p.Timezone) } - if p.IsActive != nil { q += ", is_active = ?"; args = append(args, boolToInt(*p.IsActive)) } - if p.MaxTokens != nil { q += ", max_tokens = ?"; args = append(args, *p.MaxTokens) } - if p.MaxToolCalls != nil { q += ", max_tool_calls = ?"; args = append(args, *p.MaxToolCalls) } - if p.MaxWallClock != nil { q += ", max_wall_clock = ?"; args = append(args, *p.MaxWallClock) } - if p.OutputMode != nil { q += ", output_mode = ?"; args = append(args, *p.OutputMode) } - if p.OutputChannelID != nil { q += ", output_channel_id = ?"; args = append(args, *p.OutputChannelID) } - if p.WebhookURL != nil { q += ", webhook_url = ?"; args = append(args, *p.WebhookURL) } - if p.ProviderConfigID != nil { q += ", provider_config_id = ?"; args = append(args, *p.ProviderConfigID) } - if p.NotifyOnComplete != nil { q += ", notify_on_complete = ?"; args = append(args, boolToInt(*p.NotifyOnComplete)) } - if p.NotifyOnFailure != nil { q += ", notify_on_failure = ?"; args = append(args, boolToInt(*p.NotifyOnFailure)) } - q += " WHERE id = ?" - args = append(args, id) - _, err := DB.ExecContext(ctx, q, args...) - return err -} - -func (s *TaskStore) Delete(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) - return err -} - -func (s *TaskStore) ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error) { - return s.list(ctx, `WHERE owner_id = ? ORDER BY created_at DESC`, ownerID) -} - -func (s *TaskStore) ListByTeam(ctx context.Context, teamID string) ([]models.Task, error) { - return s.list(ctx, `WHERE team_id = ? ORDER BY created_at DESC`, teamID) -} - -func (s *TaskStore) ListAll(ctx context.Context) ([]models.Task, error) { - return s.list(ctx, `ORDER BY created_at DESC`) -} - -func (s *TaskStore) ClaimDueTask(ctx context.Context) (*models.Task, error) { - // SQLite: single-process, no contention. Select the oldest due task, - // then atomically clear next_run_at to mark it claimed. - t := &models.Task{} - row := DB.QueryRowContext(ctx, - `SELECT `+taskColumns+` FROM tasks - WHERE is_active = 1 AND next_run_at <= datetime('now') - ORDER BY next_run_at ASC LIMIT 1`) - if err := scanTask(row, t); err != nil { - return nil, err - } - _, _ = DB.ExecContext(ctx, - `UPDATE tasks SET next_run_at = NULL WHERE id = ?`, t.ID) - return t, nil -} - -// ── Scheduler bookkeeping ────────────────────── - -func (s *TaskStore) SetNextRun(ctx context.Context, id string, nextRun interface{}) error { - _, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = ?, updated_at = datetime('now') WHERE id = ?`, timeToSQLiteAny(nextRun), id) - return err -} - -func (s *TaskStore) SetLastRun(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `UPDATE tasks SET last_run_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, id) - return err -} - -func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error { - _, err := DB.ExecContext(ctx, `UPDATE tasks SET run_count = run_count + 1, updated_at = datetime('now') WHERE id = ?`, id) - return err -} - -// ── Run History ───────────────────────────────── - -func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error { - r.ID = store.NewID() - // F4 audit fix: set StartedAt in Go so the returned struct matches PG - // behavior (PG returns it via RETURNING; SQLite can't). - r.StartedAt = time.Now().UTC() - _, err := DB.ExecContext(ctx, ` - INSERT INTO task_runs (id, task_id, channel_id, status, trigger_payload, started_at) - VALUES (?, ?, ?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status, - nilIfEmpty(r.TriggerPayload), r.StartedAt.UTC().Format("2006-01-02 15:04:05")) - return err -} - -func (s *TaskStore) CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) { - // SQLite: check-then-insert. Single-process, no race. - var count int - err := DB.QueryRowContext(ctx, ` - SELECT COUNT(*) FROM task_runs - WHERE task_id = ? AND status IN ('running', 'queued')`, taskID).Scan(&count) - if err != nil { - return nil, err - } - if count > 0 { - return nil, sql.ErrNoRows - } - r := &models.TaskRun{ - ID: store.NewID(), - TaskID: taskID, - Status: "running", - StartedAt: time.Now().UTC(), - } - _, err = DB.ExecContext(ctx, ` - INSERT INTO task_runs (id, task_id, status, started_at) - VALUES (?, ?, 'running', ?)`, - r.ID, taskID, r.StartedAt.UTC().Format("2006-01-02 15:04:05")) - if err != nil { - return nil, err - } - return r, nil -} - -func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error { - _, err := DB.ExecContext(ctx, ` - UPDATE task_runs SET status = ?, tokens_used = ?, tool_calls = ?, - wall_clock = ?, error = ?, completed_at = datetime('now') - WHERE id = ?`, status, tokensUsed, toolCalls, wallClock, errMsg, id) - return err -} - -func (s *TaskStore) TransitionRunStatus(ctx context.Context, id string, status string) error { - _, err := DB.ExecContext(ctx, `UPDATE task_runs SET status = ? WHERE id = ?`, status, id) - return err -} - -func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) { - r := &models.TaskRun{} - err := DB.QueryRowContext(ctx, ` - SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at - FROM task_runs WHERE task_id = ? AND status = 'running' - LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt)) - if err != nil { - return nil, err - } - return r, nil -} - -func (s *TaskStore) GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error) { - r := &models.TaskRun{} - err := DB.QueryRowContext(ctx, ` - SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at - FROM task_runs WHERE task_id = ? AND status = 'queued' - ORDER BY started_at ASC LIMIT 1`, taskID).Scan( - &r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt)) - if err != nil { - return nil, err - } - return r, nil -} - -func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), - started_at, completed_at, tokens_used, tool_calls, wall_clock, - COALESCE(error, '') - FROM task_runs WHERE task_id = ? - ORDER BY started_at DESC LIMIT ?`, taskID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var runs []models.TaskRun - for rows.Next() { - var r models.TaskRun - if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, - st(&r.StartedAt), stN(&r.CompletedAt), - &r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil { - continue - } - runs = append(runs, r) - } - return runs, nil -} - -// ── list helper ──────────────────────────────── - -func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) { - q := `SELECT ` + taskColumns + ` FROM tasks ` + where - - rows, err := DB.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var tasks []models.Task - for rows.Next() { - var t models.Task - if err := scanTask(rows, &t); err != nil { - continue - } - tasks = append(tasks, t) - } - return tasks, nil -} - -// ── Helpers ───────────────────────────────────── - -func nullableJSON(data []byte) interface{} { - if len(data) == 0 || string(data) == "null" { - return nil - } - return string(data) -} - -func nilIfEmpty(s string) interface{} { - if s == "" { - return nil - } - return s -} - -// timeToSQLite converts *time.Time to the TEXT format that datetime('now') -// produces ("2006-01-02 15:04:05"). Returns nil for nil input. -// This prevents the modernc driver from serializing time.Time in an -// unparseable format (int64 / RFC3339Nano / driver-specific). -func timeToSQLite(t *time.Time) interface{} { - if t == nil { - return nil - } - return t.UTC().Format("2006-01-02 15:04:05") -} - -// timeToSQLiteAny handles interface{} that may be *time.Time, time.Time, or nil. -func timeToSQLiteAny(v interface{}) interface{} { - if v == nil { - return nil - } - switch t := v.(type) { - case *time.Time: - return timeToSQLite(t) - case time.Time: - return t.UTC().Format("2006-01-02 15:04:05") - default: - return v - } -} diff --git a/server/store/sqlite/workflows.go b/server/store/sqlite/workflows.go index 07526b9..adc0130 100644 --- a/server/store/sqlite/workflows.go +++ b/server/store/sqlite/workflows.go @@ -155,7 +155,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag transRules := jsonOrEmpty(st.TransitionRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeChatOnly + stageMode = models.StageModeCustom } _, err := DB.ExecContext(ctx, ` INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id, @@ -203,7 +203,7 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag transRules := jsonOrEmpty(st.TransitionRules) stageMode := st.StageMode if stageMode == "" { - stageMode = models.StageModeChatOnly + stageMode = models.StageModeCustom } _, err := DB.ExecContext(ctx, ` UPDATE workflow_stages @@ -380,218 +380,6 @@ func nullIfEmpty(s string) interface{} { // ── Assignments (v0.29.0-cs3) ─────────────────────────────────────────── -func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error { - a.ID = store.NewID() - _, err := DB.ExecContext(ctx, ` - INSERT INTO workflow_assignments (id, channel_id, stage, team_id) - VALUES (?, ?, ?, ?) - `, a.ID, a.ChannelID, a.Stage, a.TeamID) - return err -} - -func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT id, channel_id, stage, team_id, assigned_to, status, - created_at, claimed_at, completed_at - FROM workflow_assignments - WHERE team_id = ? AND status = ? - ORDER BY created_at DESC - `, teamID, status) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAssignments(rows) -} - -func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status, - wa.created_at, wa.claimed_at, wa.completed_at - FROM workflow_assignments wa - LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = ? - WHERE (wa.assigned_to = ? AND wa.status = 'claimed') - OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL) - ORDER BY wa.created_at DESC - `, userID, userID) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAssignments(rows) -} - -func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET assigned_to = ?, status = 'claimed', claimed_at = ? - WHERE id = ? AND status = 'unassigned' - `, userID, time.Now().UTC().Format(timeFmt), assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'completed', completed_at = ? - WHERE id = ? AND status = 'claimed' - `, time.Now().UTC().Format(timeFmt), assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) { - var channelID string - err := DB.QueryRowContext(ctx, - `SELECT channel_id FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&channelID) - return channelID, err -} - // ── Lifecycle operations (v0.37.15) ── -func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'cancelled' - WHERE channel_id = ? AND status IN ('unassigned', 'claimed') - `, channelID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL - WHERE id = ? AND status = 'claimed' - `, assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET assigned_to = ?, claimed_at = ? - WHERE id = ? AND status = 'claimed' - `, newUserID, time.Now().UTC().Format(timeFmt), assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) { - res, err := DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET status = 'cancelled' - WHERE id = ? AND status IN ('unassigned', 'claimed') - `, assignmentID) - if err != nil { - return 0, err - } - return res.RowsAffected() -} - -func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) { - rows, err := DB.QueryContext(ctx, ` - SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim - FROM team_members m - LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = ? - WHERE m.team_id = ? - GROUP BY m.user_id - ORDER BY last_claim ASC - LIMIT 1 - `, teamID, teamID) - if err != nil { - return "", err - } - defer rows.Close() - - if !rows.Next() { - return "", nil - } - var userID, lastClaim string - if err := rows.Scan(&userID, &lastClaim); err != nil { - return "", err - } - - _, err = DB.ExecContext(ctx, ` - UPDATE workflow_assignments - SET assigned_to = ?, status = 'claimed', claimed_at = ? - WHERE id = ? AND status = 'unassigned' - `, userID, time.Now().UTC().Format(timeFmt), assignmentID) - if err != nil { - return "", err - } - return userID, nil -} - -func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) { - var result []store.WorkflowAssignment - for rows.Next() { - var a store.WorkflowAssignment - var claimedAt, completedAt *time.Time - if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, - &a.AssignedTo, &a.Status, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt)); err != nil { - return nil, err - } - a.ClaimedAt = claimedAt - a.CompletedAt = completedAt - result = append(result, a) - } - if result == nil { - result = []store.WorkflowAssignment{} - } - return result, rows.Err() -} - // ── Review Comments (v0.35.0) ─────────────────────────── - -func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) { - var a store.WorkflowAssignment - var rc string - var claimedAt, completedAt *time.Time - err := DB.QueryRowContext(ctx, ` - SELECT id, channel_id, stage, team_id, assigned_to, status, - COALESCE(review_comments, '[]'), created_at, claimed_at, completed_at - FROM workflow_assignments WHERE id = ? - `, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status, - &rc, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt)) - if err != nil { - return nil, err - } - a.ReviewComments = json.RawMessage(rc) - a.ClaimedAt = claimedAt - a.CompletedAt = completedAt - return &a, nil -} - -func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error { - commentJSON, err := json.Marshal(comment) - if err != nil { - return err - } - - // SQLite: read, append, write back - var existing string - err = DB.QueryRowContext(ctx, `SELECT COALESCE(review_comments, '[]') FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&existing) - if err != nil { - return err - } - var arr []json.RawMessage - _ = json.Unmarshal([]byte(existing), &arr) - arr = append(arr, json.RawMessage(commentJSON)) - updated, _ := json.Marshal(arr) - _, err = DB.ExecContext(ctx, `UPDATE workflow_assignments SET review_comments = ? WHERE id = ?`, string(updated), assignmentID) - return err -} diff --git a/server/store/task_iface.go b/server/store/task_iface.go deleted file mode 100644 index 5071f70..0000000 --- a/server/store/task_iface.go +++ /dev/null @@ -1,37 +0,0 @@ -package store - -import ( - "context" - - "switchboard-core/models" -) - -// TaskStore manages task definitions and run history. -type TaskStore interface { - // Task CRUD - Create(ctx context.Context, t *models.Task) error - GetByID(ctx context.Context, id string) (*models.Task, error) - Update(ctx context.Context, id string, patch models.TaskPatch) error - Delete(ctx context.Context, id string) error - ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error) - ListByTeam(ctx context.Context, teamID string) ([]models.Task, error) - ListAll(ctx context.Context) ([]models.Task, error) - - // Trigger lookup (inbound webhooks) - GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) - - // Scheduler queries - ClaimDueTask(ctx context.Context) (*models.Task, error) - SetNextRun(ctx context.Context, id string, nextRun interface{}) error - SetLastRun(ctx context.Context, id string) error - IncrementRunCount(ctx context.Context, id string) error - - // Run history - CreateRun(ctx context.Context, r *models.TaskRun) error - CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) - UpdateRun(ctx context.Context, id string, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error - TransitionRunStatus(ctx context.Context, id string, status string) error - GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) - GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error) - ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) -} diff --git a/server/store/workflow_iface.go b/server/store/workflow_iface.go index eef00a1..5de8ed9 100644 --- a/server/store/workflow_iface.go +++ b/server/store/workflow_iface.go @@ -28,62 +28,4 @@ type WorkflowStore interface { Publish(ctx context.Context, v *models.WorkflowVersion) error GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) - - // ── Assignments (v0.29.0-cs3) ── - - // CreateAssignment inserts a workflow_assignments row. - CreateAssignment(ctx context.Context, a *WorkflowAssignment) error - - // ListAssignmentsForTeam returns assignments for a team filtered by status. - ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]WorkflowAssignment, error) - - // ListAssignmentsMine returns claimed + unassigned assignments visible to a user. - ListAssignmentsMine(ctx context.Context, userID string) ([]WorkflowAssignment, error) - - // ClaimAssignment sets assigned_to and status='claimed' on an unassigned row. - // Returns rows affected (0 = already claimed or not found). - ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) - - // CompleteAssignment sets status='completed' on a claimed row. - // Returns rows affected (0 = not claimed or not found). - CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) - - // GetAssignmentChannelID returns the channel_id for an assignment. - GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) - - // TryRoundRobin finds the least-recently-assigned team member and claims. - // Returns the assigned user ID, or "" if no members available. - TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) - - // ── Lifecycle operations (v0.37.15) ── - - // CancelAssignmentsForChannel sets status='cancelled' on all - // unassigned/claimed assignments for a channel. - CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) - - // UnclaimAssignment returns a claimed assignment to unassigned. - // Returns rows affected (0 = not claimed or not found). - UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) - - // ReassignAssignment changes assigned_to on a claimed assignment. - // Returns rows affected. - ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) - - // CancelAssignment sets a single assignment to cancelled. - CancelAssignment(ctx context.Context, assignmentID string) (int64, error) - - // ── Review Comments (v0.35.0) ── - - // GetAssignmentByID returns a single assignment by ID. - GetAssignmentByID(ctx context.Context, id string) (*WorkflowAssignment, error) - - // AddReviewComment appends a comment to an assignment's review_comments array. - AddReviewComment(ctx context.Context, assignmentID string, comment ReviewComment) error -} - -// ReviewComment is a single review comment on a workflow assignment (v0.35.0). -type ReviewComment struct { - Text string `json:"text"` - UserID string `json:"user_id"` - CreatedAt string `json:"created_at"` } diff --git a/server/store/workflow_types.go b/server/store/workflow_types.go deleted file mode 100644 index 6167eda..0000000 --- a/server/store/workflow_types.go +++ /dev/null @@ -1,31 +0,0 @@ -package store - -import ( - "encoding/json" - "time" -) - -// WorkflowChannelStatus holds the workflow state columns from the channels table. -type WorkflowChannelStatus struct { - WorkflowID *string `json:"workflow_id"` - WorkflowVersion *int `json:"workflow_version"` - CurrentStage int `json:"current_stage"` - StageData json.RawMessage `json:"stage_data"` - Status string `json:"status"` - LastActivityAt *string `json:"last_activity_at"` - StageEnteredAt *string `json:"stage_entered_at,omitempty"` -} - -// WorkflowAssignment is a row from the workflow_assignments table. -type WorkflowAssignment struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - Stage int `json:"stage"` - TeamID string `json:"team_id"` - AssignedTo *string `json:"assigned_to"` - Status string `json:"status"` - ReviewComments json.RawMessage `json:"review_comments"` - CreatedAt time.Time `json:"created_at"` - ClaimedAt *time.Time `json:"claimed_at"` - CompletedAt *time.Time `json:"completed_at"` -} diff --git a/server/taskutil/cron.go b/server/taskutil/cron.go deleted file mode 100644 index 8ba946d..0000000 --- a/server/taskutil/cron.go +++ /dev/null @@ -1,49 +0,0 @@ -package taskutil - -import ( - "time" - - "github.com/robfig/cron/v3" -) - -// cronParser is a shared parser instance. Standard 5-field cron with -// optional descriptors (@hourly, @daily, @weekly, @monthly, etc.). -var cronParser = cron.NewParser( - cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, -) - -// NextRunFromSchedule computes the next run time from a cron expression -// and timezone. Returns nil for "once" schedules (one-shot tasks). -// -// Replaces the v0.27.1 hand-rolled parseDailyCron with full 5-field -// cron support via robfig/cron/v3. -func NextRunFromSchedule(schedule, timezone string) *time.Time { - if schedule == "once" { - return nil - } - - now := time.Now() - if tz, err := time.LoadLocation(timezone); err == nil { - now = now.In(tz) - } - - sched, err := cronParser.Parse(schedule) - if err != nil { - // Unparseable — log at call site, caller decides fallback - return nil - } - - next := sched.Next(now).UTC() - return &next -} - -// ValidateCron checks whether a cron expression is valid. -// Returns nil for valid expressions, error describing the problem otherwise. -// "once" is always valid (one-shot schedule). -func ValidateCron(schedule string) error { - if schedule == "once" { - return nil - } - _, err := cronParser.Parse(schedule) - return err -} diff --git a/server/taskutil/system_registry.go b/server/taskutil/system_registry.go deleted file mode 100644 index 7c2e1fc..0000000 --- a/server/taskutil/system_registry.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package taskutil — system_registry.go -// -// v0.28.6: System task function registry. -// -// Built-in Go functions that run as auditable, scheduled tasks instead of -// ad-hoc goroutines. Each function receives a context, the full store set, -// and returns a structured result. The registry is permanent — system -// functions are not replaced by Starlark (v0.29.0). Core platform ops -// must not break from bad user code. -// -// Registration happens at init time from main.go. The executor calls -// Get() to look up a function by name at execution time. -package taskutil - -import ( - "context" - "fmt" - "sort" - "sync" - - "switchboard-core/store" -) - -// SystemFunc is the signature for built-in system task functions. -// The function receives a context and the full store set. It returns -// a human-readable result string (logged in the task run) and an error. -type SystemFunc func(ctx context.Context, stores store.Stores) (string, error) - -// SystemFuncInfo describes a registered system function. -type SystemFuncInfo struct { - Name string `json:"name"` - Description string `json:"description"` -} - -var ( - registryMu sync.RWMutex - registry = make(map[string]registryEntry) -) - -type registryEntry struct { - fn SystemFunc - description string -} - -// RegisterSystemFunc registers a named system function. -// Call during init (before scheduler starts). Not goroutine-safe for writes. -func RegisterSystemFunc(name, description string, fn SystemFunc) { - registryMu.Lock() - defer registryMu.Unlock() - registry[name] = registryEntry{fn: fn, description: description} -} - -// GetSystemFunc returns a system function by name. -func GetSystemFunc(name string) (SystemFunc, bool) { - registryMu.RLock() - defer registryMu.RUnlock() - e, ok := registry[name] - if !ok { - return nil, false - } - return e.fn, true -} - -// ListSystemFuncs returns all registered system function names and descriptions. -func ListSystemFuncs() []SystemFuncInfo { - registryMu.RLock() - defer registryMu.RUnlock() - result := make([]SystemFuncInfo, 0, len(registry)) - for name, e := range registry { - result = append(result, SystemFuncInfo{Name: name, Description: e.description}) - } - sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) - return result -} - -// ValidateSystemFunc returns an error if the function name is not registered. -func ValidateSystemFunc(name string) error { - registryMu.RLock() - defer registryMu.RUnlock() - if _, ok := registry[name]; !ok { - names := make([]string, 0, len(registry)) - for n := range registry { - names = append(names, n) - } - sort.Strings(names) - return fmt.Errorf("unknown system function %q (available: %v)", name, names) - } - return nil -} diff --git a/server/taskutil/task_config.go b/server/taskutil/task_config.go deleted file mode 100644 index d6943aa..0000000 --- a/server/taskutil/task_config.go +++ /dev/null @@ -1,115 +0,0 @@ -package taskutil - -import ( - "context" - "log" - - "switchboard-core/models" - "switchboard-core/store" -) - -// TaskConfig holds the runtime task configuration read from global_settings. -// Keys: tasks.enabled, tasks.allow_personal, tasks.max_concurrent, -// tasks.default_max_tokens, tasks.default_max_tool_calls, -// tasks.default_max_wall_clock, tasks.personal_require_byok -type TaskConfig struct { - Enabled bool - AllowPersonal bool - PersonalRequireBYOK bool - MaxConcurrent int - DefaultMaxTokens int - DefaultMaxToolCalls int - DefaultMaxWallClock int // seconds -} - -// DefaultTaskConfig returns sensible defaults when no global config is set. -func DefaultTaskConfig() TaskConfig { - return TaskConfig{ - Enabled: true, - AllowPersonal: true, - PersonalRequireBYOK: false, - MaxConcurrent: 5, - DefaultMaxTokens: 4096, - DefaultMaxToolCalls: 10, - DefaultMaxWallClock: 300, - } -} - -// LoadTaskConfig reads task configuration from global_settings. -// Falls back to defaults for missing keys. -func LoadTaskConfig(ctx context.Context, gc store.GlobalConfigStore) TaskConfig { - cfg := DefaultTaskConfig() - if gc == nil { - return cfg - } - - raw, err := gc.Get(ctx, "tasks") - if err != nil || raw == nil { - return cfg - } - - if v, ok := boolVal(raw, "enabled"); ok { - cfg.Enabled = v - } - if v, ok := boolVal(raw, "allow_personal"); ok { - cfg.AllowPersonal = v - } - if v, ok := boolVal(raw, "personal_require_byok"); ok { - cfg.PersonalRequireBYOK = v - } - if v, ok := intVal(raw, "max_concurrent"); ok && v > 0 { - cfg.MaxConcurrent = v - } - if v, ok := intVal(raw, "default_max_tokens"); ok && v > 0 { - cfg.DefaultMaxTokens = v - } - if v, ok := intVal(raw, "default_max_tool_calls"); ok && v > 0 { - cfg.DefaultMaxToolCalls = v - } - if v, ok := intVal(raw, "default_max_wall_clock"); ok && v > 0 { - cfg.DefaultMaxWallClock = v - } - - return cfg -} - -// ApplyDefaults fills zero-value budget fields on a task with the global defaults. -func (tc TaskConfig) ApplyDefaults(t *models.Task) { - if t.MaxTokens == 0 { - t.MaxTokens = tc.DefaultMaxTokens - } - if t.MaxToolCalls == 0 { - t.MaxToolCalls = tc.DefaultMaxToolCalls - } - if t.MaxWallClock == 0 { - t.MaxWallClock = tc.DefaultMaxWallClock - } -} - -// ── helpers ──────────────────────────────────── - -func boolVal(m models.JSONMap, key string) (bool, bool) { - v, ok := m[key] - if !ok { - return false, false - } - b, ok := v.(bool) - return b, ok -} - -func intVal(m models.JSONMap, key string) (int, bool) { - v, ok := m[key] - if !ok { - return 0, false - } - // JSON numbers are float64 after Unmarshal - switch n := v.(type) { - case float64: - return int(n), true - case int: - return n, true - default: - log.Printf("[task_config] unexpected type for %s: %T", key, v) - return 0, false - } -}