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