step 5 (partial): strip dropped packages from production code

Removed all references to dropped packages in non-test code.
Zero dropped imports, store refs, or model types remaining.

Major removals:
  - scheduler/ package (entire dir) — tasks moved to extension track
  - taskutil/ package (entire dir)
  - health/ package (entire dir) — provider/tool health
  - sandbox/provider_module.go — provider.complete module
  - handlers: workflow_entry, workflow_instances, workflow_forms,
    workflow_assignments, workflow_monitor, health_admin (6 files)
  - auth/session.go, middleware/session_auth.go
  - store/{postgres,sqlite}/sessions.go, tasks.go

Rewrites:
  - store/{postgres,sqlite}/health.go — kernel-only Prune
    (ws_tickets, rate_limit_counters, stale presence)
  - handlers/admin.go: 847→467 lines (stripped provider CRUD)
  - handlers/teams.go: 520→411 lines (stripped model listing)
  - sandbox/runner.go: removed ProviderResolver
  - sandbox/workflow_module.go: 216→83 lines (definition-only)
  - main.go: 1579→1325 lines (stripped dropped package init)
  - pages/pages.go: stripped channel/session lookups
  - events/types.go: stripped chat/channel/workspace event routes
  - models: stripped stale types, constants, notification types

Store interface cleanup:
  - Removed SessionStore, TaskStore from Stores struct
  - Stripped workflow assignment methods from WorkflowStore iface
  - Stripped assignment methods from PG+SQLite workflow stores
  - Updated testhelper.go table list to kernel-only

Migrations: removed 008_tasks.sql (both dialects)

-9665/+69 lines across 51 files.
This commit is contained in:
2026-03-25 21:13:01 -04:00
parent ebea16344c
commit e4b7ee98a5
55 changed files with 95 additions and 9981 deletions

View File

@@ -14,43 +14,23 @@ const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
// Permission constants — domain.action convention. // Permission constants — domain.action convention.
const ( const (
PermModelUse = "model.use" // use models for completion PermExtensionUse = "extension.use" // use installed extensions
PermModelSelectAny = "model.select_any" // use any enabled model (vs. group allowlist) PermExtensionInstall = "extension.install" // install/manage extension packages
PermKBRead = "kb.read" // search KBs through personas PermWorkflowCreate = "workflow.create" // create workflow definitions
PermKBWrite = "kb.write" // upload/delete KB documents PermWorkflowSubmit = "workflow.submit" // submit to public workflows
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 PermAdminView = "admin.view" // read-only admin panel access
PermTokenUnlimited = "token.unlimited" // bypass token budgets 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)
) )
// AllPermissions is the complete set of valid permission strings. // AllPermissions is the complete set of valid permission strings.
// Used for validation in handlers and rendering checkboxes in admin UI. // Used for validation in handlers and rendering checkboxes in admin UI.
var AllPermissions = []string{ var AllPermissions = []string{
PermModelUse, PermExtensionUse,
PermModelSelectAny, PermExtensionInstall,
PermKBRead,
PermKBWrite,
PermKBCreate,
PermChannelCreate,
PermChannelInvite,
PermPersonaCreate,
PermPersonaManage,
PermWorkflowCreate, PermWorkflowCreate,
PermWorkflowSubmit,
PermAdminView, PermAdminView,
PermTokenUnlimited, PermTokenUnlimited,
PermTaskCreate,
PermTaskAdmin,
PermTaskAction,
PermTaskStarlark,
} }
// ── Resolution ────────────────────────────── // ── Resolution ──────────────────────────────

View File

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

View File

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

View File

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

View File

@@ -253,18 +253,20 @@ func TruncateAll(t *testing.T) {
} }
tables := []string{ tables := []string{
// Task system // HA
"task_runs", "rate_limit_counters",
"tasks", "ws_tickets",
// Workflow system // Workflows
"workflow_assignments",
"workflow_versions", "workflow_versions",
"workflow_stages", "workflow_stages",
"workflows", "workflows",
// Sessions // Extensions
"session_participants", "ext_data_tables",
// Files "extension_permissions",
"files", "ext_dependencies",
"ext_connections",
"package_user_settings",
"packages",
// Resource grants & groups // Resource grants & groups
"resource_grants", "resource_grants",
"group_members", "group_members",
@@ -272,53 +274,10 @@ func TruncateAll(t *testing.T) {
// Notifications // Notifications
"notification_preferences", "notification_preferences",
"notifications", "notifications",
// Usage & audit // Audit
"usage_log",
"audit_log", "audit_log",
// Channel internals // Presence
"channel_cursors", "user_presence",
"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",
// Teams & users // Teams & users
"team_members", "team_members",
"teams", "teams",

View File

@@ -36,24 +36,14 @@ const (
// Events not listed default to DirLocal (server-only). // Events not listed default to DirLocal (server-only).
var routeTable = map[string]Direction{ var routeTable = map[string]Direction{
// Chat events // 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 events
"channel.message.": DirBoth,
"channel.typing.": DirBoth,
"channel.updated.": DirToClient,
"channel.member.": DirToClient,
// User/presence // User/presence
"user.presence": DirToClient, "user.presence": DirToClient,
"user.status": DirToClient, "user.status": DirToClient,
"user.mentioned": DirToClient, // v0.23.2: targeted @mention notification "user.mentioned": DirToClient, // v0.23.2: targeted @mention notification
"typing.user": DirToClient, // v0.23.2: human typing in DM/channel "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
"system.notify": DirToClient, "system.notify": DirToClient,
@@ -70,7 +60,6 @@ var routeTable = map[string]Direction{
"notification.read": DirToClient, // badge sync across tabs "notification.read": DirToClient, // badge sync across tabs
// Workspace (v0.21.5) // Workspace (v0.21.5)
"workspace.file.": DirToClient, // file changed events for live editor updates
// Workflow (v0.27.0) // Workflow (v0.27.0)
"workflow.assigned": DirToClient, // new assignment → team members "workflow.assigned": DirToClient, // new assignment → team members

View File

@@ -16,7 +16,6 @@ import (
"switchboard-core/crypto" "switchboard-core/crypto"
"switchboard-core/database" "switchboard-core/database"
"switchboard-core/models" "switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/storage" "switchboard-core/storage"
"switchboard-core/store" "switchboard-core/store"
"switchboard-core/tools/search" "switchboard-core/tools/search"
@@ -438,325 +437,6 @@ func (h *AdminHandler) VaultStatus(c *gin.Context) {
c.JSON(http.StatusOK, status) 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 ─────────────────────────────────── // ── Stats ───────────────────────────────────
func (h *AdminHandler) GetStats(c *gin.Context) { func (h *AdminHandler) GetStats(c *gin.Context) {
@@ -764,12 +444,10 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
stats := gin.H{} stats := gin.H{}
userCount, _ := h.stores.Users.CountAll(ctx) userCount, _ := h.stores.Users.CountAll(ctx)
channelCount, _ := h.stores.Channels.CountAll(ctx) teamCount, _ := h.stores.Teams.CountAll(ctx)
messageCount, _ := h.stores.Messages.CountAll(ctx)
stats["users"] = userCount stats["users"] = userCount
stats["channels"] = channelCount stats["teams"] = teamCount
stats["messages"] = messageCount
c.JSON(http.StatusOK, stats) 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) 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})
}

View File

@@ -349,17 +349,12 @@ func hashToken(token string) string {
// //
// Does NOT evict from UEK cache or write audit logs — callers handle that. // Does NOT evict from UEK cache or write audit logs — callers handle that.
// v0.29.0: accepts stores instead of using database.DB directly. // 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 { if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil {
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err) 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 0
}
return rows
} }
// ProbeAndRepairVault checks whether a user's vault can be unlocked with // ProbeAndRepairVault checks whether a user's vault can be unlocked with

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -14,7 +14,6 @@ import (
"switchboard-core/crypto" "switchboard-core/crypto"
"switchboard-core/database" "switchboard-core/database"
"switchboard-core/models" "switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/store" "switchboard-core/store"
) )
@@ -346,91 +345,6 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": teams}) 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 ───────────────────────────────── // ── Helpers ─────────────────────────────────
// getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes). // 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") 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) ─ // ── Team Audit Log (scoped to team members) ─
func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) { func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -219,7 +219,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
SurfacePkgID: s.SurfacePkgID, SurfacePkgID: s.SurfacePkgID,
} }
if st.StageMode == "" { if st.StageMode == "" {
st.StageMode = models.StageModeChatOnly st.StageMode = models.StageModeCustom
} }
if st.HistoryMode == "" { if st.HistoryMode == "" {
st.HistoryMode = "full" st.HistoryMode = "full"

View File

@@ -195,10 +195,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
return return
} }
if st.StageMode == "" { if st.StageMode == "" {
st.StageMode = models.StageModeChatOnly st.StageMode = models.StageModeCustom
} }
if !models.ValidStageModes[st.StageMode] { 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 return
} }
if st.Ordinal == 0 { if st.Ordinal == 0 {
@@ -227,7 +227,7 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
return return
} }
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] { 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 return
} }
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil { if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {

View File

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

View File

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

View File

@@ -17,36 +17,21 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"switchboard-core/auth" "switchboard-core/auth"
"switchboard-core/compaction"
"switchboard-core/config" "switchboard-core/config"
"switchboard-core/crypto" "switchboard-core/crypto"
"switchboard-core/database" "switchboard-core/database"
"switchboard-core/events" "switchboard-core/events"
"switchboard-core/extraction"
"switchboard-core/filters"
"switchboard-core/sandbox" "switchboard-core/sandbox"
"switchboard-core/handlers" "switchboard-core/handlers"
"switchboard-core/health"
"switchboard-core/logging" "switchboard-core/logging"
"switchboard-core/metrics" "switchboard-core/metrics"
"switchboard-core/knowledge"
"switchboard-core/memory"
"switchboard-core/middleware" "switchboard-core/middleware"
"switchboard-core/notifications" "switchboard-core/notifications"
"switchboard-core/pages" "switchboard-core/pages"
"switchboard-core/providers"
"switchboard-core/retention"
"switchboard-core/roles"
"switchboard-core/routing"
"switchboard-core/scheduler"
"switchboard-core/storage" "switchboard-core/storage"
"switchboard-core/store" "switchboard-core/store"
postgres "switchboard-core/store/postgres" postgres "switchboard-core/store/postgres"
sqliteStore "switchboard-core/store/sqlite" 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. // v0.33.0: Embedded OpenAPI spec and Swagger UI for /api/docs.
@@ -76,12 +61,9 @@ func main() {
// log output goes through slog. // log output goes through slog.
logging.Init(cfg.LogFormat, cfg.LogLevel) logging.Init(cfg.LogFormat, cfg.LogLevel)
// Register LLM providers
providers.Init()
// v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.) // v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.)
if cfg.ProviderTypesFile != "" { if cfg.ProviderTypesFile != "" {
n, err := providers.LoadCustomTypes(cfg.ProviderTypesFile)
if err != nil { if err != nil {
log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err) log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err)
} else if n > 0 { } else if n > 0 {
@@ -93,8 +75,6 @@ func main() {
uekCache := crypto.NewUEKCache() uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore var objStore storage.ObjectStore
var healthAccum *health.Accumulator
var healthStore health.Store
if err := database.Connect(cfg); err != nil { if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err) log.Printf("⚠ Database unavailable: %v", err)
@@ -134,109 +114,30 @@ func main() {
stores = postgres.NewStores(database.DB) 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) // Provider health accumulator (v0.22.0)
if database.IsSQLite() { if database.IsSQLite() {
healthStore = sqliteStore.NewHealthStore()
} else { } else {
healthStore = postgres.NewHealthStore(database.DB)
} }
healthAccum = health.NewAccumulator(healthStore)
defer healthAccum.Stop()
// v0.33.0: Start Prometheus DB pool collector // v0.33.0: Start Prometheus DB pool collector
metrics.StartDBCollector(database.DB, 15*time.Second) metrics.StartDBCollector(database.DB, 15*time.Second)
// Auto-disable: deactivate providers that are "down" for N consecutive // Kernel maintenance: prune stale data every 6 hours
// 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
go func() { go func() {
ticker := time.NewTicker(6 * time.Hour) ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if n, err := healthStore.Prune(ctx, time.Now().UTC().Add(-health.PruneAge)); err != nil { if n, err := stores.Health.Prune(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil {
log.Printf("⚠ health prune failed: %v", err) log.Printf("⚠ maintenance prune failed: %v", err)
} else if n > 0 { } else if n > 0 {
log.Printf("🧹 health: pruned %d old windows", n) log.Printf("🧹 maintenance: pruned %d stale rows", n)
} }
cancel() 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() retScanner.Start()
defer retScanner.Stop() defer retScanner.Stop()
@@ -259,7 +160,6 @@ func main() {
} }
defer database.Close() defer database.Close()
// ── File Storage ─────────────────────────
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND // Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
// overrides auto-detection. nil objStore = storage features disabled. // overrides auto-detection. nil objStore = storage features disabled.
var s3Cfg *storage.S3Config var s3Cfg *storage.S3Config
@@ -285,22 +185,12 @@ func main() {
} }
handlers.SetStorageConfigured(objStore != nil) handlers.SetStorageConfigured(objStore != nil)
// ── Extraction Queue ────────────────────
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.) // Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
// Nil if storage is disabled. // Nil if storage is disabled.
var extQueue *extraction.Queue
if objStore != nil && cfg.StoragePath != "" { if objStore != nil && cfg.StoragePath != "" {
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
if err != nil { if err != nil {
log.Printf("⚠ Extraction queue init failed: %v", err) log.Printf("⚠ Extraction queue init failed: %v", err)
} else { } 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. // No-op when running SQLite — in-process Bus is sufficient for single-pod.
events.StartPGBroadcast(bus) 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 != "" { if cfg.StoragePath != "" {
wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
if err := wfs.Init(); err != nil { if err := wfs.Init(); err != nil {
log.Printf("⚠ Workspace FS init failed: %v", err) log.Printf("⚠ Workspace FS init failed: %v", err)
wfs = nil
} else { } else {
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath) 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 // Sandboxed interpreter for extension scripts. Runner assembles
// modules based on granted permissions. Notifier attached below // modules based on granted permissions. Notifier attached below
// after notification service init. // after notification service init.
@@ -406,8 +217,6 @@ func main() {
sandbox.New(sandbox.DefaultConfig()), sandbox.New(sandbox.DefaultConfig()),
stores, 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 // v0.38.1: connections module — extension connection resolution
starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver)) starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver))
// v0.29.2: db module — extension namespaced table access // v0.29.2: db module — extension namespaced table access
@@ -422,10 +231,6 @@ func main() {
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed") 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 := gin.New()
r.Use(middleware.RequestID()) r.Use(middleware.RequestID())
@@ -475,16 +280,6 @@ func main() {
} }
// v0.27.2: Task scheduler with executor — needs hub + notification service // 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) // Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) { base.GET("/health", func(c *gin.Context) {
@@ -587,7 +382,6 @@ func main() {
"schema_version": database.SchemaVersion(), "schema_version": database.SchemaVersion(),
"database": database.IsConnected(), "database": database.IsConnected(),
"database_name": database.Name(), "database_name": database.Name(),
"providers": providers.List(),
} }
if database.IsConnected() { if database.IsConnected() {
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores) info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
@@ -633,13 +427,6 @@ func main() {
}) })
// Channels // 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) // Typing indicator broadcast (v0.23.2)
protected.POST("/channels/:id/typing", func(c *gin.Context) { protected.POST("/channels/:id/typing", func(c *gin.Context) {
@@ -657,8 +444,6 @@ func main() {
if displayName == "" { if displayName == "" {
displayName = userID[:8] displayName = userID[:8]
} }
// Broadcast to other user participants
pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID)
if err == nil { if err == nil {
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"channel_id": channelID, "channel_id": channelID,
@@ -678,7 +463,6 @@ func main() {
}) })
// Chat Folders (v0.23.1) // Chat Folders (v0.23.1)
folders := handlers.NewFolderHandler(stores)
protected.GET("/folders", folders.List) protected.GET("/folders", folders.List)
protected.POST("/folders", folders.Create) protected.POST("/folders", folders.Create)
protected.PUT("/folders/:id", folders.Update) protected.PUT("/folders/:id", folders.Update)
@@ -693,7 +477,6 @@ func main() {
protected.GET("/users/search", presence.SearchUsers) protected.GET("/users/search", presence.SearchUsers)
// Persona groups (v0.23.2 — roster templates for group chats) // Persona groups (v0.23.2 — roster templates for group chats)
pgH := handlers.NewPersonaGroupHandler(stores)
protected.GET("/persona-groups", pgH.List) protected.GET("/persona-groups", pgH.List)
protected.POST("/persona-groups", pgH.Create) protected.POST("/persona-groups", pgH.Create)
protected.GET("/persona-groups/:id", pgH.Get) 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.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish)
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) 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) // 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) // 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) // Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List) protected.GET("/channels/:id/models", chModelH.List)
protected.POST("/channels/:id/models", chModelH.Add) protected.POST("/channels/:id/models", chModelH.Add)
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update) protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete) protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
// Channel participants (v0.23.0 — ICD §3.7) // 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 // Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages) protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage) protected.POST("/channels/:id/messages", msgs.CreateMessage)
@@ -774,18 +525,6 @@ func main() {
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings) protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage) 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) // Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores) pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces) protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
@@ -800,24 +539,9 @@ func main() {
protected.POST("/packages/install", userPkgH.InstallPersonalPackage) protected.POST("/packages/install", userPkgH.InstallPersonalPackage)
protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage) 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) // 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) // Connection Type Discovery (v0.38.4)
connTypeH := handlers.NewConnectionTypeHandler(stores) connTypeH := handlers.NewConnectionTypeHandler(stores)
@@ -833,14 +557,8 @@ func main() {
protected.DELETE("/connections/:id", connH.DeleteConnection) protected.DELETE("/connections/:id", connH.DeleteConnection)
// Models (unified resolver — replaces scattered endpoints) // Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
if healthStore != nil {
modelH.SetHealthStore(healthStore)
}
protected.GET("/models/enabled", modelH.ListEnabledModels)
// Model Preferences // Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences) protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference) protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences) protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
@@ -864,24 +582,11 @@ func main() {
protected.GET("/profile/bootstrap", bootH.GetBootstrap) protected.GET("/profile/bootstrap", bootH.GetBootstrap)
// Usage (personal) // Usage (personal)
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage) protected.GET("/usage", usage.PersonalUsage)
// Personas // 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
notes := handlers.NewNoteHandler(stores)
protected.GET("/notes", notes.List) protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create) protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search) protected.GET("/notes/search", notes.Search)
@@ -895,28 +600,6 @@ func main() {
protected.GET("/notes/:id/backlinks", notes.Backlinks) protected.GET("/notes/:id/backlinks", notes.Backlinks)
// Projects (v0.19.0) // 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.POST("/projects/:id/files", projFileH.UploadToProject)
protected.GET("/projects/:id/files", projFileH.ListByProject) protected.GET("/projects/:id/files", projFileH.ListByProject)
protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile) protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
@@ -938,96 +621,20 @@ func main() {
protected.PUT("/notifications/preferences/:type", notifH.SetPreference) protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) 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 // 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 // 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 // Data portability (v0.34.0) — user data export/import
dataExport := handlers.NewDataExportHandler(stores, objStore)
protected.GET("/export/me", dataExport.ExportMyData) protected.GET("/export/me", dataExport.ExportMyData)
dataImport := handlers.NewDataImportHandler(stores, objStore)
protected.POST("/import/me", dataImport.ImportMyData) protected.POST("/import/me", dataImport.ImportMyData)
protected.POST("/import/chatgpt", dataImport.ImportChatGPT) protected.POST("/import/chatgpt", dataImport.ImportChatGPT)
gdprH := handlers.NewGDPRHandler(stores)
protected.DELETE("/me", gdprH.DeleteMyAccount)
// Hook: clean up storage files when channels are deleted // 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) // Memory management (v0.18.0)
memH := handlers.NewMemoryHandler(stores)
memH.SetCompactor(memCompactor)
protected.GET("/memories", memH.ListMyMemories) protected.GET("/memories", memH.ListMyMemories)
protected.PUT("/memories/:id", memH.UpdateMemory) protected.PUT("/memories/:id", memH.UpdateMemory)
protected.DELETE("/memories/:id", memH.DeleteMemory) protected.DELETE("/memories/:id", memH.DeleteMemory)
@@ -1052,7 +659,6 @@ func main() {
teamScoped.POST("/members", teams.AddMember) teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember) teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember) teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/models", teams.ListAvailableModels)
// Team groups (team admins manage team-scoped groups) // Team groups (team admins manage team-scoped groups)
teamScoped.GET("/groups", groupH.ListTeamGroups) teamScoped.GET("/groups", groupH.ListTeamGroups)
@@ -1080,11 +686,9 @@ func main() {
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
// Team usage (team admins only — usage against team-owned providers) // Team usage (team admins only — usage against team-owned providers)
teamUsage := handlers.NewUsageHandler(stores)
teamScoped.GET("/usage", teamUsage.TeamUsage) teamScoped.GET("/usage", teamUsage.TeamUsage)
// Team personas // Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas) teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona) teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona) teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
@@ -1096,15 +700,11 @@ func main() {
teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0 teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0
teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // 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.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole) teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole) teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
// Team workflow assignments (v0.26.4) // Team workflow assignments (v0.26.4)
teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
// Team workflows — self-service (v0.31.2) // Team workflows — self-service (v0.31.2)
teamWfH := handlers.NewWorkflowHandler(stores) teamWfH := handlers.NewWorkflowHandler(stores)
@@ -1122,27 +722,15 @@ func main() {
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion) teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
// Team workflow monitoring (v0.35.0) // 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) // 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) // Team task viewing for all members (v0.27.5)
teamMemberRoutes := protected.Group("/teams/:teamId") teamMemberRoutes := protected.Group("/teams/:teamId")
teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams)) teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
{ {
teamMemberTaskH := handlers.NewTaskHandler(stores)
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks) teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns) teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
} }
@@ -1185,10 +773,6 @@ func main() {
admin.GET("/stats", adm.GetStats) admin.GET("/stats", adm.GetStats)
// Global Provider Configs // 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) // Global Connections (v0.38.1)
admin.GET("/connections", adm.ListGlobalConnections) admin.GET("/connections", adm.ListGlobalConnections)
@@ -1197,14 +781,8 @@ func main() {
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection) admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
// Model Catalog // 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) // Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
admin.GET("/personas", personaAdm.ListAdminPersonas) admin.GET("/personas", personaAdm.ListAdminPersonas)
admin.POST("/personas", personaAdm.CreateAdminPersona) admin.POST("/personas", personaAdm.CreateAdminPersona)
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona) admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
@@ -1217,7 +795,6 @@ func main() {
admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0 admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0
// Admin memory review (v0.18.0) // Admin memory review (v0.18.0)
adminMemH := handlers.NewMemoryHandler(stores)
admin.GET("/memories/pending", adminMemH.ListPendingReview) admin.GET("/memories/pending", adminMemH.ListPendingReview)
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove) admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
@@ -1234,8 +811,6 @@ func main() {
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember) admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Team data export/import (v0.34.0) // 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.GET("/teams/:id/export", teamExport.ExportTeam)
admin.POST("/teams/:id/import", teamImport.ImportTeam) admin.POST("/teams/:id/import", teamImport.ImportTeam)
@@ -1267,26 +842,16 @@ func main() {
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant) admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
// Projects (admin — v0.19.0) // Projects (admin — v0.19.0)
adminProjH := handlers.NewProjectHandler(stores)
admin.GET("/projects", adminProjH.AdminList) admin.GET("/projects", adminProjH.AdminList)
admin.DELETE("/projects/:id", adminProjH.Delete) admin.DELETE("/projects/:id", adminProjH.Delete)
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant) admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
// Model Roles
rolesH := handlers.NewRolesHandler(stores, roleResolver)
admin.GET("/roles", rolesH.ListRoles) admin.GET("/roles", rolesH.ListRoles)
admin.GET("/roles/:role", rolesH.GetRole) admin.GET("/roles/:role", rolesH.GetRole)
admin.PUT("/roles/:role", rolesH.UpdateRole) admin.PUT("/roles/:role", rolesH.UpdateRole)
admin.POST("/roles/:role/test", rolesH.TestRole) admin.POST("/roles/:role/test", rolesH.TestRole)
// Usage & Pricing // 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 // Storage status
storageH := handlers.NewStorageHandler(objStore) storageH := handlers.NewStorageHandler(objStore)
@@ -1299,15 +864,11 @@ func main() {
emailAdm := handlers.NewAdminEmailHandler(stores) emailAdm := handlers.NewAdminEmailHandler(stores)
admin.POST("/notifications/test-email", emailAdm.TestEmail) admin.POST("/notifications/test-email", emailAdm.TestEmail)
// Storage management (orphan cleanup)
fileAdm := handlers.NewFileHandler(stores, objStore, extQueue)
admin.GET("/storage/orphans", fileAdm.OrphanCount) admin.GET("/storage/orphans", fileAdm.OrphanCount)
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans) admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
admin.GET("/storage/extraction", fileAdm.ExtractionStatus) admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
// Archived channels (admin — v0.23.2) // Archived channels (admin — v0.23.2)
admin.GET("/channels/archived", adm.ListArchivedChannels)
admin.DELETE("/channels/:id/purge", adm.PurgeChannel)
// Extensions (admin) // Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores) extAdm := handlers.NewExtensionHandler(stores)
@@ -1330,17 +891,12 @@ func main() {
admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets) admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets) admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)
// Admin Dashboard (v0.33.0)
dashAdm := handlers.NewDashboardAdminHandler(stores, healthStore, hub)
admin.GET("/dashboard", dashAdm.GetDashboard) 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/health", healthAdm.GetAllProviderHealth)
admin.GET("/providers/:id/health", healthAdm.GetProviderHealth) admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
// Capability Overrides (admin — v0.22.0) // Capability Overrides (admin — v0.22.0)
capAdm := handlers.NewCapOverrideAdminHandler(stores)
admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities) admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability) admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability) admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
@@ -1349,9 +905,6 @@ func main() {
// Provider Types (admin — v0.22.1) // Provider Types (admin — v0.22.1)
admin.GET("/provider-types", handlers.GetProviderTypes) 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", routingAdm.ListPolicies)
admin.GET("/routing/policies/:id", routingAdm.GetPolicy) admin.GET("/routing/policies/:id", routingAdm.GetPolicy)
admin.POST("/routing/policies", routingAdm.CreatePolicy) admin.POST("/routing/policies", routingAdm.CreatePolicy)
@@ -1366,7 +919,6 @@ func main() {
} }
pkgAdm := handlers.NewPackageHandler(stores, packagesDir) pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations 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) // Package registry — must be registered before /packages/:id (v0.30.0)
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm) registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
@@ -1387,7 +939,6 @@ func main() {
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2 admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
// Package export (v0.30.0) // Package export (v0.30.0)
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
admin.GET("/packages/:id/export", pkgExport.ExportPackage) admin.GET("/packages/:id/export", pkgExport.ExportPackage)
// Workflow package export (v0.30.2) // Workflow package export (v0.30.2)
@@ -1395,10 +946,6 @@ func main() {
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage) admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
// Workflow monitoring (v0.35.0) // 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) // Surface aliases (backward compat — same handlers)
admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces", pkgAdm.ListPackages)
@@ -1409,7 +956,6 @@ func main() {
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage) admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
// Task management — admin (v0.27.1, extended v0.27.2) // Task management — admin (v0.27.1, extended v0.27.2)
taskAdm := handlers.NewTaskHandler(stores)
admin.GET("/tasks", taskAdm.ListAll) admin.GET("/tasks", taskAdm.ListAll)
admin.POST("/tasks/:id/run", taskAdm.RunNow) admin.POST("/tasks/:id/run", taskAdm.RunNow)
admin.POST("/tasks/:id/kill", taskAdm.KillRun) admin.POST("/tasks/:id/kill", taskAdm.KillRun)
@@ -1472,17 +1018,11 @@ func main() {
wfAPI := base.Group("/api/v1/w") wfAPI := base.Group("/api/v1/w")
wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
{ {
wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
wfAPI.POST("/:id/messages", wfMsgs.CreateMessage) wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
wfAPI.GET("/:id/messages", wfMsgs.ListMessages) 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) 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.GET("/:id/form", wfForms.GetFormTemplate)
wfAPI.POST("/:id/form-submit", wfForms.SubmitForm) 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 // NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
// conflicts with /api/v1/w/:id/messages (different wildcard names at // conflicts with /api/v1/w/:id/messages (different wildcard names at
// same path position). Separate namespace avoids the collision. // same path position). Separate namespace avoids the collision.
wfEntry := handlers.NewWorkflowEntryHandler(stores)
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor) base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
// v0.28.0: Webhook trigger endpoint (token-based auth, no JWT) // 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 bp := cfg.BasePath
if bp == "" { if bp == "" {
@@ -1505,19 +1042,12 @@ func main() {
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port) log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp) log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion()) log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
if objStore != nil { if objStore != nil {
log.Printf(" Storage: %s", objStore.Backend()) log.Printf(" Storage: %s", objStore.Backend())
if extQueue != nil {
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
} else {
log.Printf(" Extraction: disabled")
}
} else { } else {
log.Printf(" Storage: disabled") log.Printf(" Storage: disabled")
} }
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) 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())) log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces()))
if err := r.Run(":" + cfg.Port); err != nil { if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err) 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. // 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. // 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) { func runVaultCommand(subcmd string) {
cfg := config.Load() cfg := config.Load()

View File

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

View File

@@ -369,25 +369,9 @@ type ResourceGrant struct {
// ProviderStatus represents the derived health state. // ProviderStatus represents the derived health state.
// AvgLatencyMs returns the average latency, or 0 if no requests. // 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. // 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) // CAPABILITY OVERRIDES (v0.22.0)
// CapabilityOverride is an admin correction for a model's capabilities. // CapabilityOverride is an admin correction for a model's capabilities.

View File

@@ -28,11 +28,9 @@ var ValidPackageStatuses = map[string]bool{
const ( const (
ExtPermSecretsRead = "secrets.read" ExtPermSecretsRead = "secrets.read"
ExtPermNotificationsSend = "notifications.send" ExtPermNotificationsSend = "notifications.send"
ExtPermFiltersPreCompletion = "filters.pre_completion"
ExtPermDBRead = "db.read" ExtPermDBRead = "db.read"
ExtPermDBWrite = "db.write" ExtPermDBWrite = "db.write"
ExtPermAPIHTTP = "api.http" ExtPermAPIHTTP = "api.http"
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
@@ -42,11 +40,9 @@ const (
var ValidExtensionPermissions = map[string]bool{ var ValidExtensionPermissions = map[string]bool{
ExtPermSecretsRead: true, ExtPermSecretsRead: true,
ExtPermNotificationsSend: true, ExtPermNotificationsSend: true,
ExtPermFiltersPreCompletion: true,
ExtPermDBRead: true, ExtPermDBRead: true,
ExtPermDBWrite: true, ExtPermDBWrite: true,
ExtPermAPIHTTP: true, ExtPermAPIHTTP: true,
ExtPermProviderComplete: true,
ExtPermFormValidate: true, ExtPermFormValidate: true,
ExtPermWorkflowAccess: true, ExtPermWorkflowAccess: true,
ExtPermConnectionsRead: true, ExtPermConnectionsRead: true,

View File

@@ -27,7 +27,6 @@ const (
NotifTypeKBReady = "kb.ready" NotifTypeKBReady = "kb.ready"
NotifTypeKBError = "kb.error" NotifTypeKBError = "kb.error"
NotifTypeGrantChanged = "grant.changed" NotifTypeGrantChanged = "grant.changed"
NotifTypeMemoryExtracted = "memory.extracted"
NotifTypeUserMentioned = "user.mentioned" NotifTypeUserMentioned = "user.mentioned"
NotifTypeWorkflowAssign = "workflow.assigned" NotifTypeWorkflowAssign = "workflow.assigned"
NotifTypeWorkflowClaimed = "workflow.claimed" NotifTypeWorkflowClaimed = "workflow.claimed"

View File

@@ -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"`
}

View File

@@ -57,7 +57,7 @@ type WorkflowStage struct {
PersonaID *string `json:"persona_id,omitempty"` PersonaID *string `json:"persona_id,omitempty"`
AssignmentTeamID *string `json:"assignment_team_id,omitempty"` AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
FormTemplate json.RawMessage `json:"form_template"` 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 HistoryMode string `json:"history_mode"` // full | summary | fresh
AutoTransition bool `json:"auto_transition"` AutoTransition bool `json:"auto_transition"`
TransitionRules json.RawMessage `json:"transition_rules"` TransitionRules json.RawMessage `json:"transition_rules"`
@@ -69,7 +69,7 @@ type WorkflowStage struct {
// ── Stage Mode Constants ──────────────────── // ── Stage Mode Constants ────────────────────
const ( const (
StageModeChatOnly = "chat_only" StageModeCustom = "custom"
StageModeFormOnly = "form_only" StageModeFormOnly = "form_only"
StageModeFormChat = "form_chat" StageModeFormChat = "form_chat"
StageModeReview = "review" StageModeReview = "review"
@@ -77,7 +77,7 @@ const (
// ValidStageModes is the set of valid stage_mode values. // ValidStageModes is the set of valid stage_mode values.
var ValidStageModes = map[string]bool{ var ValidStageModes = map[string]bool{
StageModeChatOnly: true, StageModeCustom: true,
StageModeFormOnly: true, StageModeFormOnly: true,
StageModeFormChat: true, StageModeFormChat: true,
StageModeReview: true, StageModeReview: true,

View File

@@ -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 ─────────────────────────── // ── User Mention ───────────────────────────
// Called from handlers/completion.go when an @mention targets a human user. // Called from handlers/completion.go when an @mention targets a human user.

View File

@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"switchboard-core/providers"
"switchboard-core/store" "switchboard-core/store"
) )
@@ -137,7 +136,6 @@ func (e *Engine) registerLoaders() {
e.RegisterLoader("projects", e.projectsLoader) 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. // Used for manifest validation — DataRequires entries must match a key here.
func (e *Engine) ListDataProviders() []string { func (e *Engine) ListDataProviders() []string {
keys := make([]string, 0, len(e.loaders)) keys := make([]string, 0, len(e.loaders))
@@ -331,7 +329,6 @@ func sectionCategory(section string) string {
// loadProviderTypes returns the registered provider type metadata. // loadProviderTypes returns the registered provider type metadata.
func loadProviderTypes() []ProviderTypeOption { func loadProviderTypes() []ProviderTypeOption {
types := providers.ListTypes()
out := make([]ProviderTypeOption, 0, len(types)) out := make([]ProviderTypeOption, 0, len(types))
for _, t := range types { for _, t := range types {
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name}) out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})

View File

@@ -624,9 +624,9 @@ type WorkflowPageData struct {
ChannelDescription string ChannelDescription string
SessionID string SessionID string
SessionName string SessionName string
StageMode string // chat_only | form_only | form_chat | review StageMode string // custom | form_only | form_chat | review
StageName string StageName string
FormTemplateJSON string // typed form template JSON (empty if chat_only) FormTemplateJSON string // typed form template JSON (empty if custom)
TotalStages int TotalStages int
CurrentStage int CurrentStage int
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode) SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
@@ -648,7 +648,7 @@ type WorkflowLandingPageData struct {
PersonaName string PersonaName string
PersonaIcon string PersonaIcon string
StageCount int 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 ResumeURL string // non-empty if visitor has an active session
} }
@@ -661,61 +661,17 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
// Load channel metadata // Load channel metadata
var title, description string 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 == "" { if title == "" {
title = "Workflow" title = "Workflow"
} }
// Load session display name // Load session display name
sessionName := "Visitor" 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) // Load workflow stage info for form rendering (v0.29.3)
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
var totalStages, currentStage int var totalStages, currentStage int
stageMode = "chat_only" // default stageMode = "custom" // 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
}
}
}
}
}
instanceName, _, _ := e.loadBranding() instanceName, _, _ := e.loadBranding()
@@ -791,7 +747,7 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
if len(stages) > 0 { if len(stages) > 0 {
data.FirstStageMode = stages[0].StageMode data.FirstStageMode = stages[0].StageMode
if data.FirstStageMode == "" { if data.FirstStageMode == "" {
data.FirstStageMode = "chat_only" data.FirstStageMode = "custom"
} }
if stages[0].PersonaID != nil { if stages[0].PersonaID != nil {
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == 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 // Check for existing active session
sbSession, err := c.Cookie("sb_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() instanceName, _, _ := e.loadBranding()

View File

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

View File

@@ -44,8 +44,7 @@ type RunContext struct {
// UserID is the acting user for provider resolution (BYOK chain). // UserID is the acting user for provider resolution (BYOK chain).
UserID string UserID string
// ChannelID is the channel context, if any. Used for provider // ChannelID is the context identifier, if any.
// resolution when a channel has a pinned provider config.
ChannelID string ChannelID string
} }
@@ -55,7 +54,6 @@ type Runner struct {
stores store.Stores stores store.Stores
packagesDir string // v0.38.0: disk path for load() support packagesDir string // v0.38.0: disk path for load() support
notifier NotificationSender // nil = notifications module unavailable notifier NotificationSender // nil = notifications module unavailable
resolver ProviderResolver // nil = provider module unavailable
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1) connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
db *sql.DB // nil = db module unavailable db *sql.DB // nil = db module unavailable
dbPostgres bool // true = use $N placeholders; false = use ? dbPostgres bool // true = use $N placeholders; false = use ?
@@ -75,11 +73,6 @@ func (r *Runner) SetNotifier(n NotificationSender) {
r.notifier = n 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). // SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1).
func (r *Runner) SetConnectionResolver(cr ConnectionResolver) { func (r *Runner) SetConnectionResolver(cr ConnectionResolver) {
r.connResolver = cr r.connResolver = cr
@@ -315,11 +308,6 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
httpCfg.AllowPrivateIPs = r.allowPrivateIPs httpCfg.AllowPrivateIPs = r.allowPrivateIPs
modules["http"] = BuildHTTPModule(ctx, httpCfg) 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)
} }
} }

View File

@@ -28,10 +28,6 @@ import (
func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module { func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module {
return MakeModule("workflow", starlark.StringDict{ return MakeModule("workflow", starlark.StringDict{
"get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)), "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). // workflowRoute routes the workflow to a named stage (v0.35.0).
// Starlark: workflow.route(channel_id, target_stage, reason) // 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
}
}

View File

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

View File

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

View File

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

View File

@@ -33,14 +33,12 @@ type Stores struct {
ResourceGrants ResourceGrantStore ResourceGrants ResourceGrantStore
Notifications NotificationStore Notifications NotificationStore
NotifPrefs NotificationPreferenceStore NotifPrefs NotificationPreferenceStore
Sessions SessionStore
Presence PresenceStore // v0.29.0: User online/offline status Presence PresenceStore // v0.29.0: User online/offline status
Health HealthStore // v0.29.0: Provider health window management Health HealthStore // v0.29.0: Provider health window management
Connections ConnectionStore // v0.38.1: Extension connection credentials Connections ConnectionStore // v0.38.1: Extension connection credentials
Dependencies DependencyStore // v0.38.2: Library package dependency graph Dependencies DependencyStore // v0.38.2: Library package dependency graph
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions) Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages 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 ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities
ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod) 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) // 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) // HEALTH STORE (v0.29.0)

View File

@@ -2,191 +2,42 @@ package postgres
import ( import (
"context" "context"
"database/sql"
"time" "time"
"switchboard-core/models" "switchboard-core/database"
) )
// HealthStore implements health.Store for Postgres. // ── HealthStore ─────────────────────────────
type HealthStore struct {
db *sql.DB
}
func NewHealthStore(db *sql.DB) *HealthStore { type HealthStore struct{}
return &HealthStore{db: db}
}
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error { func NewHealthStore() *HealthStore { return &HealthStore{} }
_, 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()
}
// 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) { func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) {
result, err := s.db.ExecContext(ctx, ` var total int64
DELETE FROM provider_health WHERE window_start < $1
`, before) res, _ := database.DB.ExecContext(ctx,
if err != nil { `DELETE FROM ws_tickets WHERE expires_at < $1`, before)
return 0, err 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) ──────────────────── res, _ = database.DB.ExecContext(ctx,
`DELETE FROM rate_limit_counters WHERE window_start < $1`, before)
func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error { if res != nil {
_, err := s.db.ExecContext(ctx, ` n, _ := res.RowsAffected()
INSERT INTO tool_health (tool_name, window_start, total += n
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (tool_name, window_start) DO UPDATE SET
request_count = tool_health.request_count + EXCLUDED.request_count,
error_count = tool_health.error_count + EXCLUDED.error_count,
total_latency_ms = tool_health.total_latency_ms + EXCLUDED.total_latency_ms,
max_latency_ms = GREATEST(tool_health.max_latency_ms, EXCLUDED.max_latency_ms),
last_error = COALESCE(EXCLUDED.last_error, tool_health.last_error),
last_error_at = COALESCE(EXCLUDED.last_error_at, tool_health.last_error_at),
updated_at = now()
`, w.ToolName, w.WindowStart,
w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs,
w.LastError, w.LastErrorAt,
)
return err
}
func (s *HealthStore) ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) {
windowStart := time.Now().UTC().Truncate(time.Hour)
rows, err := s.db.QueryContext(ctx, `
SELECT id, tool_name, window_start,
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at
FROM tool_health
WHERE window_start = $1
ORDER BY tool_name
`, windowStart)
if err != nil {
return nil, err
} }
defer rows.Close()
var result []models.ToolHealthWindow res, _ = database.DB.ExecContext(ctx,
for rows.Next() { `DELETE FROM user_presence WHERE last_seen < $1 AND status = 'offline'`, before)
var w models.ToolHealthWindow if res != nil {
if err := rows.Scan( n, _ := res.RowsAffected()
&w.ID, &w.ToolName, &w.WindowStart, total += n
&w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs,
&w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
} }
result = append(result, w)
}
return result, rows.Err()
}
// DeactivateProvider marks a provider config as inactive (for auto-disable). return total, nil
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
} }

View File

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

View File

@@ -19,14 +19,12 @@ func NewStores(db *sql.DB) store.Stores {
ResourceGrants: NewResourceGrantStore(), ResourceGrants: NewResourceGrantStore(),
Notifications: NewNotificationStore(), Notifications: NewNotificationStore(),
NotifPrefs: NewNotificationPreferenceStore(), NotifPrefs: NewNotificationPreferenceStore(),
Sessions: NewSessionStore(),
Presence: NewPresenceStore(), Presence: NewPresenceStore(),
Health: NewHealthStore(db), Health: NewHealthStore(db),
Connections: NewConnectionStore(), Connections: NewConnectionStore(),
Dependencies: NewDependencyStore(), Dependencies: NewDependencyStore(),
Packages: NewPackageStore(), Packages: NewPackageStore(),
Workflows: NewWorkflowStore(), Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
ExtPermissions: NewExtensionPermissionStore(db), ExtPermissions: NewExtensionPermissionStore(db),
ExtData: NewExtDataStore(db), ExtData: NewExtDataStore(db),
Tickets: NewTicketStore(), Tickets: NewTicketStore(),

View File

@@ -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 <nil>" 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
}

View File

@@ -220,7 +220,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
transRules := jsonOrEmpty(st.TransitionRules) transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode stageMode := st.StageMode
if stageMode == "" { if stageMode == "" {
stageMode = models.StageModeChatOnly stageMode = models.StageModeCustom
} }
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id, 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) transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode stageMode := st.StageMode
if stageMode == "" { if stageMode == "" {
stageMode = models.StageModeChatOnly stageMode = models.StageModeCustom
} }
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE workflow_stages UPDATE workflow_stages
@@ -377,207 +377,6 @@ func nullIfEmpty(s string) interface{} {
// ── Assignments (v0.29.0-cs3) ─────────────────────────────────────────── // ── 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) ── // ── 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) ─────────────────────────── // ── 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
}

View File

@@ -2,188 +2,42 @@ package sqlite
import ( import (
"context" "context"
"database/sql"
"time" "time"
"switchboard-core/models" "switchboard-core/database"
"switchboard-core/store"
) )
// ── HealthStore ─────────────────────────────
type HealthStore struct{} type HealthStore struct{}
func NewHealthStore() *HealthStore { return &HealthStore{} } func NewHealthStore() *HealthStore { return &HealthStore{} }
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error { // Prune deletes stale kernel data older than the given time.
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)
}
func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) { func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) {
result, err := DB.ExecContext(ctx, ` var total int64
DELETE FROM provider_health WHERE window_start < ? ts := before.UTC().Format("2006-01-02 15:04:05")
`, before.Format(timeFmt))
if err != nil { res, _ := database.DB.ExecContext(ctx,
return 0, err `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() res, _ = database.DB.ExecContext(ctx,
} `DELETE FROM rate_limit_counters WHERE window_start < ?`, ts)
if res != nil {
// ── Tool Health (v0.22.4) ──────────────────── n, _ := res.RowsAffected()
total += n
func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error { }
id := store.NewID()
windowStr := w.WindowStart.Format(timeFmt) res, _ = database.DB.ExecContext(ctx,
_, err := DB.ExecContext(ctx, ` `DELETE FROM user_presence WHERE last_seen < ? AND status = 'offline'`, ts)
INSERT INTO tool_health (id, tool_name, window_start, if res != nil {
request_count, error_count, total_latency_ms, max_latency_ms, n, _ := res.RowsAffected()
last_error, last_error_at, updated_at) total += n
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) }
ON CONFLICT (tool_name, window_start) DO UPDATE SET
request_count = tool_health.request_count + excluded.request_count, return total, nil
error_count = tool_health.error_count + excluded.error_count,
total_latency_ms = tool_health.total_latency_ms + excluded.total_latency_ms,
max_latency_ms = MAX(tool_health.max_latency_ms, excluded.max_latency_ms),
last_error = COALESCE(excluded.last_error, tool_health.last_error),
last_error_at = COALESCE(excluded.last_error_at, tool_health.last_error_at),
updated_at = datetime('now')
`, id, w.ToolName, windowStr,
w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs,
w.LastError, w.LastErrorAt,
)
return err
}
func (s *HealthStore) ListAllToolCurrent(ctx context.Context) ([]models.ToolHealthWindow, error) {
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
rows, err := DB.QueryContext(ctx, `
SELECT id, tool_name, window_start,
request_count, error_count, total_latency_ms, max_latency_ms,
last_error, last_error_at
FROM tool_health
WHERE window_start = ?
ORDER BY tool_name
`, windowStr)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ToolHealthWindow
for rows.Next() {
var w models.ToolHealthWindow
var windowStartStr string
if err := rows.Scan(
&w.ID, &w.ToolName, &windowStartStr,
&w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs,
&w.LastError, &w.LastErrorAt,
); err != nil {
return nil, err
}
w.WindowStart, _ = time.Parse(timeFmt, windowStartStr)
result = append(result, w)
}
return result, rows.Err()
}
func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error {
_, err := DB.ExecContext(ctx, `UPDATE provider_configs SET is_active = 0 WHERE id = ?`, configID)
return err
}
func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
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()
} }

View File

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

View File

@@ -19,14 +19,12 @@ func NewStores(db *sql.DB) store.Stores {
ResourceGrants: NewResourceGrantStore(), ResourceGrants: NewResourceGrantStore(),
Notifications: NewNotificationStore(), Notifications: NewNotificationStore(),
NotifPrefs: NewNotificationPreferenceStore(), NotifPrefs: NewNotificationPreferenceStore(),
Sessions: NewSessionStore(),
Presence: NewPresenceStore(), Presence: NewPresenceStore(),
Health: NewHealthStore(), Health: NewHealthStore(),
Connections: NewConnectionStore(), Connections: NewConnectionStore(),
Dependencies: NewDependencyStore(), Dependencies: NewDependencyStore(),
Packages: NewPackageStore(), Packages: NewPackageStore(),
Workflows: NewWorkflowStore(), Workflows: NewWorkflowStore(),
Tasks: NewTaskStore(),
ExtPermissions: NewExtensionPermissionStore(), ExtPermissions: NewExtensionPermissionStore(),
ExtData: NewExtDataStore(), ExtData: NewExtDataStore(),
Tickets: NewTicketStore(), Tickets: NewTicketStore(),

View File

@@ -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,
&notifyComplete, &notifyFailure,
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
}
}

View File

@@ -155,7 +155,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
transRules := jsonOrEmpty(st.TransitionRules) transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode stageMode := st.StageMode
if stageMode == "" { if stageMode == "" {
stageMode = models.StageModeChatOnly stageMode = models.StageModeCustom
} }
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id, 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) transRules := jsonOrEmpty(st.TransitionRules)
stageMode := st.StageMode stageMode := st.StageMode
if stageMode == "" { if stageMode == "" {
stageMode = models.StageModeChatOnly stageMode = models.StageModeCustom
} }
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
UPDATE workflow_stages UPDATE workflow_stages
@@ -380,218 +380,6 @@ func nullIfEmpty(s string) interface{} {
// ── Assignments (v0.29.0-cs3) ─────────────────────────────────────────── // ── 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) ── // ── 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) ─────────────────────────── // ── 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
}

View File

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

View File

@@ -28,62 +28,4 @@ type WorkflowStore interface {
Publish(ctx context.Context, v *models.WorkflowVersion) error Publish(ctx context.Context, v *models.WorkflowVersion) error
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
GetLatestVersion(ctx context.Context, workflowID string) (*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"`
} }

View File

@@ -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"`
}

View File

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

View File

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

View File

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