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:
@@ -14,43 +14,23 @@ const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// Permission constants — domain.action convention.
|
||||
const (
|
||||
PermModelUse = "model.use" // use models for completion
|
||||
PermModelSelectAny = "model.select_any" // use any enabled model (vs. group allowlist)
|
||||
PermKBRead = "kb.read" // search KBs through personas
|
||||
PermKBWrite = "kb.write" // upload/delete KB documents
|
||||
PermKBCreate = "kb.create" // create new knowledge bases
|
||||
PermChannelCreate = "channel.create" // create group/channel conversations
|
||||
PermChannelInvite = "channel.invite" // invite users to channels
|
||||
PermPersonaCreate = "persona.create" // create new personas
|
||||
PermPersonaManage = "persona.manage" // edit/delete team personas
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions (v0.25.0)
|
||||
PermExtensionUse = "extension.use" // use installed extensions
|
||||
PermExtensionInstall = "extension.install" // install/manage extension packages
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions
|
||||
PermWorkflowSubmit = "workflow.submit" // submit to public workflows
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
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.
|
||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
||||
var AllPermissions = []string{
|
||||
PermModelUse,
|
||||
PermModelSelectAny,
|
||||
PermKBRead,
|
||||
PermKBWrite,
|
||||
PermKBCreate,
|
||||
PermChannelCreate,
|
||||
PermChannelInvite,
|
||||
PermPersonaCreate,
|
||||
PermPersonaManage,
|
||||
PermExtensionUse,
|
||||
PermExtensionInstall,
|
||||
PermWorkflowCreate,
|
||||
PermWorkflowSubmit,
|
||||
PermAdminView,
|
||||
PermTokenUnlimited,
|
||||
PermTaskCreate,
|
||||
PermTaskAdmin,
|
||||
PermTaskAction,
|
||||
PermTaskStarlark,
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -253,18 +253,20 @@ func TruncateAll(t *testing.T) {
|
||||
}
|
||||
|
||||
tables := []string{
|
||||
// Task system
|
||||
"task_runs",
|
||||
"tasks",
|
||||
// Workflow system
|
||||
"workflow_assignments",
|
||||
// HA
|
||||
"rate_limit_counters",
|
||||
"ws_tickets",
|
||||
// Workflows
|
||||
"workflow_versions",
|
||||
"workflow_stages",
|
||||
"workflows",
|
||||
// Sessions
|
||||
"session_participants",
|
||||
// Files
|
||||
"files",
|
||||
// Extensions
|
||||
"ext_data_tables",
|
||||
"extension_permissions",
|
||||
"ext_dependencies",
|
||||
"ext_connections",
|
||||
"package_user_settings",
|
||||
"packages",
|
||||
// Resource grants & groups
|
||||
"resource_grants",
|
||||
"group_members",
|
||||
@@ -272,53 +274,10 @@ func TruncateAll(t *testing.T) {
|
||||
// Notifications
|
||||
"notification_preferences",
|
||||
"notifications",
|
||||
// Usage & audit
|
||||
"usage_log",
|
||||
// Audit
|
||||
"audit_log",
|
||||
// Channel internals
|
||||
"channel_cursors",
|
||||
"messages",
|
||||
"channel_models",
|
||||
"channel_participants",
|
||||
"channel_knowledge_bases",
|
||||
"user_model_settings",
|
||||
// Projects
|
||||
"project_notes",
|
||||
"project_knowledge_bases",
|
||||
"project_channels",
|
||||
// Knowledge bases
|
||||
"persona_knowledge_bases",
|
||||
"kb_chunks",
|
||||
"kb_documents",
|
||||
"knowledge_bases",
|
||||
// Notes
|
||||
"note_links",
|
||||
"notes",
|
||||
// Memory
|
||||
"memory_extraction_log",
|
||||
"memories",
|
||||
// Workspaces
|
||||
"workspace_chunks",
|
||||
"workspace_files",
|
||||
"git_credentials",
|
||||
"workspaces",
|
||||
// Channels & folders
|
||||
"channels",
|
||||
"folders",
|
||||
"projects",
|
||||
// Providers & health
|
||||
"model_pricing",
|
||||
"provider_health",
|
||||
"capability_overrides",
|
||||
"routing_policies",
|
||||
"tool_health",
|
||||
"model_catalog",
|
||||
// Personas
|
||||
"persona_grants",
|
||||
"persona_group_members",
|
||||
"persona_groups",
|
||||
"personas",
|
||||
"provider_configs",
|
||||
// Presence
|
||||
"user_presence",
|
||||
// Teams & users
|
||||
"team_members",
|
||||
"teams",
|
||||
|
||||
@@ -36,24 +36,14 @@ const (
|
||||
// Events not listed default to DirLocal (server-only).
|
||||
var routeTable = map[string]Direction{
|
||||
// Chat events
|
||||
"chat.message.": DirBoth, // new messages
|
||||
"chat.typing.": DirBoth, // typing indicators
|
||||
"chat.updated.": DirToClient, // chat title/metadata changed
|
||||
"chat.deleted.": DirToClient, // chat removed
|
||||
|
||||
// Channel events
|
||||
"channel.message.": DirBoth,
|
||||
"channel.typing.": DirBoth,
|
||||
"channel.updated.": DirToClient,
|
||||
"channel.member.": DirToClient,
|
||||
|
||||
// User/presence
|
||||
"user.presence": DirToClient,
|
||||
"user.status": DirToClient,
|
||||
"user.mentioned": DirToClient, // v0.23.2: targeted @mention notification
|
||||
"typing.user": DirToClient, // v0.23.2: human typing in DM/channel
|
||||
"message.created": DirToClient, // v0.23.2: chained/DM message delivery
|
||||
"message.deleted": DirToClient, // v0.37.14: soft-delete broadcast
|
||||
|
||||
// System
|
||||
"system.notify": DirToClient,
|
||||
@@ -70,7 +60,6 @@ var routeTable = map[string]Direction{
|
||||
"notification.read": DirToClient, // badge sync across tabs
|
||||
|
||||
// Workspace (v0.21.5)
|
||||
"workspace.file.": DirToClient, // file changed events for live editor updates
|
||||
|
||||
// Workflow (v0.27.0)
|
||||
"workflow.assigned": DirToClient, // new assignment → team members
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"switchboard-core/crypto"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/providers"
|
||||
"switchboard-core/storage"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/tools/search"
|
||||
@@ -438,325 +437,6 @@ func (h *AdminHandler) VaultStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// ── Provider Configs (Global) ───────────────
|
||||
|
||||
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
||||
cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Redact API keys but expose has_key flag for UI
|
||||
type configWithKey struct {
|
||||
models.ProviderConfig
|
||||
HasKey bool `json:"has_key"`
|
||||
}
|
||||
out := make([]configWithKey, len(cfgs))
|
||||
for i, cfg := range cfgs {
|
||||
out[i] = configWithKey{
|
||||
ProviderConfig: cfg,
|
||||
HasKey: cfg.HasKey(),
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||
// Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-"
|
||||
// so ShouldBindJSON would silently drop the api_key field.
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Endpoint string `json:"endpoint" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
ModelDefault string `json:"model_default,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
Headers map[string]interface{} `json:"headers,omitempty"`
|
||||
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||
IsPrivate bool `json:"is_private,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := providers.Get(req.Provider); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unsupported provider: " + req.Provider,
|
||||
"supported_providers": providers.List(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg := &models.ProviderConfig{
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Endpoint: req.Endpoint,
|
||||
ModelDefault: req.ModelDefault,
|
||||
Config: models.JSONMap(req.Config),
|
||||
Headers: models.JSONMap(req.Headers),
|
||||
Settings: models.JSONMap(req.Settings),
|
||||
Scope: models.ScopeGlobal,
|
||||
KeyScope: models.ScopeGlobal,
|
||||
IsActive: true,
|
||||
IsPrivate: req.IsPrivate,
|
||||
}
|
||||
|
||||
// Encrypt the API key
|
||||
if req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
cfg.APIKeyEnc = enc
|
||||
cfg.KeyNonce = nonce
|
||||
} else {
|
||||
// No vault configured — store raw bytes (unencrypted fallback)
|
||||
cfg.APIKeyEnc = []byte(req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": cfg.ID,
|
||||
"scope": cfg.Scope,
|
||||
"name": cfg.Name,
|
||||
"provider": cfg.Provider,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"model_default": cfg.ModelDefault,
|
||||
"config": cfg.Config,
|
||||
"headers": cfg.Headers,
|
||||
"settings": cfg.Settings,
|
||||
"is_active": cfg.IsActive,
|
||||
"is_private": cfg.IsPrivate,
|
||||
"has_key": cfg.HasKey(),
|
||||
"created_at": cfg.CreatedAt,
|
||||
"updated_at": cfg.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-"
|
||||
var req struct {
|
||||
models.ProviderConfigPatch
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
patch := req.ProviderConfigPatch
|
||||
// Transfer api_key → encrypted fields
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
patch.APIKeyEnc = enc
|
||||
patch.KeyNonce = nonce
|
||||
} else {
|
||||
patch.APIKeyEnc = []byte(*req.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLog(c, "provider.update", "provider_config", id, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// Delete associated catalog entries first
|
||||
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
|
||||
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
|
||||
return
|
||||
}
|
||||
h.auditLog(c, "provider.delete", "provider_config", id, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
|
||||
}
|
||||
|
||||
// ── Model Catalog ───────────────────────────
|
||||
|
||||
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
||||
entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": entries})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) FetchModels(c *gin.Context) {
|
||||
var req struct {
|
||||
ProviderConfigID string `json:"provider_config_id"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
// If no specific provider, fetch from ALL global providers
|
||||
if req.ProviderConfigID == "" {
|
||||
configs, err := h.stores.Providers.ListGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"})
|
||||
return
|
||||
}
|
||||
if len(configs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"})
|
||||
return
|
||||
}
|
||||
|
||||
totalAdded, totalUpdated, totalFetched := 0, 0, 0
|
||||
var errs []string
|
||||
for _, cfg := range configs {
|
||||
if !cfg.IsActive {
|
||||
continue
|
||||
}
|
||||
added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg)
|
||||
if err != nil {
|
||||
errs = append(errs, cfg.Provider+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
totalAdded += added
|
||||
totalUpdated += updated
|
||||
totalFetched += fetched
|
||||
}
|
||||
|
||||
result := gin.H{
|
||||
"message": "models synced",
|
||||
"added": totalAdded,
|
||||
"updated": totalUpdated,
|
||||
"total": totalFetched,
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
result["errors"] = errs
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Single provider fetch
|
||||
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamTimeout) {
|
||||
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "models synced",
|
||||
"added": added,
|
||||
"updated": updated,
|
||||
"total": fetched,
|
||||
})
|
||||
}
|
||||
|
||||
// fetchModelsForProvider fetches and syncs models for a single provider config.
|
||||
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
|
||||
// Decrypt the API key for the provider API call
|
||||
apiKey := ""
|
||||
if len(cfg.APIKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||
apiKey = string(cfg.APIKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
|
||||
h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{
|
||||
"added": result.Added, "updated": result.Updated, "total": result.Total,
|
||||
})
|
||||
|
||||
return result.Added, result.Updated, result.Total, nil
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Visibility != nil {
|
||||
if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
|
||||
var req struct {
|
||||
ProviderConfigID string `json:"provider_config_id"`
|
||||
Visibility string `json:"visibility" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if req.ProviderConfigID != "" {
|
||||
err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility)
|
||||
} else {
|
||||
err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
|
||||
}
|
||||
|
||||
// ── Stats ───────────────────────────────────
|
||||
|
||||
func (h *AdminHandler) GetStats(c *gin.Context) {
|
||||
@@ -764,12 +444,10 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
|
||||
stats := gin.H{}
|
||||
|
||||
userCount, _ := h.stores.Users.CountAll(ctx)
|
||||
channelCount, _ := h.stores.Channels.CountAll(ctx)
|
||||
messageCount, _ := h.stores.Messages.CountAll(ctx)
|
||||
teamCount, _ := h.stores.Teams.CountAll(ctx)
|
||||
|
||||
stats["users"] = userCount
|
||||
stats["channels"] = channelCount
|
||||
stats["messages"] = messageCount
|
||||
stats["teams"] = teamCount
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -786,62 +464,3 @@ func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID
|
||||
}
|
||||
AuditLog(h.stores.Audit, c, action, resourceType, resourceID, meta)
|
||||
}
|
||||
|
||||
// ── Archived Channels (v0.23.2) ──────────────
|
||||
|
||||
func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
channels, total, err := h.stores.Channels.ListArchived(c.Request.Context(), store.ListOptions{
|
||||
Limit: perPage,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Retention config
|
||||
retentionTTL := 0
|
||||
if ttlCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "retention_ttl_days"); err == nil {
|
||||
if v, ok := ttlCfg["value"].(float64); ok {
|
||||
retentionTTL = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": channels,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"retention_ttl_days": retentionTTL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) PurgeChannel(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Clean up file storage blobs before CASCADE deletes the DB references
|
||||
if h.objStore != nil {
|
||||
prefix := fmt.Sprintf("files/%s", channelID)
|
||||
if err := h.objStore.DeletePrefix(c.Request.Context(), prefix); err != nil {
|
||||
log.Printf("⚠️ storage cleanup for purged channel %s failed: %v", channelID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.stores.Channels.Purge(c.Request.Context(), channelID); err != nil {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case msg == "channel not found":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": msg})
|
||||
case msg == "channel must be archived before purging":
|
||||
c.JSON(http.StatusConflict, gin.H{"error": msg})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.auditLog(c, "channel.purged", "channel", channelID, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -349,18 +349,13 @@ func hashToken(token string) string {
|
||||
//
|
||||
// Does NOT evict from UEK cache or write audit logs — callers handle that.
|
||||
// v0.29.0: accepts stores instead of using database.DB directly.
|
||||
func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (providersDeleted int64) {
|
||||
func DestroyVaultDB(ctx context.Context, stores store.Stores, userID string) (deleted int64) {
|
||||
if err := stores.Users.ClearVaultKeys(ctx, userID); err != nil {
|
||||
log.Printf("⚠ DestroyVaultDB: failed to clear vault columns for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
rows, err := stores.Providers.DeletePersonalByOwner(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠ DestroyVaultDB: failed to delete personal providers for user %s: %v", userID, err)
|
||||
return 0
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// ProbeAndRepairVault checks whether a user's vault can be unlocked with
|
||||
// the given password. If the vault doesn't exist (vault_set=false), this is
|
||||
|
||||
@@ -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
@@ -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()})
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"switchboard-core/crypto"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/providers"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
@@ -346,91 +345,6 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": teams})
|
||||
}
|
||||
|
||||
// ── Team Models: Available for Personas ──────
|
||||
|
||||
// ListAvailableModels returns models with visibility 'enabled' or 'team'
|
||||
// for team admins building personas. Requires RequireTeamAdmin middleware.
|
||||
// GET /api/v1/teams/:teamId/models
|
||||
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
type availableModel struct {
|
||||
ID string `json:"id"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Visibility string `json:"visibility"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
result := make([]availableModel, 0)
|
||||
|
||||
// ── 1. Global admin models (synced in model_catalog) ──
|
||||
catalogModels, err := h.stores.Catalog.ListTeamAvailable(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
for _, cm := range catalogModels {
|
||||
result = append(result, availableModel{
|
||||
ID: cm.ID,
|
||||
ModelID: cm.ModelID,
|
||||
DisplayName: cm.DisplayName,
|
||||
Visibility: cm.Visibility,
|
||||
Provider: cm.Provider,
|
||||
ProviderName: cm.ProviderName,
|
||||
Source: "global",
|
||||
})
|
||||
}
|
||||
|
||||
// ── 2. Team provider models (live query) ──
|
||||
teamConfigs, err := h.stores.Providers.ListForTeam(ctx, teamID)
|
||||
if err == nil {
|
||||
for _, cfg := range teamConfigs {
|
||||
provider, pErr := providers.Get(cfg.Provider)
|
||||
if pErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key := ""
|
||||
if cfg.HasKey() {
|
||||
key = string(cfg.APIKeyEnc)
|
||||
}
|
||||
|
||||
var customHeaders map[string]string
|
||||
if cfg.Headers != nil {
|
||||
b, _ := json.Marshal(cfg.Headers)
|
||||
_ = json.Unmarshal(b, &customHeaders)
|
||||
}
|
||||
|
||||
provModels, lErr := provider.ListModels(ctx, providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
})
|
||||
if lErr != nil {
|
||||
log.Printf("[models] team provider %q list failed: %v", cfg.Name, lErr)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pm := range provModels {
|
||||
result = append(result, availableModel{
|
||||
ID: pm.ID,
|
||||
ModelID: pm.ID,
|
||||
Provider: cfg.Provider,
|
||||
ProviderName: cfg.Name,
|
||||
Visibility: "enabled",
|
||||
Source: "team",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// getTeamID extracts team ID from either :id (admin routes) or :teamId (team-scoped routes).
|
||||
@@ -441,30 +355,6 @@ func getTeamID(c *gin.Context) string {
|
||||
return c.Param("id")
|
||||
}
|
||||
|
||||
// enforcePrivateProviderPolicy checks if a user belongs to any team that
|
||||
// requires private providers, and if so, verifies the resolved config is
|
||||
// marked as private. Returns nil if allowed, error if blocked.
|
||||
func enforcePrivateProviderPolicy(ctx context.Context, stores store.Stores, userID, configID string) error {
|
||||
if configID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
requiresPrivate, err := stores.Teams.HasPrivateProviderRequirement(ctx, userID)
|
||||
if err != nil || !requiresPrivate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is in a restricted team — verify the config is private
|
||||
cfg, err := stores.Providers.GetByID(ctx, configID)
|
||||
if err != nil {
|
||||
return nil // config lookup failed, allow (fail open)
|
||||
}
|
||||
if !cfg.IsPrivate {
|
||||
return fmt.Errorf("your team requires private providers — this provider sends data externally")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Team Audit Log (scoped to team members) ─
|
||||
|
||||
func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
||||
SurfacePkgID: s.SurfacePkgID,
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeChatOnly
|
||||
st.StageMode = models.StageModeCustom
|
||||
}
|
||||
if st.HistoryMode == "" {
|
||||
st.HistoryMode = "full"
|
||||
|
||||
@@ -195,10 +195,10 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeChatOnly
|
||||
st.StageMode = models.StageModeCustom
|
||||
}
|
||||
if !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"})
|
||||
return
|
||||
}
|
||||
if st.Ordinal == 0 {
|
||||
@@ -227,7 +227,7 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, form_chat, or review"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be custom, form_only, form_chat, or review"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
494
server/main.go
494
server/main.go
@@ -17,36 +17,21 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"switchboard-core/auth"
|
||||
"switchboard-core/compaction"
|
||||
"switchboard-core/config"
|
||||
"switchboard-core/crypto"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/events"
|
||||
"switchboard-core/extraction"
|
||||
"switchboard-core/filters"
|
||||
"switchboard-core/sandbox"
|
||||
"switchboard-core/handlers"
|
||||
"switchboard-core/health"
|
||||
"switchboard-core/logging"
|
||||
"switchboard-core/metrics"
|
||||
"switchboard-core/knowledge"
|
||||
"switchboard-core/memory"
|
||||
"switchboard-core/middleware"
|
||||
"switchboard-core/notifications"
|
||||
"switchboard-core/pages"
|
||||
"switchboard-core/providers"
|
||||
"switchboard-core/retention"
|
||||
"switchboard-core/roles"
|
||||
"switchboard-core/routing"
|
||||
"switchboard-core/scheduler"
|
||||
"switchboard-core/storage"
|
||||
"switchboard-core/store"
|
||||
postgres "switchboard-core/store/postgres"
|
||||
sqliteStore "switchboard-core/store/sqlite"
|
||||
"switchboard-core/tools"
|
||||
"switchboard-core/tools/search"
|
||||
"switchboard-core/treepath"
|
||||
"switchboard-core/workspace"
|
||||
)
|
||||
|
||||
// v0.33.0: Embedded OpenAPI spec and Swagger UI for /api/docs.
|
||||
@@ -76,12 +61,9 @@ func main() {
|
||||
// log output goes through slog.
|
||||
logging.Init(cfg.LogFormat, cfg.LogLevel)
|
||||
|
||||
// Register LLM providers
|
||||
providers.Init()
|
||||
|
||||
// v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.)
|
||||
if cfg.ProviderTypesFile != "" {
|
||||
n, err := providers.LoadCustomTypes(cfg.ProviderTypesFile)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err)
|
||||
} else if n > 0 {
|
||||
@@ -93,8 +75,6 @@ func main() {
|
||||
uekCache := crypto.NewUEKCache()
|
||||
var keyResolver *crypto.KeyResolver
|
||||
var objStore storage.ObjectStore
|
||||
var healthAccum *health.Accumulator
|
||||
var healthStore health.Store
|
||||
|
||||
if err := database.Connect(cfg); err != nil {
|
||||
log.Printf("⚠ Database unavailable: %v", err)
|
||||
@@ -134,109 +114,30 @@ func main() {
|
||||
stores = postgres.NewStores(database.DB)
|
||||
}
|
||||
|
||||
// v0.29.0: Wire store layer into treepath for backward compat.
|
||||
// New code should call stores.Messages.* directly.
|
||||
treepath.Stores = &stores
|
||||
|
||||
// Provider health accumulator (v0.22.0)
|
||||
if database.IsSQLite() {
|
||||
healthStore = sqliteStore.NewHealthStore()
|
||||
} else {
|
||||
healthStore = postgres.NewHealthStore(database.DB)
|
||||
}
|
||||
healthAccum = health.NewAccumulator(healthStore)
|
||||
defer healthAccum.Stop()
|
||||
|
||||
// v0.33.0: Start Prometheus DB pool collector
|
||||
metrics.StartDBCollector(database.DB, 15*time.Second)
|
||||
|
||||
// Auto-disable: deactivate providers that are "down" for N consecutive
|
||||
// hourly windows. Configured via PROVIDER_AUTO_DISABLE_THRESHOLD env var.
|
||||
// Default: 3 (3 consecutive "down" hours triggers deactivation). Set to 0 to disable.
|
||||
autoDisableThreshold := 3
|
||||
if v := cfg.ProviderAutoDisableThreshold; v >= 0 {
|
||||
autoDisableThreshold = v
|
||||
}
|
||||
if autoDisableThreshold > 0 {
|
||||
if ad, ok := healthStore.(health.AutoDisabler); ok {
|
||||
healthAccum.SetAutoDisable(ad, autoDisableThreshold)
|
||||
log.Printf(" 🛡️ Provider auto-disable: %d consecutive down windows", autoDisableThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// Background health cleanup: prune windows older than 7 days
|
||||
// Kernel maintenance: prune stale data every 6 hours
|
||||
go func() {
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if n, err := healthStore.Prune(ctx, time.Now().UTC().Add(-health.PruneAge)); err != nil {
|
||||
log.Printf("⚠ health prune failed: %v", err)
|
||||
if n, err := stores.Health.Prune(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil {
|
||||
log.Printf("⚠ maintenance prune failed: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("🧹 health: pruned %d old windows", n)
|
||||
log.Printf("🧹 maintenance: pruned %d stale rows", n)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Background session cleanup (v0.26.0): remove expired anonymous sessions
|
||||
if cfg.SessionExpiryDays > 0 {
|
||||
go func() {
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -cfg.SessionExpiryDays)
|
||||
if n, err := stores.Sessions.DeleteExpired(ctx, cutoff); err != nil {
|
||||
log.Printf("⚠ session cleanup failed: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("🧹 sessions: cleaned up %d expired sessions", n)
|
||||
}
|
||||
cancel()
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Background workflow staleness sweep (v0.26.2): mark idle instances as stale
|
||||
// + v0.27.0: retention enforcement — archive/delete completed instances per workflow policy
|
||||
if cfg.WorkflowStaleHours > 0 {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
||||
// Staleness: mark idle active instances as stale
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour)
|
||||
n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff)
|
||||
if err != nil {
|
||||
log.Printf("⚠ workflow staleness sweep failed: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("🧹 workflows: marked %d instances as stale", n)
|
||||
}
|
||||
|
||||
// v0.27.0: Retention enforcement — delete completed workflow channels
|
||||
// where the parent workflow has retention.mode="delete" and
|
||||
// retention.delete_after_days has elapsed since completion.
|
||||
n2, err := stores.Channels.EnforceWorkflowRetention(ctx)
|
||||
if err != nil {
|
||||
// SQLite doesn't support JSON operators — skip retention on SQLite
|
||||
if !database.IsSQLite() {
|
||||
log.Printf("⚠ workflow retention enforcement failed: %v", err)
|
||||
}
|
||||
} else if n2 > 0 {
|
||||
log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n2)
|
||||
}
|
||||
|
||||
cancel()
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// v0.37.14: Channel retention scanner — purges archived channels past their TTL
|
||||
retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{})
|
||||
retScanner.Start()
|
||||
defer retScanner.Stop()
|
||||
|
||||
@@ -259,7 +160,6 @@ func main() {
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// ── File Storage ─────────────────────────
|
||||
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
|
||||
// overrides auto-detection. nil objStore = storage features disabled.
|
||||
var s3Cfg *storage.S3Config
|
||||
@@ -285,22 +185,12 @@ func main() {
|
||||
}
|
||||
handlers.SetStorageConfigured(objStore != nil)
|
||||
|
||||
// ── Extraction Queue ────────────────────
|
||||
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
|
||||
// Nil if storage is disabled.
|
||||
var extQueue *extraction.Queue
|
||||
if objStore != nil && cfg.StoragePath != "" {
|
||||
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Extraction queue init failed: %v", err)
|
||||
} else {
|
||||
extQueue = q
|
||||
// Recover items stuck in "processing" from previous crash
|
||||
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
|
||||
log.Printf("⚠ Extraction recovery failed: %v", err)
|
||||
} else if recovered > 0 {
|
||||
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,94 +201,15 @@ func main() {
|
||||
// No-op when running SQLite — in-process Bus is sufficient for single-pod.
|
||||
events.StartPGBroadcast(bus)
|
||||
|
||||
// ── Workspace FS (v0.21.0) ──────────────
|
||||
// Provides file operations for workspace storage primitive.
|
||||
// Nil-safe: handler checks wfs != nil before operating.
|
||||
var wfs *workspace.FS
|
||||
if cfg.StoragePath != "" {
|
||||
wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
|
||||
if err := wfs.Init(); err != nil {
|
||||
log.Printf("⚠ Workspace FS init failed: %v", err)
|
||||
wfs = nil
|
||||
} else {
|
||||
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Register workspace tools (late registration — needs stores + wfs)
|
||||
if wfs != nil {
|
||||
tools.RegisterWorkspaceTools(stores, wfs)
|
||||
}
|
||||
|
||||
// Role resolver for model role dispatch (needs stores + vault + bus)
|
||||
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
||||
|
||||
// ── Knowledge Base Pipeline ─────────────
|
||||
// Embedder + ingester for RAG document processing (v0.14.0).
|
||||
// Nil-safe: handler checks embedder.IsConfigured() before accepting uploads.
|
||||
kbEmbedder := knowledge.NewEmbedder(roleResolver).WithStores(stores)
|
||||
kbIngester := knowledge.NewIngester(stores, kbEmbedder, objStore, knowledge.DefaultConcurrency)
|
||||
defer kbIngester.Wait() // drain in-flight ingestions on shutdown
|
||||
|
||||
// Register kb_search tool (late registration — needs stores + embedder)
|
||||
tools.RegisterKBSearch(stores, kbEmbedder)
|
||||
|
||||
// Register note tools (late registration — needs stores + embedder for semantic search)
|
||||
tools.RegisterNoteTools(stores, kbEmbedder)
|
||||
|
||||
// ── Workspace Indexer (v0.21.2) ───────────
|
||||
// Background indexing pipeline for workspace files.
|
||||
// Shares the embedder with KB ingestion; uses its own concurrency semaphore.
|
||||
var wsIndexer *workspace.Indexer
|
||||
if wfs != nil {
|
||||
idxSem := make(chan struct{}, cfg.WorkspaceIndexConcurrency)
|
||||
wsIndexer = workspace.NewIndexer(stores, kbEmbedder, wfs, idxSem, cfg.WorkspaceIndexingEnabled)
|
||||
wfs.SetIndexer(wsIndexer) // hook into write path
|
||||
defer wsIndexer.Wait()
|
||||
if cfg.WorkspaceIndexingEnabled {
|
||||
log.Printf(" 📑 Workspace indexer enabled (concurrency=%d)", cfg.WorkspaceIndexConcurrency)
|
||||
} else {
|
||||
log.Printf(" 📑 Workspace indexer disabled (WORKSPACE_INDEXING_ENABLED=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// Register workspace_search tool (needs embedder for query embedding)
|
||||
tools.RegisterWorkspaceSearchTool(stores, kbEmbedder)
|
||||
|
||||
// ── Git Integration (v0.21.4) ────────────
|
||||
// GitOps wraps exec-based git operations with vault credential injection.
|
||||
var gitOps *workspace.GitOps
|
||||
if wfs != nil {
|
||||
gitOps = workspace.NewGitOps(wfs, stores, keyResolver, wsIndexer)
|
||||
tools.RegisterGitTools(gitOps)
|
||||
log.Println(" 🔀 Git integration enabled")
|
||||
}
|
||||
|
||||
// Register context recall tools (v0.15.1)
|
||||
tools.RegisterFileRecall(stores, objStore)
|
||||
tools.RegisterConversationSearch(stores)
|
||||
|
||||
// Memory tools + extraction scanner (v0.18.0)
|
||||
tools.RegisterMemoryTools(stores, kbEmbedder)
|
||||
|
||||
// Workflow tools (v0.26.4 — AI-triggered stage advancement)
|
||||
tools.RegisterWorkflowTools(stores)
|
||||
|
||||
// v0.27.3: task_create tool (AI spawns sub-tasks)
|
||||
tools.RegisterTaskTools(stores)
|
||||
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
|
||||
memCompactor := memory.NewCompactor(stores, roleResolver)
|
||||
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
|
||||
memScanner.Start()
|
||||
defer memScanner.Stop()
|
||||
|
||||
// ── Pre-completion filter chain (v0.29.0) ──
|
||||
// Built-in filters register here. Starlark extension filters will
|
||||
// register at package install time (CS3).
|
||||
filterChain := filters.NewChain()
|
||||
filterChain.Register(filters.NewKBInjectFilter(stores))
|
||||
|
||||
// ── Starlark Runner (v0.29.0 CS3) ──
|
||||
// Sandboxed interpreter for extension scripts. Runner assembles
|
||||
// modules based on granted permissions. Notifier attached below
|
||||
// after notification service init.
|
||||
@@ -406,8 +217,6 @@ func main() {
|
||||
sandbox.New(sandbox.DefaultConfig()),
|
||||
stores,
|
||||
)
|
||||
// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
|
||||
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
|
||||
// v0.38.1: connections module — extension connection resolution
|
||||
starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver))
|
||||
// v0.29.2: db module — extension namespaced table access
|
||||
@@ -422,10 +231,6 @@ func main() {
|
||||
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
|
||||
}
|
||||
|
||||
// Discover and register active Starlark pre-completion filters
|
||||
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)
|
||||
|
||||
log.Printf(" 🔗 Pre-completion filter chain: %d filters", filterChain.Len())
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.RequestID())
|
||||
@@ -475,16 +280,6 @@ func main() {
|
||||
}
|
||||
|
||||
// v0.27.2: Task scheduler with executor — needs hub + notification service
|
||||
if stores.Tasks != nil {
|
||||
// v0.28.6: Register built-in system task functions
|
||||
scheduler.RegisterBuiltins()
|
||||
|
||||
exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum)
|
||||
exec.SetRunner(starlarkRunner)
|
||||
taskSched := scheduler.New(stores, exec)
|
||||
go taskSched.Run()
|
||||
log.Println(" ⏰ Task scheduler started (with executor)")
|
||||
}
|
||||
|
||||
// Health check (k8s probes hit this directly)
|
||||
base.GET("/health", func(c *gin.Context) {
|
||||
@@ -587,7 +382,6 @@ func main() {
|
||||
"schema_version": database.SchemaVersion(),
|
||||
"database": database.IsConnected(),
|
||||
"database_name": database.Name(),
|
||||
"providers": providers.List(),
|
||||
}
|
||||
if database.IsConnected() {
|
||||
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
|
||||
@@ -633,13 +427,6 @@ func main() {
|
||||
})
|
||||
|
||||
// Channels
|
||||
channels := handlers.NewChannelHandler(stores)
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
protected.POST("/channels/:id/mark-read", channels.MarkRead)
|
||||
|
||||
// Typing indicator broadcast (v0.23.2)
|
||||
protected.POST("/channels/:id/typing", func(c *gin.Context) {
|
||||
@@ -657,8 +444,6 @@ func main() {
|
||||
if displayName == "" {
|
||||
displayName = userID[:8]
|
||||
}
|
||||
// Broadcast to other user participants
|
||||
pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID)
|
||||
if err == nil {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"channel_id": channelID,
|
||||
@@ -678,7 +463,6 @@ func main() {
|
||||
})
|
||||
|
||||
// Chat Folders (v0.23.1)
|
||||
folders := handlers.NewFolderHandler(stores)
|
||||
protected.GET("/folders", folders.List)
|
||||
protected.POST("/folders", folders.Create)
|
||||
protected.PUT("/folders/:id", folders.Update)
|
||||
@@ -693,7 +477,6 @@ func main() {
|
||||
protected.GET("/users/search", presence.SearchUsers)
|
||||
|
||||
// Persona groups (v0.23.2 — roster templates for group chats)
|
||||
pgH := handlers.NewPersonaGroupHandler(stores)
|
||||
protected.GET("/persona-groups", pgH.List)
|
||||
protected.POST("/persona-groups", pgH.Create)
|
||||
protected.GET("/persona-groups/:id", pgH.Get)
|
||||
@@ -717,52 +500,20 @@ func main() {
|
||||
protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish)
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances (v0.26.2 — runtime lifecycle)
|
||||
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
protected.POST("/channels/:id/workflow/reject", wfInstH.Reject)
|
||||
protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15
|
||||
|
||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
|
||||
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
|
||||
protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim) // v0.37.15
|
||||
protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign) // v0.37.15
|
||||
protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15
|
||||
|
||||
// Tasks (v0.27.1, permissions v0.27.2)
|
||||
taskH := handlers.NewTaskHandler(stores)
|
||||
protected.GET("/tasks", taskH.ListMine)
|
||||
protected.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Create)
|
||||
protected.GET("/tasks/:id", taskH.Get)
|
||||
protected.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Update)
|
||||
protected.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Delete)
|
||||
protected.GET("/tasks/:id/runs", taskH.ListRuns)
|
||||
protected.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.RunNow)
|
||||
protected.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.KillRun)
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
chModelH := handlers.NewChannelModelHandler(stores)
|
||||
protected.GET("/channels/:id/models", chModelH.List)
|
||||
protected.POST("/channels/:id/models", chModelH.Add)
|
||||
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
|
||||
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
|
||||
|
||||
// Channel participants (v0.23.0 — ICD §3.7)
|
||||
partH := handlers.NewParticipantHandler(stores)
|
||||
protected.GET("/channels/:id/participants", partH.List)
|
||||
protected.POST("/channels/:id/participants", middleware.RequirePermission(auth.PermChannelInvite, stores), partH.Add)
|
||||
protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
|
||||
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
|
||||
|
||||
// Messages
|
||||
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
|
||||
@@ -774,18 +525,6 @@ func main() {
|
||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||
protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage)
|
||||
|
||||
// Chat Completions
|
||||
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
|
||||
if healthAccum != nil {
|
||||
comp.SetHealthRecorder(healthAccum)
|
||||
comp.SetHealthStore(healthStore)
|
||||
}
|
||||
comp.SetRoutingEvaluator(routing.NewEvaluator())
|
||||
comp.SetFilterChain(filterChain)
|
||||
comp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
|
||||
protected.POST("/chat/completions", middleware.RequirePermission(auth.PermModelUse, stores), comp.Complete)
|
||||
protected.GET("/tools", comp.ListTools)
|
||||
|
||||
// Surface discovery (v0.25.0, v0.28.7: unified packages)
|
||||
pkgH := handlers.NewPackageHandler(stores)
|
||||
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||
@@ -800,24 +539,9 @@ func main() {
|
||||
protected.POST("/packages/install", userPkgH.InstallPersonalPackage)
|
||||
protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage)
|
||||
|
||||
// Summarize & Continue (backed by compaction service)
|
||||
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||
summarize := handlers.NewSummarizeHandler(stores, compactionSvc)
|
||||
protected.POST("/channels/:id/summarize", middleware.RequirePermission(auth.PermModelUse, stores), summarize.Summarize)
|
||||
|
||||
// Auto-title generation (utility role)
|
||||
titleH := handlers.NewTitleHandler(stores, roleResolver)
|
||||
protected.POST("/channels/:id/generate-title", middleware.RequirePermission(auth.PermModelUse, stores), titleH.GenerateTitle)
|
||||
|
||||
// Provider Configs (user-facing — replaces /api-configs)
|
||||
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
|
||||
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
|
||||
protected.POST("/api-configs", provCfg.CreateConfig)
|
||||
protected.GET("/api-configs/:id", provCfg.GetConfig)
|
||||
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
|
||||
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
|
||||
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
||||
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||
|
||||
// Connection Type Discovery (v0.38.4)
|
||||
connTypeH := handlers.NewConnectionTypeHandler(stores)
|
||||
@@ -833,14 +557,8 @@ func main() {
|
||||
protected.DELETE("/connections/:id", connH.DeleteConnection)
|
||||
|
||||
// Models (unified resolver — replaces scattered endpoints)
|
||||
modelH := handlers.NewModelHandler(stores)
|
||||
if healthStore != nil {
|
||||
modelH.SetHealthStore(healthStore)
|
||||
}
|
||||
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
||||
|
||||
// Model Preferences
|
||||
modelPrefs := handlers.NewModelPrefsHandler(stores)
|
||||
protected.GET("/models/preferences", modelPrefs.GetPreferences)
|
||||
protected.PUT("/models/preferences", modelPrefs.SetPreference)
|
||||
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
|
||||
@@ -864,24 +582,11 @@ func main() {
|
||||
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
|
||||
|
||||
// Usage (personal)
|
||||
usage := handlers.NewUsageHandler(stores)
|
||||
protected.GET("/usage", usage.PersonalUsage)
|
||||
|
||||
// Personas
|
||||
personas := handlers.NewPersonaHandler(stores)
|
||||
protected.GET("/personas", personas.ListUserPersonas)
|
||||
protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona)
|
||||
protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona)
|
||||
protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona)
|
||||
protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar)
|
||||
protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar)
|
||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0
|
||||
protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.0
|
||||
|
||||
// Notes
|
||||
notes := handlers.NewNoteHandler(stores)
|
||||
protected.GET("/notes", notes.List)
|
||||
protected.POST("/notes", notes.Create)
|
||||
protected.GET("/notes/search", notes.Search)
|
||||
@@ -895,28 +600,6 @@ func main() {
|
||||
protected.GET("/notes/:id/backlinks", notes.Backlinks)
|
||||
|
||||
// Projects (v0.19.0)
|
||||
projectH := handlers.NewProjectHandler(stores)
|
||||
protected.GET("/projects", projectH.List)
|
||||
protected.POST("/projects", projectH.Create)
|
||||
protected.GET("/projects/:id", projectH.Get)
|
||||
protected.PUT("/projects/:id", projectH.Update)
|
||||
protected.DELETE("/projects/:id", projectH.Delete)
|
||||
protected.POST("/projects/:id/channels", projectH.AddChannel)
|
||||
protected.DELETE("/projects/:id/channels/:channelId", projectH.RemoveChannel)
|
||||
protected.GET("/projects/:id/channels", projectH.ListChannels)
|
||||
protected.PUT("/projects/:id/channels/reorder", projectH.ReorderChannels)
|
||||
protected.POST("/projects/:id/knowledge-bases", projectH.AddKB)
|
||||
protected.DELETE("/projects/:id/knowledge-bases/:kbId", projectH.RemoveKB)
|
||||
protected.GET("/projects/:id/knowledge-bases", projectH.ListKBs)
|
||||
protected.POST("/projects/:id/notes", projectH.AddNote)
|
||||
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
|
||||
protected.GET("/projects/:id/notes", projectH.ListNotes)
|
||||
|
||||
// Project files (v0.22.4, reworked v0.37.17 — workspace-backed)
|
||||
projFileH := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||
if wfs != nil {
|
||||
projFileH.SetWorkspaceFS(wfs)
|
||||
}
|
||||
protected.POST("/projects/:id/files", projFileH.UploadToProject)
|
||||
protected.GET("/projects/:id/files", projFileH.ListByProject)
|
||||
protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
|
||||
@@ -938,96 +621,20 @@ func main() {
|
||||
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
||||
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
||||
|
||||
// Workspaces (v0.21.0)
|
||||
if wfs != nil {
|
||||
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
||||
protected.POST("/workspaces", wsH.Create)
|
||||
protected.GET("/workspaces", wsH.List)
|
||||
protected.GET("/workspaces/default", wsH.GetDefault) // v0.37.18: before :id param
|
||||
protected.GET("/workspaces/:id", wsH.Get)
|
||||
protected.PATCH("/workspaces/:id", wsH.Update)
|
||||
protected.DELETE("/workspaces/:id", wsH.Delete)
|
||||
protected.GET("/workspaces/:id/files", wsH.ListFiles)
|
||||
protected.GET("/workspaces/:id/files/read", wsH.ReadFile)
|
||||
protected.PUT("/workspaces/:id/files/write", wsH.WriteFile)
|
||||
protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler)
|
||||
protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir)
|
||||
protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive)
|
||||
protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive)
|
||||
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
|
||||
protected.GET("/workspaces/:id/stats", wsH.Stats)
|
||||
protected.GET("/workspaces/:id/index-status", wsH.IndexStatus)
|
||||
|
||||
// Git operations (v0.21.4)
|
||||
if gitOps != nil {
|
||||
gitH := handlers.NewGitHandler(stores, gitOps)
|
||||
protected.POST("/workspaces/:id/git/clone", gitH.Clone)
|
||||
protected.POST("/workspaces/:id/git/pull", gitH.Pull)
|
||||
protected.POST("/workspaces/:id/git/push", gitH.Push)
|
||||
protected.GET("/workspaces/:id/git/status", gitH.Status)
|
||||
protected.GET("/workspaces/:id/git/diff", gitH.Diff)
|
||||
protected.POST("/workspaces/:id/git/commit", gitH.Commit)
|
||||
protected.GET("/workspaces/:id/git/log", gitH.Log)
|
||||
protected.GET("/workspaces/:id/git/branches", gitH.Branches)
|
||||
protected.POST("/workspaces/:id/git/checkout", gitH.Checkout)
|
||||
}
|
||||
}
|
||||
|
||||
// Git credentials (v0.21.4) — user-scoped, independent of workspace
|
||||
gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
|
||||
protected.POST("/git-credentials", gitCredH.Create)
|
||||
protected.POST("/git-credentials/generate", gitCredH.Generate)
|
||||
protected.GET("/git-credentials", gitCredH.List)
|
||||
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
|
||||
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
|
||||
|
||||
// Files (upload/download)
|
||||
fileH := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||
protected.POST("/channels/:id/files", fileH.Upload)
|
||||
protected.GET("/channels/:id/files", fileH.ListByChannel)
|
||||
protected.GET("/files", fileH.ListByUser)
|
||||
protected.GET("/files/:id", fileH.GetMetadata)
|
||||
protected.GET("/files/:id/download", fileH.Download)
|
||||
protected.DELETE("/files/:id", fileH.Delete)
|
||||
protected.GET("/messages/:id/files", fileH.ListByMessage)
|
||||
|
||||
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
|
||||
exportH := handlers.NewExportHandler()
|
||||
protected.POST("/export", exportH.Convert)
|
||||
|
||||
// Data portability (v0.34.0) — user data export/import
|
||||
dataExport := handlers.NewDataExportHandler(stores, objStore)
|
||||
protected.GET("/export/me", dataExport.ExportMyData)
|
||||
dataImport := handlers.NewDataImportHandler(stores, objStore)
|
||||
protected.POST("/import/me", dataImport.ImportMyData)
|
||||
protected.POST("/import/chatgpt", dataImport.ImportChatGPT)
|
||||
gdprH := handlers.NewGDPRHandler(stores)
|
||||
protected.DELETE("/me", gdprH.DeleteMyAccount)
|
||||
|
||||
// Hook: clean up storage files when channels are deleted
|
||||
handlers.SetChannelDeleteHook(fileH.CleanupChannelStorage)
|
||||
|
||||
// Knowledge Bases (RAG — v0.14.0)
|
||||
kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder)
|
||||
protected.POST("/knowledge-bases", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB)
|
||||
protected.GET("/knowledge-bases", kbH.ListKBs)
|
||||
protected.GET("/knowledge-bases/:id", kbH.GetKB)
|
||||
protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UpdateKB)
|
||||
protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteKB)
|
||||
protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UploadDocument)
|
||||
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
|
||||
protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
|
||||
protected.DELETE("/knowledge-bases/:id/documents/:docId", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteDocument)
|
||||
protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(auth.PermKBRead, stores), kbH.SearchKB)
|
||||
protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.RebuildKB)
|
||||
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
|
||||
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
|
||||
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
|
||||
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0 (admin only enforced in handler)
|
||||
|
||||
// Memory management (v0.18.0)
|
||||
memH := handlers.NewMemoryHandler(stores)
|
||||
memH.SetCompactor(memCompactor)
|
||||
protected.GET("/memories", memH.ListMyMemories)
|
||||
protected.PUT("/memories/:id", memH.UpdateMemory)
|
||||
protected.DELETE("/memories/:id", memH.DeleteMemory)
|
||||
@@ -1052,7 +659,6 @@ func main() {
|
||||
teamScoped.POST("/members", teams.AddMember)
|
||||
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||
teamScoped.GET("/models", teams.ListAvailableModels)
|
||||
|
||||
// Team groups (team admins manage team-scoped groups)
|
||||
teamScoped.GET("/groups", groupH.ListTeamGroups)
|
||||
@@ -1080,11 +686,9 @@ func main() {
|
||||
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||
|
||||
// Team usage (team admins only — usage against team-owned providers)
|
||||
teamUsage := handlers.NewUsageHandler(stores)
|
||||
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
||||
|
||||
// Team personas
|
||||
teamPersonas := handlers.NewPersonaHandler(stores)
|
||||
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
|
||||
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
|
||||
teamScoped.PUT("/personas/:id", teamPersonas.UpdateTeamPersona)
|
||||
@@ -1096,15 +700,11 @@ func main() {
|
||||
teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0
|
||||
teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // v0.28.0
|
||||
|
||||
// Team role overrides
|
||||
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
||||
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||
|
||||
// Team workflow assignments (v0.26.4)
|
||||
teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub)
|
||||
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||
|
||||
// Team workflows — self-service (v0.31.2)
|
||||
teamWfH := handlers.NewWorkflowHandler(stores)
|
||||
@@ -1122,27 +722,15 @@ func main() {
|
||||
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
|
||||
|
||||
// Team workflow monitoring (v0.35.0)
|
||||
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
|
||||
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
|
||||
|
||||
// Team workflow instance cancel (v0.37.15)
|
||||
teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
|
||||
teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
teamTaskH := handlers.NewTaskHandler(stores)
|
||||
teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask)
|
||||
teamScoped.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Update)
|
||||
teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete)
|
||||
teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow)
|
||||
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun)
|
||||
}
|
||||
|
||||
// Team task viewing for all members (v0.27.5)
|
||||
teamMemberRoutes := protected.Group("/teams/:teamId")
|
||||
teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
|
||||
{
|
||||
teamMemberTaskH := handlers.NewTaskHandler(stores)
|
||||
teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks)
|
||||
teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns)
|
||||
}
|
||||
@@ -1185,10 +773,6 @@ func main() {
|
||||
admin.GET("/stats", adm.GetStats)
|
||||
|
||||
// Global Provider Configs
|
||||
admin.GET("/configs", adm.ListGlobalConfigs)
|
||||
admin.POST("/configs", adm.CreateGlobalConfig)
|
||||
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
|
||||
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||
|
||||
// Global Connections (v0.38.1)
|
||||
admin.GET("/connections", adm.ListGlobalConnections)
|
||||
@@ -1197,14 +781,8 @@ func main() {
|
||||
admin.DELETE("/connections/:id", adm.DeleteGlobalConnection)
|
||||
|
||||
// Model Catalog
|
||||
admin.GET("/models", adm.ListModelConfigs)
|
||||
admin.POST("/models/fetch", adm.FetchModels)
|
||||
admin.PUT("/models/bulk", adm.BulkUpdateModels)
|
||||
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
||||
admin.DELETE("/models/:id", adm.DeleteModelConfig)
|
||||
|
||||
// Personas (admin global)
|
||||
personaAdm := handlers.NewPersonaHandler(stores)
|
||||
admin.GET("/personas", personaAdm.ListAdminPersonas)
|
||||
admin.POST("/personas", personaAdm.CreateAdminPersona)
|
||||
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
|
||||
@@ -1217,7 +795,6 @@ func main() {
|
||||
admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.0
|
||||
|
||||
// Admin memory review (v0.18.0)
|
||||
adminMemH := handlers.NewMemoryHandler(stores)
|
||||
admin.GET("/memories/pending", adminMemH.ListPendingReview)
|
||||
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
|
||||
|
||||
@@ -1234,8 +811,6 @@ func main() {
|
||||
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
|
||||
|
||||
// Team data export/import (v0.34.0)
|
||||
teamExport := handlers.NewDataExportHandler(stores, objStore)
|
||||
teamImport := handlers.NewDataImportHandler(stores, objStore)
|
||||
admin.GET("/teams/:id/export", teamExport.ExportTeam)
|
||||
admin.POST("/teams/:id/import", teamImport.ImportTeam)
|
||||
|
||||
@@ -1267,26 +842,16 @@ func main() {
|
||||
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
|
||||
|
||||
// Projects (admin — v0.19.0)
|
||||
adminProjH := handlers.NewProjectHandler(stores)
|
||||
admin.GET("/projects", adminProjH.AdminList)
|
||||
admin.DELETE("/projects/:id", adminProjH.Delete)
|
||||
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
||||
|
||||
// Model Roles
|
||||
rolesH := handlers.NewRolesHandler(stores, roleResolver)
|
||||
admin.GET("/roles", rolesH.ListRoles)
|
||||
admin.GET("/roles/:role", rolesH.GetRole)
|
||||
admin.PUT("/roles/:role", rolesH.UpdateRole)
|
||||
admin.POST("/roles/:role/test", rolesH.TestRole)
|
||||
|
||||
// Usage & Pricing
|
||||
usageH := handlers.NewUsageHandler(stores)
|
||||
admin.GET("/usage", usageH.AdminUsage)
|
||||
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
|
||||
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
|
||||
admin.GET("/pricing", usageH.ListPricing)
|
||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
||||
|
||||
// Storage status
|
||||
storageH := handlers.NewStorageHandler(objStore)
|
||||
@@ -1299,15 +864,11 @@ func main() {
|
||||
emailAdm := handlers.NewAdminEmailHandler(stores)
|
||||
admin.POST("/notifications/test-email", emailAdm.TestEmail)
|
||||
|
||||
// Storage management (orphan cleanup)
|
||||
fileAdm := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||
admin.GET("/storage/orphans", fileAdm.OrphanCount)
|
||||
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
|
||||
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
|
||||
|
||||
// Archived channels (admin — v0.23.2)
|
||||
admin.GET("/channels/archived", adm.ListArchivedChannels)
|
||||
admin.DELETE("/channels/:id/purge", adm.PurgeChannel)
|
||||
|
||||
// Extensions (admin)
|
||||
extAdm := handlers.NewExtensionHandler(stores)
|
||||
@@ -1330,17 +891,12 @@ func main() {
|
||||
admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
|
||||
admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)
|
||||
|
||||
// Admin Dashboard (v0.33.0)
|
||||
dashAdm := handlers.NewDashboardAdminHandler(stores, healthStore, hub)
|
||||
admin.GET("/dashboard", dashAdm.GetDashboard)
|
||||
|
||||
// Provider Health (admin — v0.22.0)
|
||||
healthAdm := handlers.NewHealthAdminHandler(healthStore, stores)
|
||||
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
|
||||
admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
|
||||
|
||||
// Capability Overrides (admin — v0.22.0)
|
||||
capAdm := handlers.NewCapOverrideAdminHandler(stores)
|
||||
admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
|
||||
admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
|
||||
admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
|
||||
@@ -1349,9 +905,6 @@ func main() {
|
||||
// Provider Types (admin — v0.22.1)
|
||||
admin.GET("/provider-types", handlers.GetProviderTypes)
|
||||
|
||||
// Routing Policies (admin — v0.22.2)
|
||||
routingEval := routing.NewEvaluator()
|
||||
routingAdm := handlers.NewRoutingAdminHandler(stores, routingEval, healthStore)
|
||||
admin.GET("/routing/policies", routingAdm.ListPolicies)
|
||||
admin.GET("/routing/policies/:id", routingAdm.GetPolicy)
|
||||
admin.POST("/routing/policies", routingAdm.CreatePolicy)
|
||||
@@ -1366,7 +919,6 @@ func main() {
|
||||
}
|
||||
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
||||
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations
|
||||
pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool
|
||||
|
||||
// Package registry — must be registered before /packages/:id (v0.30.0)
|
||||
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
||||
@@ -1387,7 +939,6 @@ func main() {
|
||||
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
|
||||
|
||||
// Package export (v0.30.0)
|
||||
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
||||
admin.GET("/packages/:id/export", pkgExport.ExportPackage)
|
||||
|
||||
// Workflow package export (v0.30.2)
|
||||
@@ -1395,10 +946,6 @@ func main() {
|
||||
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
|
||||
|
||||
// Workflow monitoring (v0.35.0)
|
||||
wfMonH := handlers.NewWorkflowMonitorHandler(stores)
|
||||
admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances)
|
||||
admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel)
|
||||
admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances)
|
||||
|
||||
// Surface aliases (backward compat — same handlers)
|
||||
admin.GET("/surfaces", pkgAdm.ListPackages)
|
||||
@@ -1409,7 +956,6 @@ func main() {
|
||||
admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage)
|
||||
|
||||
// Task management — admin (v0.27.1, extended v0.27.2)
|
||||
taskAdm := handlers.NewTaskHandler(stores)
|
||||
admin.GET("/tasks", taskAdm.ListAll)
|
||||
admin.POST("/tasks/:id/run", taskAdm.RunNow)
|
||||
admin.POST("/tasks/:id/kill", taskAdm.KillRun)
|
||||
@@ -1472,17 +1018,11 @@ func main() {
|
||||
wfAPI := base.Group("/api/v1/w")
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
|
||||
{
|
||||
wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||
wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
|
||||
wfAPI.GET("/:id/messages", wfMsgs.ListMessages)
|
||||
|
||||
wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
|
||||
wfComp.SetFilterChain(filterChain)
|
||||
wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch
|
||||
wfAPI.POST("/:id/completions", wfComp.Complete)
|
||||
|
||||
// v0.29.3: Workflow form endpoints
|
||||
wfForms := handlers.NewWorkflowFormHandler(stores, starlarkRunner, hub)
|
||||
wfAPI.GET("/:id/form", wfForms.GetFormTemplate)
|
||||
wfAPI.POST("/:id/form-submit", wfForms.SubmitForm)
|
||||
}
|
||||
@@ -1491,12 +1031,9 @@ func main() {
|
||||
// NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
|
||||
// conflicts with /api/v1/w/:id/messages (different wildcard names at
|
||||
// same path position). Separate namespace avoids the collision.
|
||||
wfEntry := handlers.NewWorkflowEntryHandler(stores)
|
||||
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
||||
|
||||
// v0.28.0: Webhook trigger endpoint (token-based auth, no JWT)
|
||||
triggerH := handlers.NewTriggerHandler(stores)
|
||||
base.POST("/api/v1/hooks/t/:token", triggerH.Handle)
|
||||
|
||||
bp := cfg.BasePath
|
||||
if bp == "" {
|
||||
@@ -1505,19 +1042,12 @@ func main() {
|
||||
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
|
||||
log.Printf(" Base path: %s", bp)
|
||||
log.Printf(" Schema: %s", database.SchemaVersion())
|
||||
log.Printf(" Providers: %v", providers.List())
|
||||
if objStore != nil {
|
||||
log.Printf(" Storage: %s", objStore.Backend())
|
||||
if extQueue != nil {
|
||||
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
|
||||
} else {
|
||||
log.Printf(" Extraction: disabled")
|
||||
}
|
||||
} else {
|
||||
log.Printf(" Storage: disabled")
|
||||
}
|
||||
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
||||
log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge)
|
||||
log.Printf(" Pages: template engine active (%d surfaces registered)", len(pageEngine.Surfaces()))
|
||||
if err := r.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
@@ -1528,22 +1058,6 @@ func main() {
|
||||
|
||||
// loadSearchConfig reads search provider config from global_config and applies it.
|
||||
// Falls back to DuckDuckGo (the default set in search package init) if not configured.
|
||||
func loadSearchConfig(stores store.Stores) {
|
||||
raw, err := stores.GlobalConfig.Get(context.Background(), "search_config")
|
||||
if err != nil || raw == nil {
|
||||
log.Println("🔍 Search: using default provider (DuckDuckGo)")
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
var cfg search.Config
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
log.Printf("⚠️ Failed to parse search config: %v", err)
|
||||
return
|
||||
}
|
||||
if err := search.ApplyConfig(cfg); err != nil {
|
||||
log.Printf("⚠️ Failed to apply search config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runVaultCommand(subcmd string) {
|
||||
cfg := config.Load()
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -369,25 +369,9 @@ type ResourceGrant struct {
|
||||
// ProviderStatus represents the derived health state.
|
||||
|
||||
// AvgLatencyMs returns the average latency, or 0 if no requests.
|
||||
func (w *ProviderHealthWindow) AvgLatencyMs() int {
|
||||
if w.RequestCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(w.TotalLatencyMs / int64(w.RequestCount))
|
||||
}
|
||||
|
||||
// ErrorRate returns the error fraction, or 0 if no requests.
|
||||
func (w *ProviderHealthWindow) ErrorRate() float64 {
|
||||
if w.RequestCount == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(w.ErrorCount) / float64(w.RequestCount)
|
||||
}
|
||||
|
||||
// ProviderHealthSummary is the API response for a provider's current health.
|
||||
// ToolHealthWindow tracks health of built-in tools (web_search, url_fetch, etc.)
|
||||
// in hourly buckets, analogous to ProviderHealthWindow.
|
||||
// ToolHealthSummary is the API response for a tool's health.
|
||||
// CAPABILITY OVERRIDES (v0.22.0)
|
||||
|
||||
// CapabilityOverride is an admin correction for a model's capabilities.
|
||||
|
||||
@@ -28,11 +28,9 @@ var ValidPackageStatuses = map[string]bool{
|
||||
const (
|
||||
ExtPermSecretsRead = "secrets.read"
|
||||
ExtPermNotificationsSend = "notifications.send"
|
||||
ExtPermFiltersPreCompletion = "filters.pre_completion"
|
||||
ExtPermDBRead = "db.read"
|
||||
ExtPermDBWrite = "db.write"
|
||||
ExtPermAPIHTTP = "api.http"
|
||||
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
||||
ExtPermFormValidate = "forms.validate" // v0.29.3: form validation hooks
|
||||
ExtPermWorkflowAccess = "workflow.access" // v0.30.2: workflow definition + stage data access
|
||||
ExtPermConnectionsRead = "connections.read" // v0.38.1: extension connection resolution
|
||||
@@ -42,11 +40,9 @@ const (
|
||||
var ValidExtensionPermissions = map[string]bool{
|
||||
ExtPermSecretsRead: true,
|
||||
ExtPermNotificationsSend: true,
|
||||
ExtPermFiltersPreCompletion: true,
|
||||
ExtPermDBRead: true,
|
||||
ExtPermDBWrite: true,
|
||||
ExtPermAPIHTTP: true,
|
||||
ExtPermProviderComplete: true,
|
||||
ExtPermFormValidate: true,
|
||||
ExtPermWorkflowAccess: true,
|
||||
ExtPermConnectionsRead: true,
|
||||
|
||||
@@ -27,7 +27,6 @@ const (
|
||||
NotifTypeKBReady = "kb.ready"
|
||||
NotifTypeKBError = "kb.error"
|
||||
NotifTypeGrantChanged = "grant.changed"
|
||||
NotifTypeMemoryExtracted = "memory.extracted"
|
||||
NotifTypeUserMentioned = "user.mentioned"
|
||||
NotifTypeWorkflowAssign = "workflow.assigned"
|
||||
NotifTypeWorkflowClaimed = "workflow.claimed"
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -57,7 +57,7 @@ type WorkflowStage struct {
|
||||
PersonaID *string `json:"persona_id,omitempty"`
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
FormTemplate json.RawMessage `json:"form_template"`
|
||||
StageMode string `json:"stage_mode"` // chat_only | form_only | form_chat | review
|
||||
StageMode string `json:"stage_mode"` // form_only | form_chat | review | custom
|
||||
HistoryMode string `json:"history_mode"` // full | summary | fresh
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
TransitionRules json.RawMessage `json:"transition_rules"`
|
||||
@@ -69,7 +69,7 @@ type WorkflowStage struct {
|
||||
// ── Stage Mode Constants ────────────────────
|
||||
|
||||
const (
|
||||
StageModeChatOnly = "chat_only"
|
||||
StageModeCustom = "custom"
|
||||
StageModeFormOnly = "form_only"
|
||||
StageModeFormChat = "form_chat"
|
||||
StageModeReview = "review"
|
||||
@@ -77,7 +77,7 @@ const (
|
||||
|
||||
// ValidStageModes is the set of valid stage_mode values.
|
||||
var ValidStageModes = map[string]bool{
|
||||
StageModeChatOnly: true,
|
||||
StageModeCustom: true,
|
||||
StageModeFormOnly: true,
|
||||
StageModeFormChat: true,
|
||||
StageModeReview: true,
|
||||
|
||||
@@ -145,30 +145,6 @@ func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory Extraction ──────────────────────
|
||||
// Called from memory/extractor.go after successful extraction.
|
||||
|
||||
// NotifyMemoryExtracted creates a notification when new memories are extracted.
|
||||
func NotifyMemoryExtracted(svc *Service, userID, channelID string, count int) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
title := fmt.Sprintf("%d new memories extracted", count)
|
||||
if count == 1 {
|
||||
title = "1 new memory extracted"
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: userID,
|
||||
Type: models.NotifTypeMemoryExtracted,
|
||||
Title: title,
|
||||
ResourceType: models.ResourceTypeChannel,
|
||||
ResourceID: channelID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] memory.extracted failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Mention ───────────────────────────
|
||||
// Called from handlers/completion.go when an @mention targets a human user.
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/providers"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
@@ -137,7 +136,6 @@ func (e *Engine) registerLoaders() {
|
||||
e.RegisterLoader("projects", e.projectsLoader)
|
||||
}
|
||||
|
||||
// ListDataProviders returns the keys of all registered data providers.
|
||||
// Used for manifest validation — DataRequires entries must match a key here.
|
||||
func (e *Engine) ListDataProviders() []string {
|
||||
keys := make([]string, 0, len(e.loaders))
|
||||
@@ -331,7 +329,6 @@ func sectionCategory(section string) string {
|
||||
|
||||
// loadProviderTypes returns the registered provider type metadata.
|
||||
func loadProviderTypes() []ProviderTypeOption {
|
||||
types := providers.ListTypes()
|
||||
out := make([]ProviderTypeOption, 0, len(types))
|
||||
for _, t := range types {
|
||||
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})
|
||||
|
||||
@@ -624,9 +624,9 @@ type WorkflowPageData struct {
|
||||
ChannelDescription string
|
||||
SessionID string
|
||||
SessionName string
|
||||
StageMode string // chat_only | form_only | form_chat | review
|
||||
StageMode string // custom | form_only | form_chat | review
|
||||
StageName string
|
||||
FormTemplateJSON string // typed form template JSON (empty if chat_only)
|
||||
FormTemplateJSON string // typed form template JSON (empty if custom)
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
|
||||
@@ -648,7 +648,7 @@ type WorkflowLandingPageData struct {
|
||||
PersonaName string
|
||||
PersonaIcon string
|
||||
StageCount int
|
||||
FirstStageMode string // chat_only | form_only | form_chat (v0.29.3)
|
||||
FirstStageMode string // custom | form_only | form_chat (v0.29.3)
|
||||
ResumeURL string // non-empty if visitor has an active session
|
||||
}
|
||||
|
||||
@@ -661,61 +661,17 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
|
||||
// Load channel metadata
|
||||
var title, description string
|
||||
if e.stores.Channels != nil && channelID != "" {
|
||||
ch, err := e.stores.Channels.GetByID(c.Request.Context(), channelID)
|
||||
if err == nil {
|
||||
title = ch.Title
|
||||
description = ch.Description
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Workflow"
|
||||
}
|
||||
|
||||
// Load session display name
|
||||
sessionName := "Visitor"
|
||||
if e.stores.Sessions != nil && sessionID != "" {
|
||||
sp, err := e.stores.Sessions.GetByID(c.Request.Context(), sessionID)
|
||||
if err == nil {
|
||||
sessionName = sp.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Load workflow stage info for form rendering (v0.29.3)
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "chat_only" // default
|
||||
if e.stores.Channels != nil && channelID != "" {
|
||||
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
|
||||
if wsErr == nil && ws != nil && ws.WorkflowID != nil {
|
||||
currentStage = ws.CurrentStage
|
||||
|
||||
// v0.35.0: Load workflow branding
|
||||
if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil {
|
||||
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
|
||||
brandingJSON = string(wf.Branding)
|
||||
}
|
||||
}
|
||||
|
||||
if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
|
||||
totalStages = len(stages)
|
||||
if currentStage < len(stages) {
|
||||
stg := stages[currentStage]
|
||||
stageMode = stg.StageMode
|
||||
stageName = stg.Name
|
||||
if stageMode == "" {
|
||||
stageMode = "chat_only"
|
||||
}
|
||||
if stageMode != "chat_only" {
|
||||
formTplJSON = string(stg.FormTemplate)
|
||||
}
|
||||
if stg.SurfacePkgID != nil {
|
||||
surfacePkgID = *stg.SurfacePkgID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stageMode = "custom" // default
|
||||
|
||||
instanceName, _, _ := e.loadBranding()
|
||||
|
||||
@@ -791,7 +747,7 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
|
||||
if len(stages) > 0 {
|
||||
data.FirstStageMode = stages[0].StageMode
|
||||
if data.FirstStageMode == "" {
|
||||
data.FirstStageMode = "chat_only"
|
||||
data.FirstStageMode = "custom"
|
||||
}
|
||||
if stages[0].PersonaID != nil {
|
||||
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
|
||||
@@ -803,12 +759,6 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
|
||||
|
||||
// Check for existing active session
|
||||
sbSession, err := c.Cookie("sb_session")
|
||||
if err == nil && sbSession != "" && e.stores.Sessions != nil {
|
||||
sess, err := e.stores.Sessions.GetByToken(ctx, sbSession)
|
||||
if err == nil && sess != nil {
|
||||
data.ResumeURL = "/w/" + sess.ChannelID
|
||||
}
|
||||
}
|
||||
|
||||
instanceName, _, _ := e.loadBranding()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -44,8 +44,7 @@ type RunContext struct {
|
||||
// UserID is the acting user for provider resolution (BYOK chain).
|
||||
UserID string
|
||||
|
||||
// ChannelID is the channel context, if any. Used for provider
|
||||
// resolution when a channel has a pinned provider config.
|
||||
// ChannelID is the context identifier, if any.
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
@@ -55,7 +54,6 @@ type Runner struct {
|
||||
stores store.Stores
|
||||
packagesDir string // v0.38.0: disk path for load() support
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
@@ -75,11 +73,6 @@ func (r *Runner) SetNotifier(n NotificationSender) {
|
||||
r.notifier = n
|
||||
}
|
||||
|
||||
// SetProviderResolver attaches the provider resolver for the provider.complete module.
|
||||
func (r *Runner) SetProviderResolver(pr ProviderResolver) {
|
||||
r.resolver = pr
|
||||
}
|
||||
|
||||
// SetConnectionResolver attaches the connection resolver for the connections module (v0.38.1).
|
||||
func (r *Runner) SetConnectionResolver(cr ConnectionResolver) {
|
||||
r.connResolver = cr
|
||||
@@ -315,11 +308,6 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
||||
httpCfg.AllowPrivateIPs = r.allowPrivateIPs
|
||||
modules["http"] = BuildHTTPModule(ctx, httpCfg)
|
||||
|
||||
case models.ExtPermProviderComplete:
|
||||
if r.resolver != nil && rc != nil && rc.UserID != "" {
|
||||
provCfg, ok := ParseRequiresProvider(manifest)
|
||||
if ok {
|
||||
modules["provider"] = BuildProviderModule(ctx, r.resolver, rc.UserID, provCfg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,6 @@ import (
|
||||
func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module {
|
||||
return MakeModule("workflow", starlark.StringDict{
|
||||
"get_definition": starlark.NewBuiltin("workflow.get_definition", workflowGetDef(ctx, stores)),
|
||||
"get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)),
|
||||
"advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)),
|
||||
"reject": starlark.NewBuiltin("workflow.reject", workflowReject(ctx, stores)),
|
||||
"route": starlark.NewBuiltin("workflow.route", workflowRoute(ctx, stores)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,135 +78,5 @@ func workflowGetDef(ctx context.Context, stores store.Stores) func(*starlark.Thr
|
||||
}
|
||||
}
|
||||
|
||||
func workflowGetStageData(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var channelID string
|
||||
if err := starlark.UnpackPositionalArgs("workflow.get_stage_data", args, kwargs, 1, &channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil {
|
||||
return starlark.None, fmt.Errorf("workflow.get_stage_data: channel not found or not a workflow")
|
||||
}
|
||||
|
||||
result := starlark.NewDict(8)
|
||||
result.SetKey(starlark.String("current_stage"), starlark.MakeInt(ws.CurrentStage))
|
||||
result.SetKey(starlark.String("status"), starlark.String(ws.Status))
|
||||
|
||||
if ws.StageData != nil {
|
||||
var data map[string]any
|
||||
if json.Unmarshal(ws.StageData, &data) == nil {
|
||||
for k, v := range data {
|
||||
sv, err := goToStarlark(v)
|
||||
if err == nil {
|
||||
result.SetKey(starlark.String(k), sv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
func workflowAdvance(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var channelID string
|
||||
if err := starlark.UnpackPositionalArgs("workflow.advance", args, kwargs, 1, &channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: channel not found or not a workflow")
|
||||
}
|
||||
|
||||
stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: %w", err)
|
||||
}
|
||||
|
||||
nextStage, err := workflow.ResolveNextStage(stages, ws.CurrentStage, ws.StageData)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: routing error: %w", err)
|
||||
}
|
||||
if nextStage >= len(stages) {
|
||||
// Complete the workflow
|
||||
if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: %w", err)
|
||||
}
|
||||
return starlark.String("completed"), nil
|
||||
}
|
||||
|
||||
if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: %w", err)
|
||||
}
|
||||
return starlark.String("advanced"), nil
|
||||
}
|
||||
}
|
||||
|
||||
func workflowReject(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var channelID, reason string
|
||||
if err := starlark.UnpackPositionalArgs("workflow.reject", args, kwargs, 2, &channelID, &reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil {
|
||||
return starlark.None, fmt.Errorf("workflow.reject: channel not found or not a workflow")
|
||||
}
|
||||
|
||||
if ws.CurrentStage <= 0 {
|
||||
return starlark.None, fmt.Errorf("workflow.reject: already at stage 0")
|
||||
}
|
||||
|
||||
prevStage := ws.CurrentStage - 1
|
||||
if err := stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.reject: %w", err)
|
||||
}
|
||||
return starlark.String("rejected"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// workflowRoute routes the workflow to a named stage (v0.35.0).
|
||||
// Starlark: workflow.route(channel_id, target_stage, reason)
|
||||
func workflowRoute(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var channelID, targetStage, reason string
|
||||
if err := starlark.UnpackPositionalArgs("workflow.route", args, kwargs, 3, &channelID, &targetStage, &reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: channel not found or not a workflow")
|
||||
}
|
||||
if ws.Status != "active" {
|
||||
return starlark.None, fmt.Errorf("workflow.route: workflow is %s", ws.Status)
|
||||
}
|
||||
|
||||
stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
|
||||
targetOrdinal, err := workflow.ResolveStageByName(stages, targetStage)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
|
||||
if targetOrdinal >= len(stages) {
|
||||
if err := stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
return starlark.String("completed"), nil
|
||||
}
|
||||
|
||||
if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
return starlark.String("routed"), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -33,14 +33,12 @@ type Stores struct {
|
||||
ResourceGrants ResourceGrantStore
|
||||
Notifications NotificationStore
|
||||
NotifPrefs NotificationPreferenceStore
|
||||
Sessions SessionStore
|
||||
Presence PresenceStore // v0.29.0: User online/offline status
|
||||
Health HealthStore // v0.29.0: Provider health window management
|
||||
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
||||
Dependencies DependencyStore // v0.38.2: Library package dependency graph
|
||||
Packages PackageStore // v0.28.7: Unified package registry (surfaces + extensions)
|
||||
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
|
||||
Tasks TaskStore // v0.27.1: Task scheduling + run history
|
||||
ExtPermissions ExtensionPermissionStore // v0.29.0: Extension declared/granted capabilities
|
||||
ExtData ExtDataStore // v0.29.2: Extension namespaced table catalog
|
||||
Tickets TicketStore // v0.32.0: WS auth tickets (PG-backed for cross-pod)
|
||||
@@ -312,19 +310,6 @@ type NotificationPreferenceStore interface {
|
||||
// SESSION STORE (v0.24.3)
|
||||
// =========================================
|
||||
|
||||
type SessionStore interface {
|
||||
Create(ctx context.Context, s *models.SessionParticipant) error
|
||||
GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error)
|
||||
GetByID(ctx context.Context, id string) (*models.SessionParticipant, error)
|
||||
ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error)
|
||||
CountForChannel(ctx context.Context, channelID string) (int, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// DeleteExpired removes sessions older than the given time that have
|
||||
// no associated messages. Returns the number of deleted rows.
|
||||
// Used by the background session cleanup job (v0.26.0).
|
||||
DeleteExpired(ctx context.Context, olderThan time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// HEALTH STORE (v0.29.0)
|
||||
|
||||
@@ -2,191 +2,42 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/database"
|
||||
)
|
||||
|
||||
// HealthStore implements health.Store for Postgres.
|
||||
type HealthStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
// ── HealthStore ─────────────────────────────
|
||||
|
||||
func NewHealthStore(db *sql.DB) *HealthStore {
|
||||
return &HealthStore{db: db}
|
||||
}
|
||||
type HealthStore struct{}
|
||||
|
||||
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO provider_health (provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
|
||||
ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
|
||||
request_count = provider_health.request_count + EXCLUDED.request_count,
|
||||
error_count = provider_health.error_count + EXCLUDED.error_count,
|
||||
timeout_count = provider_health.timeout_count + EXCLUDED.timeout_count,
|
||||
rate_limit_count = provider_health.rate_limit_count + EXCLUDED.rate_limit_count,
|
||||
total_latency_ms = provider_health.total_latency_ms + EXCLUDED.total_latency_ms,
|
||||
max_latency_ms = GREATEST(provider_health.max_latency_ms, EXCLUDED.max_latency_ms),
|
||||
last_error = COALESCE(EXCLUDED.last_error, provider_health.last_error),
|
||||
last_error_at = COALESCE(EXCLUDED.last_error_at, provider_health.last_error_at),
|
||||
updated_at = now()
|
||||
`, w.ProviderConfigID, w.WindowStart,
|
||||
w.RequestCount, w.ErrorCount, w.TimeoutCount, w.RateLimitCount,
|
||||
w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) {
|
||||
windowStart := time.Now().UTC().Truncate(time.Hour)
|
||||
var w models.ProviderHealthWindow
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at
|
||||
FROM provider_health
|
||||
WHERE provider_config_id = $1 AND window_start = $2
|
||||
`, providerConfigID, windowStart).Scan(
|
||||
&w.ID, &w.ProviderConfigID, &w.WindowStart,
|
||||
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
|
||||
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &w, err
|
||||
}
|
||||
|
||||
func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at
|
||||
FROM provider_health
|
||||
WHERE provider_config_id = $1
|
||||
ORDER BY window_start DESC
|
||||
LIMIT $2
|
||||
`, providerConfigID, hours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.ProviderHealthWindow
|
||||
for rows.Next() {
|
||||
var w models.ProviderHealthWindow
|
||||
if err := rows.Scan(
|
||||
&w.ID, &w.ProviderConfigID, &w.WindowStart,
|
||||
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
|
||||
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) {
|
||||
windowStart := time.Now().UTC().Truncate(time.Hour)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at
|
||||
FROM provider_health
|
||||
WHERE window_start = $1
|
||||
ORDER BY provider_config_id
|
||||
`, windowStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []models.ProviderHealthWindow
|
||||
for rows.Next() {
|
||||
var w models.ProviderHealthWindow
|
||||
if err := rows.Scan(
|
||||
&w.ID, &w.ProviderConfigID, &w.WindowStart,
|
||||
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
|
||||
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
func NewHealthStore() *HealthStore { return &HealthStore{} }
|
||||
|
||||
// Prune deletes stale kernel data older than the given time.
|
||||
// Cleans: expired ws_tickets, old rate_limit_counters, stale presence.
|
||||
func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
DELETE FROM provider_health WHERE window_start < $1
|
||||
`, before)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Also prune tool health
|
||||
s.db.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < $1`, before)
|
||||
return result.RowsAffected()
|
||||
var total int64
|
||||
|
||||
res, _ := database.DB.ExecContext(ctx,
|
||||
`DELETE FROM ws_tickets WHERE expires_at < $1`, before)
|
||||
if res != nil {
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
|
||||
// ── Tool Health (v0.22.4) ────────────────────
|
||||
|
||||
func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO tool_health (tool_name, window_start,
|
||||
request_count, error_count, total_latency_ms, max_latency_ms,
|
||||
last_error, last_error_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
||||
ON CONFLICT (tool_name, window_start) DO UPDATE SET
|
||||
request_count = tool_health.request_count + EXCLUDED.request_count,
|
||||
error_count = tool_health.error_count + EXCLUDED.error_count,
|
||||
total_latency_ms = tool_health.total_latency_ms + EXCLUDED.total_latency_ms,
|
||||
max_latency_ms = GREATEST(tool_health.max_latency_ms, EXCLUDED.max_latency_ms),
|
||||
last_error = COALESCE(EXCLUDED.last_error, tool_health.last_error),
|
||||
last_error_at = COALESCE(EXCLUDED.last_error_at, tool_health.last_error_at),
|
||||
updated_at = now()
|
||||
`, w.ToolName, w.WindowStart,
|
||||
w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs,
|
||||
w.LastError, w.LastErrorAt,
|
||||
)
|
||||
return err
|
||||
res, _ = database.DB.ExecContext(ctx,
|
||||
`DELETE FROM rate_limit_counters WHERE window_start < $1`, before)
|
||||
if res != nil {
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
|
||||
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
|
||||
for rows.Next() {
|
||||
var w models.ToolHealthWindow
|
||||
if err := rows.Scan(
|
||||
&w.ID, &w.ToolName, &w.WindowStart,
|
||||
&w.RequestCount, &w.ErrorCount, &w.TotalLatencyMs, &w.MaxLatencyMs,
|
||||
&w.LastError, &w.LastErrorAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
res, _ = database.DB.ExecContext(ctx,
|
||||
`DELETE FROM user_presence WHERE last_seen < $1 AND status = 'offline'`, before)
|
||||
if res != nil {
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
|
||||
// DeactivateProvider marks a provider config as inactive (for auto-disable).
|
||||
func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE provider_configs SET is_active = false WHERE id = $1`, configID)
|
||||
return err
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -19,14 +19,12 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
Sessions: NewSessionStore(),
|
||||
Presence: NewPresenceStore(),
|
||||
Health: NewHealthStore(db),
|
||||
Connections: NewConnectionStore(),
|
||||
Dependencies: NewDependencyStore(),
|
||||
Packages: NewPackageStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
Tasks: NewTaskStore(),
|
||||
ExtPermissions: NewExtensionPermissionStore(db),
|
||||
ExtData: NewExtDataStore(db),
|
||||
Tickets: NewTicketStore(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
stageMode := st.StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = models.StageModeChatOnly
|
||||
stageMode = models.StageModeCustom
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
@@ -267,7 +267,7 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
stageMode := st.StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = models.StageModeChatOnly
|
||||
stageMode = models.StageModeCustom
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_stages
|
||||
@@ -377,207 +377,6 @@ func nullIfEmpty(s string) interface{} {
|
||||
|
||||
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error {
|
||||
a.ID = store.NewID()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, a.ID, a.ChannelID, a.Stage, a.TeamID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments
|
||||
WHERE team_id = $1 AND status = $2
|
||||
ORDER BY created_at DESC
|
||||
`, teamID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
|
||||
wa.created_at, wa.claimed_at, wa.completed_at
|
||||
FROM workflow_assignments wa
|
||||
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = $1
|
||||
WHERE (wa.assigned_to = $2 AND wa.status = 'claimed')
|
||||
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
|
||||
ORDER BY wa.created_at DESC
|
||||
`, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, status = 'claimed', claimed_at = $2
|
||||
WHERE id = $3 AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'completed', completed_at = $1
|
||||
WHERE id = $2 AND status = 'claimed'
|
||||
`, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) {
|
||||
var channelID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT channel_id FROM workflow_assignments WHERE id = $1`, assignmentID).Scan(&channelID)
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE channel_id = $1 AND status IN ('unassigned', 'claimed')
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
|
||||
WHERE id = $1 AND status = 'claimed'
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, claimed_at = $2
|
||||
WHERE id = $3 AND status = 'claimed'
|
||||
`, newUserID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE id = $1 AND status IN ('unassigned', 'claimed')
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
// Find least-recently-assigned team member
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
|
||||
FROM team_members m
|
||||
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
|
||||
WHERE m.team_id = $2
|
||||
GROUP BY m.user_id
|
||||
ORDER BY last_claim ASC
|
||||
LIMIT 1
|
||||
`, teamID, teamID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return "", nil // no team members
|
||||
}
|
||||
var userID, lastClaim string
|
||||
if err := rows.Scan(&userID, &lastClaim); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Claim for that user
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, status = 'claimed', claimed_at = $2
|
||||
WHERE id = $3 AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC(), assignmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
var result []store.WorkflowAssignment
|
||||
for rows.Next() {
|
||||
var a store.WorkflowAssignment
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
if result == nil {
|
||||
result = []store.WorkflowAssignment{}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
review_comments, created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = rc
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET review_comments = review_comments || $1::jsonb
|
||||
WHERE id = $2
|
||||
`, "["+string(commentJSON)+"]", assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,188 +2,42 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/database"
|
||||
)
|
||||
|
||||
// ── HealthStore ─────────────────────────────
|
||||
|
||||
type HealthStore struct{}
|
||||
|
||||
func NewHealthStore() *HealthStore { return &HealthStore{} }
|
||||
|
||||
func (s *HealthStore) UpsertWindow(ctx context.Context, w *models.ProviderHealthWindow) error {
|
||||
id := store.NewID()
|
||||
windowStr := w.WindowStart.Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO provider_health (id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (provider_config_id, window_start) DO UPDATE SET
|
||||
request_count = provider_health.request_count + excluded.request_count,
|
||||
error_count = provider_health.error_count + excluded.error_count,
|
||||
timeout_count = provider_health.timeout_count + excluded.timeout_count,
|
||||
rate_limit_count = provider_health.rate_limit_count + excluded.rate_limit_count,
|
||||
total_latency_ms = provider_health.total_latency_ms + excluded.total_latency_ms,
|
||||
max_latency_ms = MAX(provider_health.max_latency_ms, excluded.max_latency_ms),
|
||||
last_error = COALESCE(excluded.last_error, provider_health.last_error),
|
||||
last_error_at = COALESCE(excluded.last_error_at, provider_health.last_error_at),
|
||||
updated_at = datetime('now')
|
||||
`, id, w.ProviderConfigID, windowStr,
|
||||
w.RequestCount, w.ErrorCount, w.TimeoutCount, w.RateLimitCount,
|
||||
w.TotalLatencyMs, w.MaxLatencyMs, w.LastError, w.LastErrorAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *HealthStore) GetCurrentWindow(ctx context.Context, providerConfigID string) (*models.ProviderHealthWindow, error) {
|
||||
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
|
||||
var w models.ProviderHealthWindow
|
||||
var windowStartStr string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at
|
||||
FROM provider_health
|
||||
WHERE provider_config_id = ? AND window_start = ?
|
||||
`, providerConfigID, windowStr).Scan(
|
||||
&w.ID, &w.ProviderConfigID, &windowStartStr,
|
||||
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
|
||||
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.WindowStart, _ = time.Parse(timeFmt, windowStartStr)
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (s *HealthStore) ListWindows(ctx context.Context, providerConfigID string, hours int) ([]models.ProviderHealthWindow, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at
|
||||
FROM provider_health
|
||||
WHERE provider_config_id = ?
|
||||
ORDER BY window_start DESC
|
||||
LIMIT ?
|
||||
`, providerConfigID, hours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanHealthRows(rows)
|
||||
}
|
||||
|
||||
func (s *HealthStore) ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) {
|
||||
windowStr := time.Now().UTC().Truncate(time.Hour).Format(timeFmt)
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, provider_config_id, window_start,
|
||||
request_count, error_count, timeout_count, rate_limit_count,
|
||||
total_latency_ms, max_latency_ms, last_error, last_error_at
|
||||
FROM provider_health
|
||||
WHERE window_start = ?
|
||||
ORDER BY provider_config_id
|
||||
`, windowStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanHealthRows(rows)
|
||||
}
|
||||
|
||||
// Prune deletes stale kernel data older than the given time.
|
||||
func (s *HealthStore) Prune(ctx context.Context, before time.Time) (int64, error) {
|
||||
result, err := DB.ExecContext(ctx, `
|
||||
DELETE FROM provider_health WHERE window_start < ?
|
||||
`, before.Format(timeFmt))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
DB.ExecContext(ctx, `DELETE FROM tool_health WHERE window_start < ?`, before.Format(timeFmt))
|
||||
return result.RowsAffected()
|
||||
var total int64
|
||||
ts := before.UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
res, _ := database.DB.ExecContext(ctx,
|
||||
`DELETE FROM ws_tickets WHERE expires_at < ?`, ts)
|
||||
if res != nil {
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
|
||||
// ── Tool Health (v0.22.4) ────────────────────
|
||||
|
||||
func (s *HealthStore) UpsertToolWindow(ctx context.Context, w *models.ToolHealthWindow) error {
|
||||
id := store.NewID()
|
||||
windowStr := w.WindowStart.Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO tool_health (id, tool_name, window_start,
|
||||
request_count, error_count, total_latency_ms, max_latency_ms,
|
||||
last_error, last_error_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT (tool_name, window_start) DO UPDATE SET
|
||||
request_count = tool_health.request_count + excluded.request_count,
|
||||
error_count = tool_health.error_count + excluded.error_count,
|
||||
total_latency_ms = tool_health.total_latency_ms + excluded.total_latency_ms,
|
||||
max_latency_ms = MAX(tool_health.max_latency_ms, excluded.max_latency_ms),
|
||||
last_error = COALESCE(excluded.last_error, tool_health.last_error),
|
||||
last_error_at = COALESCE(excluded.last_error_at, tool_health.last_error_at),
|
||||
updated_at = datetime('now')
|
||||
`, id, w.ToolName, windowStr,
|
||||
w.RequestCount, w.ErrorCount, w.TotalLatencyMs, w.MaxLatencyMs,
|
||||
w.LastError, w.LastErrorAt,
|
||||
)
|
||||
return err
|
||||
res, _ = database.DB.ExecContext(ctx,
|
||||
`DELETE FROM rate_limit_counters WHERE window_start < ?`, ts)
|
||||
if res != nil {
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
|
||||
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()
|
||||
res, _ = database.DB.ExecContext(ctx,
|
||||
`DELETE FROM user_presence WHERE last_seen < ? AND status = 'offline'`, ts)
|
||||
if res != nil {
|
||||
n, _ := res.RowsAffected()
|
||||
total += n
|
||||
}
|
||||
|
||||
func (s *HealthStore) DeactivateProvider(ctx context.Context, configID string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE provider_configs SET is_active = 0 WHERE id = ?`, configID)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanHealthRows(rows *sql.Rows) ([]models.ProviderHealthWindow, error) {
|
||||
var result []models.ProviderHealthWindow
|
||||
for rows.Next() {
|
||||
var w models.ProviderHealthWindow
|
||||
var windowStartStr string
|
||||
if err := rows.Scan(
|
||||
&w.ID, &w.ProviderConfigID, &windowStartStr,
|
||||
&w.RequestCount, &w.ErrorCount, &w.TimeoutCount, &w.RateLimitCount,
|
||||
&w.TotalLatencyMs, &w.MaxLatencyMs, &w.LastError, &w.LastErrorAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.WindowStart, _ = time.Parse(timeFmt, windowStartStr)
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -19,14 +19,12 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
ResourceGrants: NewResourceGrantStore(),
|
||||
Notifications: NewNotificationStore(),
|
||||
NotifPrefs: NewNotificationPreferenceStore(),
|
||||
Sessions: NewSessionStore(),
|
||||
Presence: NewPresenceStore(),
|
||||
Health: NewHealthStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
Dependencies: NewDependencyStore(),
|
||||
Packages: NewPackageStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
Tasks: NewTaskStore(),
|
||||
ExtPermissions: NewExtensionPermissionStore(),
|
||||
ExtData: NewExtDataStore(),
|
||||
Tickets: NewTicketStore(),
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
type TaskStore struct{}
|
||||
|
||||
func NewTaskStore() *TaskStore { return &TaskStore{} }
|
||||
|
||||
// ── Full column list (single source of truth) ──────────────────
|
||||
|
||||
// taskColumns is the canonical SELECT list for tasks.
|
||||
// COALESCE wraps nullable columns that scan into non-pointer Go types.
|
||||
// tool_grants uses COALESCE to '[]' so the string intermediate never sees NULL.
|
||||
const taskColumns = `id, owner_id, team_id, name, description, scope,
|
||||
task_type, COALESCE(system_function, '') AS system_function,
|
||||
persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, COALESCE(tool_grants, '[]') AS tool_grants,
|
||||
schedule, timezone, is_active,
|
||||
COALESCE(trigger_token, '') AS trigger_token,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
output_channel_id,
|
||||
COALESCE(webhook_url, '') AS webhook_url,
|
||||
COALESCE(webhook_secret, '') AS webhook_secret,
|
||||
provider_config_id,
|
||||
notify_on_complete, notify_on_failure,
|
||||
last_run_at, next_run_at, run_count, created_at, updated_at`
|
||||
|
||||
// scanTask scans a full task row into the model.
|
||||
//
|
||||
// SQLite dialect issues handled here:
|
||||
// - tool_grants: modernc driver returns TEXT as Go string, not []byte.
|
||||
// json.RawMessage (named []byte) can't accept string via convertAssign.
|
||||
// Scan into string intermediate, then cast.
|
||||
// - is_active, notify_on_complete, notify_on_failure: stored as INTEGER
|
||||
// (0/1). modernc returns int64, but convertAssign can't assign int64
|
||||
// to *bool. Scan into int intermediate, then convert.
|
||||
//
|
||||
// These match the patterns in store/sqlite/workflows.go.
|
||||
func scanTask(scanner interface{ Scan(...interface{}) error }, t *models.Task) error {
|
||||
var toolGrantsStr string
|
||||
var isActive, notifyComplete, notifyFailure int
|
||||
err := scanner.Scan(
|
||||
&t.ID, &t.OwnerID, &t.TeamID, &t.Name, &t.Description, &t.Scope,
|
||||
&t.TaskType, &t.SystemFunction,
|
||||
&t.PersonaID, &t.ModelID, &t.SystemPrompt, &t.UserPrompt,
|
||||
&t.WorkflowID, &toolGrantsStr, &t.Schedule, &t.Timezone, &isActive,
|
||||
&t.TriggerToken,
|
||||
&t.MaxTokens, &t.MaxToolCalls, &t.MaxWallClock, &t.OutputMode,
|
||||
&t.OutputChannelID, &t.WebhookURL, &t.WebhookSecret, &t.ProviderConfigID,
|
||||
¬ifyComplete, ¬ifyFailure,
|
||||
stN(&t.LastRunAt), stN(&t.NextRunAt), &t.RunCount, st(&t.CreatedAt), st(&t.UpdatedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.ToolGrants = json.RawMessage(toolGrantsStr)
|
||||
t.IsActive = isActive != 0
|
||||
t.NotifyOnComplete = notifyComplete != 0
|
||||
t.NotifyOnFailure = notifyFailure != 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── CRUD ───────────────────────────────────────
|
||||
|
||||
func (s *TaskStore) Create(ctx context.Context, t *models.Task) error {
|
||||
t.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
t.CreatedAt = now
|
||||
t.UpdatedAt = now
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, owner_id, team_id, name, description, scope,
|
||||
task_type, system_function,
|
||||
persona_id, model_id, system_prompt, user_prompt,
|
||||
workflow_id, tool_grants, schedule, timezone, is_active,
|
||||
trigger_token,
|
||||
max_tokens, max_tool_calls, max_wall_clock, output_mode,
|
||||
output_channel_id, webhook_url, webhook_secret, provider_config_id,
|
||||
notify_on_complete, notify_on_failure, next_run_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.OwnerID, t.TeamID, t.Name, t.Description, t.Scope,
|
||||
t.TaskType, t.SystemFunction,
|
||||
t.PersonaID, t.ModelID, t.SystemPrompt, t.UserPrompt,
|
||||
t.WorkflowID, nullableJSON(t.ToolGrants), t.Schedule, t.Timezone, boolToInt(t.IsActive),
|
||||
nilIfEmpty(t.TriggerToken),
|
||||
t.MaxTokens, t.MaxToolCalls, t.MaxWallClock, t.OutputMode,
|
||||
t.OutputChannelID, t.WebhookURL, t.WebhookSecret, t.ProviderConfigID,
|
||||
boolToInt(t.NotifyOnComplete), boolToInt(t.NotifyOnFailure), timeToSQLite(t.NextRunAt))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) GetByID(ctx context.Context, id string) (*models.Task, error) {
|
||||
t := &models.Task{}
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE id = ?`, id)
|
||||
if err := scanTask(row, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *TaskStore) GetByTriggerToken(ctx context.Context, token string) (*models.Task, error) {
|
||||
t := &models.Task{}
|
||||
row := DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE trigger_token = ?`, token)
|
||||
if err := scanTask(row, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *TaskStore) Update(ctx context.Context, id string, p models.TaskPatch) error {
|
||||
q := "UPDATE tasks SET updated_at = datetime('now')"
|
||||
args := []interface{}{}
|
||||
if p.Name != nil { q += ", name = ?"; args = append(args, *p.Name) }
|
||||
if p.Description != nil { q += ", description = ?"; args = append(args, *p.Description) }
|
||||
if p.PersonaID != nil { q += ", persona_id = ?"; args = append(args, *p.PersonaID) }
|
||||
if p.ModelID != nil { q += ", model_id = ?"; args = append(args, *p.ModelID) }
|
||||
if p.SystemPrompt != nil { q += ", system_prompt = ?"; args = append(args, *p.SystemPrompt) }
|
||||
if p.UserPrompt != nil { q += ", user_prompt = ?"; args = append(args, *p.UserPrompt) }
|
||||
if p.WorkflowID != nil { q += ", workflow_id = ?"; args = append(args, *p.WorkflowID) }
|
||||
if p.ToolGrants != nil { q += ", tool_grants = ?"; args = append(args, nullableJSON(*p.ToolGrants)) }
|
||||
if p.Schedule != nil { q += ", schedule = ?"; args = append(args, *p.Schedule) }
|
||||
if p.Timezone != nil { q += ", timezone = ?"; args = append(args, *p.Timezone) }
|
||||
if p.IsActive != nil { q += ", is_active = ?"; args = append(args, boolToInt(*p.IsActive)) }
|
||||
if p.MaxTokens != nil { q += ", max_tokens = ?"; args = append(args, *p.MaxTokens) }
|
||||
if p.MaxToolCalls != nil { q += ", max_tool_calls = ?"; args = append(args, *p.MaxToolCalls) }
|
||||
if p.MaxWallClock != nil { q += ", max_wall_clock = ?"; args = append(args, *p.MaxWallClock) }
|
||||
if p.OutputMode != nil { q += ", output_mode = ?"; args = append(args, *p.OutputMode) }
|
||||
if p.OutputChannelID != nil { q += ", output_channel_id = ?"; args = append(args, *p.OutputChannelID) }
|
||||
if p.WebhookURL != nil { q += ", webhook_url = ?"; args = append(args, *p.WebhookURL) }
|
||||
if p.ProviderConfigID != nil { q += ", provider_config_id = ?"; args = append(args, *p.ProviderConfigID) }
|
||||
if p.NotifyOnComplete != nil { q += ", notify_on_complete = ?"; args = append(args, boolToInt(*p.NotifyOnComplete)) }
|
||||
if p.NotifyOnFailure != nil { q += ", notify_on_failure = ?"; args = append(args, boolToInt(*p.NotifyOnFailure)) }
|
||||
q += " WHERE id = ?"
|
||||
args = append(args, id)
|
||||
_, err := DB.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) ListByOwner(ctx context.Context, ownerID string) ([]models.Task, error) {
|
||||
return s.list(ctx, `WHERE owner_id = ? ORDER BY created_at DESC`, ownerID)
|
||||
}
|
||||
|
||||
func (s *TaskStore) ListByTeam(ctx context.Context, teamID string) ([]models.Task, error) {
|
||||
return s.list(ctx, `WHERE team_id = ? ORDER BY created_at DESC`, teamID)
|
||||
}
|
||||
|
||||
func (s *TaskStore) ListAll(ctx context.Context) ([]models.Task, error) {
|
||||
return s.list(ctx, `ORDER BY created_at DESC`)
|
||||
}
|
||||
|
||||
func (s *TaskStore) ClaimDueTask(ctx context.Context) (*models.Task, error) {
|
||||
// SQLite: single-process, no contention. Select the oldest due task,
|
||||
// then atomically clear next_run_at to mark it claimed.
|
||||
t := &models.Task{}
|
||||
row := DB.QueryRowContext(ctx,
|
||||
`SELECT `+taskColumns+` FROM tasks
|
||||
WHERE is_active = 1 AND next_run_at <= datetime('now')
|
||||
ORDER BY next_run_at ASC LIMIT 1`)
|
||||
if err := scanTask(row, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = DB.ExecContext(ctx,
|
||||
`UPDATE tasks SET next_run_at = NULL WHERE id = ?`, t.ID)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ── Scheduler bookkeeping ──────────────────────
|
||||
|
||||
func (s *TaskStore) SetNextRun(ctx context.Context, id string, nextRun interface{}) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET next_run_at = ?, updated_at = datetime('now') WHERE id = ?`, timeToSQLiteAny(nextRun), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) SetLastRun(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET last_run_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) IncrementRunCount(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE tasks SET run_count = run_count + 1, updated_at = datetime('now') WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Run History ─────────────────────────────────
|
||||
|
||||
func (s *TaskStore) CreateRun(ctx context.Context, r *models.TaskRun) error {
|
||||
r.ID = store.NewID()
|
||||
// F4 audit fix: set StartedAt in Go so the returned struct matches PG
|
||||
// behavior (PG returns it via RETURNING; SQLite can't).
|
||||
r.StartedAt = time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO task_runs (id, task_id, channel_id, status, trigger_payload, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, r.ID, r.TaskID, r.ChannelID, r.Status,
|
||||
nilIfEmpty(r.TriggerPayload), r.StartedAt.UTC().Format("2006-01-02 15:04:05"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) CreateRunExclusive(ctx context.Context, taskID string) (*models.TaskRun, error) {
|
||||
// SQLite: check-then-insert. Single-process, no race.
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM task_runs
|
||||
WHERE task_id = ? AND status IN ('running', 'queued')`, taskID).Scan(&count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
r := &models.TaskRun{
|
||||
ID: store.NewID(),
|
||||
TaskID: taskID,
|
||||
Status: "running",
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
INSERT INTO task_runs (id, task_id, status, started_at)
|
||||
VALUES (?, ?, 'running', ?)`,
|
||||
r.ID, taskID, r.StartedAt.UTC().Format("2006-01-02 15:04:05"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *TaskStore) UpdateRun(ctx context.Context, id, status string, tokensUsed, toolCalls, wallClock int, errMsg string) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE task_runs SET status = ?, tokens_used = ?, tool_calls = ?,
|
||||
wall_clock = ?, error = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`, status, tokensUsed, toolCalls, wallClock, errMsg, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) TransitionRunStatus(ctx context.Context, id string, status string) error {
|
||||
_, err := DB.ExecContext(ctx, `UPDATE task_runs SET status = ? WHERE id = ?`, status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TaskStore) GetActiveRun(ctx context.Context, taskID string) (*models.TaskRun, error) {
|
||||
r := &models.TaskRun{}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at
|
||||
FROM task_runs WHERE task_id = ? AND status = 'running'
|
||||
LIMIT 1`, taskID).Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *TaskStore) GetQueuedRun(ctx context.Context, taskID string) (*models.TaskRun, error) {
|
||||
r := &models.TaskRun{}
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''), started_at
|
||||
FROM task_runs WHERE task_id = ? AND status = 'queued'
|
||||
ORDER BY started_at ASC LIMIT 1`, taskID).Scan(
|
||||
&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload, st(&r.StartedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *TaskStore) ListRuns(ctx context.Context, taskID string, limit int) ([]models.TaskRun, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, task_id, channel_id, status, COALESCE(trigger_payload, ''),
|
||||
started_at, completed_at, tokens_used, tool_calls, wall_clock,
|
||||
COALESCE(error, '')
|
||||
FROM task_runs WHERE task_id = ?
|
||||
ORDER BY started_at DESC LIMIT ?`, taskID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var runs []models.TaskRun
|
||||
for rows.Next() {
|
||||
var r models.TaskRun
|
||||
if err := rows.Scan(&r.ID, &r.TaskID, &r.ChannelID, &r.Status, &r.TriggerPayload,
|
||||
st(&r.StartedAt), stN(&r.CompletedAt),
|
||||
&r.TokensUsed, &r.ToolCalls, &r.WallClock, &r.Error); err != nil {
|
||||
continue
|
||||
}
|
||||
runs = append(runs, r)
|
||||
}
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// ── list helper ────────────────────────────────
|
||||
|
||||
func (s *TaskStore) list(ctx context.Context, where string, args ...interface{}) ([]models.Task, error) {
|
||||
q := `SELECT ` + taskColumns + ` FROM tasks ` + where
|
||||
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []models.Task
|
||||
for rows.Next() {
|
||||
var t models.Task
|
||||
if err := scanTask(rows, &t); err != nil {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────
|
||||
|
||||
func nullableJSON(data []byte) interface{} {
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) interface{} {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// timeToSQLite converts *time.Time to the TEXT format that datetime('now')
|
||||
// produces ("2006-01-02 15:04:05"). Returns nil for nil input.
|
||||
// This prevents the modernc driver from serializing time.Time in an
|
||||
// unparseable format (int64 / RFC3339Nano / driver-specific).
|
||||
func timeToSQLite(t *time.Time) interface{} {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// timeToSQLiteAny handles interface{} that may be *time.Time, time.Time, or nil.
|
||||
func timeToSQLiteAny(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case *time.Time:
|
||||
return timeToSQLite(t)
|
||||
case time.Time:
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
stageMode := st.StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = models.StageModeChatOnly
|
||||
stageMode = models.StageModeCustom
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
@@ -203,7 +203,7 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
stageMode := st.StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = models.StageModeChatOnly
|
||||
stageMode = models.StageModeCustom
|
||||
}
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_stages
|
||||
@@ -380,218 +380,6 @@ func nullIfEmpty(s string) interface{} {
|
||||
|
||||
// ── Assignments (v0.29.0-cs3) ───────────────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *store.WorkflowAssignment) error {
|
||||
a.ID = store.NewID()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, a.ID, a.ChannelID, a.Stage, a.TeamID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments
|
||||
WHERE team_id = ? AND status = ?
|
||||
ORDER BY created_at DESC
|
||||
`, teamID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListAssignmentsMine(ctx context.Context, userID string) ([]store.WorkflowAssignment, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT DISTINCT wa.id, wa.channel_id, wa.stage, wa.team_id, wa.assigned_to, wa.status,
|
||||
wa.created_at, wa.claimed_at, wa.completed_at
|
||||
FROM workflow_assignments wa
|
||||
LEFT JOIN team_members tm ON tm.team_id = wa.team_id AND tm.user_id = ?
|
||||
WHERE (wa.assigned_to = ? AND wa.status = 'claimed')
|
||||
OR (wa.status = 'unassigned' AND tm.user_id IS NOT NULL)
|
||||
ORDER BY wa.created_at DESC
|
||||
`, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAssignments(rows)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, status = 'claimed', claimed_at = ?
|
||||
WHERE id = ? AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CompleteAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'completed', completed_at = ?
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error) {
|
||||
var channelID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT channel_id FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&channelID)
|
||||
return channelID, err
|
||||
}
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
func (s *WorkflowStore) CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE channel_id = ? AND status IN ('unassigned', 'claimed')
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'unassigned', assigned_to = NULL, claimed_at = NULL
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, claimed_at = ?
|
||||
WHERE id = ? AND status = 'claimed'
|
||||
`, newUserID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) CancelAssignment(ctx context.Context, assignmentID string) (int64, error) {
|
||||
res, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'cancelled'
|
||||
WHERE id = ? AND status IN ('unassigned', 'claimed')
|
||||
`, assignmentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01 00:00:00') as last_claim
|
||||
FROM team_members m
|
||||
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = ?
|
||||
WHERE m.team_id = ?
|
||||
GROUP BY m.user_id
|
||||
ORDER BY last_claim ASC
|
||||
LIMIT 1
|
||||
`, teamID, teamID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return "", nil
|
||||
}
|
||||
var userID, lastClaim string
|
||||
if err := rows.Scan(&userID, &lastClaim); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = ?, status = 'claimed', claimed_at = ?
|
||||
WHERE id = ? AND status = 'unassigned'
|
||||
`, userID, time.Now().UTC().Format(timeFmt), assignmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
var result []store.WorkflowAssignment
|
||||
for rows.Next() {
|
||||
var a store.WorkflowAssignment
|
||||
var claimedAt, completedAt *time.Time
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ClaimedAt = claimedAt
|
||||
a.CompletedAt = completedAt
|
||||
result = append(result, a)
|
||||
}
|
||||
if result == nil {
|
||||
result = []store.WorkflowAssignment{}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc string
|
||||
var claimedAt, completedAt *time.Time
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
COALESCE(review_comments, '[]'), created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = ?
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = json.RawMessage(rc)
|
||||
a.ClaimedAt = claimedAt
|
||||
a.CompletedAt = completedAt
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite: read, append, write back
|
||||
var existing string
|
||||
err = DB.QueryRowContext(ctx, `SELECT COALESCE(review_comments, '[]') FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
_ = json.Unmarshal([]byte(existing), &arr)
|
||||
arr = append(arr, json.RawMessage(commentJSON))
|
||||
updated, _ := json.Marshal(arr)
|
||||
_, err = DB.ExecContext(ctx, `UPDATE workflow_assignments SET review_comments = ? WHERE id = ?`, string(updated), assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -28,62 +28,4 @@ type WorkflowStore interface {
|
||||
Publish(ctx context.Context, v *models.WorkflowVersion) error
|
||||
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
|
||||
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
|
||||
|
||||
// ── Assignments (v0.29.0-cs3) ──
|
||||
|
||||
// CreateAssignment inserts a workflow_assignments row.
|
||||
CreateAssignment(ctx context.Context, a *WorkflowAssignment) error
|
||||
|
||||
// ListAssignmentsForTeam returns assignments for a team filtered by status.
|
||||
ListAssignmentsForTeam(ctx context.Context, teamID, status string) ([]WorkflowAssignment, error)
|
||||
|
||||
// ListAssignmentsMine returns claimed + unassigned assignments visible to a user.
|
||||
ListAssignmentsMine(ctx context.Context, userID string) ([]WorkflowAssignment, error)
|
||||
|
||||
// ClaimAssignment sets assigned_to and status='claimed' on an unassigned row.
|
||||
// Returns rows affected (0 = already claimed or not found).
|
||||
ClaimAssignment(ctx context.Context, assignmentID, userID string) (int64, error)
|
||||
|
||||
// CompleteAssignment sets status='completed' on a claimed row.
|
||||
// Returns rows affected (0 = not claimed or not found).
|
||||
CompleteAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// GetAssignmentChannelID returns the channel_id for an assignment.
|
||||
GetAssignmentChannelID(ctx context.Context, assignmentID string) (string, error)
|
||||
|
||||
// TryRoundRobin finds the least-recently-assigned team member and claims.
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
|
||||
// ── Lifecycle operations (v0.37.15) ──
|
||||
|
||||
// CancelAssignmentsForChannel sets status='cancelled' on all
|
||||
// unassigned/claimed assignments for a channel.
|
||||
CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error)
|
||||
|
||||
// UnclaimAssignment returns a claimed assignment to unassigned.
|
||||
// Returns rows affected (0 = not claimed or not found).
|
||||
UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// ReassignAssignment changes assigned_to on a claimed assignment.
|
||||
// Returns rows affected.
|
||||
ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error)
|
||||
|
||||
// CancelAssignment sets a single assignment to cancelled.
|
||||
CancelAssignment(ctx context.Context, assignmentID string) (int64, error)
|
||||
|
||||
// ── Review Comments (v0.35.0) ──
|
||||
|
||||
// GetAssignmentByID returns a single assignment by ID.
|
||||
GetAssignmentByID(ctx context.Context, id string) (*WorkflowAssignment, error)
|
||||
|
||||
// AddReviewComment appends a comment to an assignment's review_comments array.
|
||||
AddReviewComment(ctx context.Context, assignmentID string, comment ReviewComment) error
|
||||
}
|
||||
|
||||
// ReviewComment is a single review comment on a workflow assignment (v0.35.0).
|
||||
type ReviewComment struct {
|
||||
Text string `json:"text"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user