Changeset 0.26.0 (#165)
This commit is contained in:
@@ -16,7 +16,7 @@ import (
|
||||
// Capability resolution chain:
|
||||
// 1. Catalog (provider API sync) — authoritative per-provider
|
||||
// 2. Heuristic inference (regex on model ID) — best effort
|
||||
// 3. Admin/user override — manual correction (TODO: 0.9.2)
|
||||
// 3. Admin override — manual correction (shipped v0.22.0)
|
||||
//
|
||||
// The catalog is populated when providers are added (auto-fetch) or refreshed.
|
||||
// Heuristics cover broad model families (llama→tools, gemini→vision, etc.)
|
||||
|
||||
@@ -68,6 +68,15 @@ type Config struct {
|
||||
// provider is automatically deactivated. Default 3. Set to 0 to disable.
|
||||
ProviderAutoDisableThreshold int
|
||||
|
||||
// SESSION_EXPIRY_DAYS: anonymous sessions older than this with no messages
|
||||
// are cleaned up by the background session sweeper. Default 30. Set to 0
|
||||
// to disable cleanup.
|
||||
SessionExpiryDays int
|
||||
|
||||
// WORKFLOW_STALE_HOURS: workflow instances with no activity for this many
|
||||
// hours are marked 'stale'. Default 72 (3 days). Set to 0 to disable.
|
||||
WorkflowStaleHours int
|
||||
|
||||
// Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc"
|
||||
AuthMode string
|
||||
|
||||
@@ -131,6 +140,10 @@ func Load() *Config {
|
||||
|
||||
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
|
||||
|
||||
SessionExpiryDays: getEnvInt("SESSION_EXPIRY_DAYS", 30),
|
||||
|
||||
WorkflowStaleHours: getEnvInt("WORKFLOW_STALE_HOURS", 72),
|
||||
|
||||
AuthMode: getEnv("AUTH_MODE", "builtin"),
|
||||
|
||||
// mTLS
|
||||
|
||||
86
server/database/migrations/023_workflows.sql
Normal file
86
server/database/migrations/023_workflows.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
-- 023_workflows.sql
|
||||
-- Workflow definitions, stages, and version snapshots (v0.26.1)
|
||||
-- Depends on: teams, personas, users
|
||||
|
||||
-- ── Workflow Definitions ────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE, -- NULL = global
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
branding JSONB NOT NULL DEFAULT '{}',
|
||||
entry_mode TEXT NOT NULL DEFAULT 'public_link'
|
||||
CHECK (entry_mode IN ('public_link', 'team_only')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT false,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
on_complete JSONB, -- v0.27.0 chaining hook, NULL for now
|
||||
retention JSONB NOT NULL DEFAULT '{"mode": "archive"}',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Slug unique within scope: team-scoped workflows + global workflows
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug
|
||||
ON workflows(team_id, slug) WHERE team_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug
|
||||
ON workflows(slug) WHERE team_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflows_active
|
||||
ON workflows(is_active) WHERE is_active = true;
|
||||
|
||||
COMMENT ON TABLE workflows IS 'Team-owned or global workflow definitions with staged processes.';
|
||||
COMMENT ON COLUMN workflows.slug IS 'URL-safe identifier, unique within scope (team or global).';
|
||||
COMMENT ON COLUMN workflows.branding IS '{"accent_color": "#hex", "logo_url": "...", "tagline": "..."}';
|
||||
COMMENT ON COLUMN workflows.on_complete IS 'v0.27.0: chaining hook — triggers another workflow on completion.';
|
||||
COMMENT ON COLUMN workflows.retention IS '{"mode": "archive"|"delete", "delete_after_days": N}';
|
||||
|
||||
-- ── Workflow Stages ─────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||
assignment_team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
form_template JSONB NOT NULL DEFAULT '{}',
|
||||
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
||||
transition_rules JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow
|
||||
ON workflow_stages(workflow_id, ordinal);
|
||||
|
||||
COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stage has a driving persona and optional human assignment.';
|
||||
COMMENT ON COLUMN workflow_stages.history_mode IS 'full=complete history, summary=utility-role summary, fresh=clean slate';
|
||||
COMMENT ON COLUMN workflow_stages.form_template IS 'JSON schema of fields the persona should collect from the visitor.';
|
||||
|
||||
-- ── Workflow Versions (immutable snapshots) ─
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
snapshot JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (workflow_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow
|
||||
ON workflow_versions(workflow_id, version_number DESC);
|
||||
|
||||
COMMENT ON TABLE workflow_versions IS 'Immutable snapshots of workflow definitions. Active instances run against a pinned version.';
|
||||
COMMENT ON COLUMN workflow_versions.snapshot IS 'Full JSON: definition + stages + persona tool grants at publish time.';
|
||||
|
||||
-- ── updated_at trigger ──────────────────────
|
||||
|
||||
CREATE TRIGGER workflows_updated_at
|
||||
BEFORE UPDATE ON workflows
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
25
server/database/migrations/024_workflow_instances.sql
Normal file
25
server/database/migrations/024_workflow_instances.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 024_workflow_instances.sql
|
||||
-- Workflow instance columns on channels (v0.26.2)
|
||||
-- Channels with type='workflow' become runtime containers for workflow instances.
|
||||
|
||||
ALTER TABLE channels
|
||||
ADD COLUMN IF NOT EXISTS workflow_id UUID REFERENCES workflows(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS workflow_version INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS current_stage INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS stage_data JSONB DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
|
||||
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ DEFAULT NOW();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow
|
||||
ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow_status
|
||||
ON channels(workflow_status) WHERE workflow_status = 'active';
|
||||
|
||||
COMMENT ON COLUMN channels.workflow_id IS 'FK to workflows — set when type=workflow';
|
||||
COMMENT ON COLUMN channels.workflow_version IS 'Pinned version number from workflow_versions';
|
||||
COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stage';
|
||||
COMMENT ON COLUMN channels.stage_data IS 'Accumulated form data collected across stages';
|
||||
COMMENT ON COLUMN channels.workflow_status IS 'active/completed/stale/cancelled';
|
||||
COMMENT ON COLUMN channels.last_activity_at IS 'Updated on each message — used by staleness sweep';
|
||||
27
server/database/migrations/025_workflow_assignments.sql
Normal file
27
server/database/migrations/025_workflow_assignments.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- 025_workflow_assignments.sql
|
||||
-- Assignment queue for workflow stages (v0.26.4)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
stage INTEGER NOT NULL,
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
claimed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel
|
||||
ON workflow_assignments(channel_id, stage);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
|
||||
ON workflow_assignments(team_id, status) WHERE status = 'unassigned';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_user
|
||||
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE workflow_assignments IS 'Queue entries for human review stages in workflows.';
|
||||
COMMENT ON COLUMN workflow_assignments.status IS 'unassigned=waiting, claimed=being worked, completed=done';
|
||||
56
server/database/migrations/sqlite/023_workflows.sql
Normal file
56
server/database/migrations/sqlite/023_workflows.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- 023_workflows.sql (SQLite)
|
||||
-- Workflow definitions, stages, and version snapshots (v0.26.1)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflows (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
branding TEXT NOT NULL DEFAULT '{}',
|
||||
entry_mode TEXT NOT NULL DEFAULT 'public_link'
|
||||
CHECK (entry_mode IN ('public_link', 'team_only')),
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
on_complete TEXT,
|
||||
retention TEXT NOT NULL DEFAULT '{"mode": "archive"}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_team_slug
|
||||
ON workflows(team_id, slug) WHERE team_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_global_slug
|
||||
ON workflows(slug) WHERE team_id IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
assignment_team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
form_template TEXT NOT NULL DEFAULT '{}',
|
||||
history_mode TEXT NOT NULL DEFAULT 'full'
|
||||
CHECK (history_mode IN ('full', 'summary', 'fresh')),
|
||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
||||
transition_rules TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_stages_workflow
|
||||
ON workflow_stages(workflow_id, ordinal);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
snapshot TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
|
||||
UNIQUE (workflow_id, version_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_versions_workflow
|
||||
ON workflow_versions(workflow_id, version_number);
|
||||
13
server/database/migrations/sqlite/024_workflow_instances.sql
Normal file
13
server/database/migrations/sqlite/024_workflow_instances.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 024_workflow_instances.sql (SQLite)
|
||||
-- Workflow instance columns on channels (v0.26.2)
|
||||
|
||||
ALTER TABLE channels ADD COLUMN workflow_id TEXT REFERENCES workflows(id) ON DELETE SET NULL;
|
||||
ALTER TABLE channels ADD COLUMN workflow_version INTEGER;
|
||||
ALTER TABLE channels ADD COLUMN current_stage INTEGER DEFAULT 0;
|
||||
ALTER TABLE channels ADD COLUMN stage_data TEXT DEFAULT '{}';
|
||||
ALTER TABLE channels ADD COLUMN workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled'));
|
||||
ALTER TABLE channels ADD COLUMN last_activity_at DATETIME DEFAULT (datetime('now'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow
|
||||
ON channels(workflow_id) WHERE workflow_id IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 025_workflow_assignments.sql (SQLite)
|
||||
-- Assignment queue for workflow stages (v0.26.4)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
stage INTEGER NOT NULL,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
claimed_at DATETIME,
|
||||
completed_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_channel
|
||||
ON workflow_assignments(channel_id, stage);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
|
||||
ON workflow_assignments(team_id, status);
|
||||
@@ -161,7 +161,7 @@ func (h *CompletionHandler) evaluateRouting(
|
||||
Model: model,
|
||||
ConfigID: configID,
|
||||
HealthStatus: healthStatus,
|
||||
Pricing: map[string]*models.ModelPricing{}, // TODO: populate from pricing store
|
||||
Pricing: map[string]*models.ModelPricing{}, // populated when cost_limit routing is enabled (deferred)
|
||||
}
|
||||
|
||||
ranked, dec := h.router.Evaluate(routingCtx, policies, candidates)
|
||||
@@ -457,6 +457,18 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── Workflow form template injection (v0.26.4) ──
|
||||
// When the channel is a workflow instance, inject the current stage's
|
||||
// form_template as a system message so the persona knows what to collect.
|
||||
if channelType == "workflow" {
|
||||
if hint := h.buildWorkflowFormHint(c.Request.Context(), channelID); hint != "" {
|
||||
messages = append(messages[:1], append([]providers.Message{{
|
||||
Role: "system",
|
||||
Content: hint,
|
||||
}}, messages[1:]...)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve capabilities early — needed for vision gating below
|
||||
caps := h.getModelCapabilities(c, model, configID)
|
||||
|
||||
@@ -709,9 +721,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
if stream {
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID)
|
||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID)
|
||||
} else {
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID)
|
||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,7 +736,7 @@ func (h *CompletionHandler) multiModelStream(
|
||||
c *gin.Context,
|
||||
targets []models.ChannelModel,
|
||||
messages []providers.Message,
|
||||
channelID, userID, personaID, personaSystemPrompt, workspaceID string,
|
||||
channelID, userID, personaID, personaSystemPrompt, workspaceID, teamID string,
|
||||
req completionRequest,
|
||||
) {
|
||||
// Set SSE headers once for the entire multi-model stream
|
||||
@@ -821,7 +833,7 @@ func (h *CompletionHandler) multiModelStream(
|
||||
}
|
||||
|
||||
// Stream this model's response using the shared streaming core
|
||||
result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
|
||||
result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health)
|
||||
|
||||
// Persist assistant message with model attribution
|
||||
if result.Content != "" {
|
||||
@@ -1012,14 +1024,14 @@ func (h *CompletionHandler) streamCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID string,
|
||||
channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string,
|
||||
) {
|
||||
// Apply provider-specific request hooks (v0.22.1)
|
||||
if hooks := providers.GetHooks(providerID); hooks != nil {
|
||||
hooks.PreRequest(cfg, &req)
|
||||
}
|
||||
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, h.hub, h.health)
|
||||
result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health)
|
||||
|
||||
// Persist assistant response
|
||||
if result.Content != "" {
|
||||
@@ -1052,7 +1064,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req providers.CompletionRequest,
|
||||
channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID string,
|
||||
channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string,
|
||||
) {
|
||||
// Apply provider-specific request hooks (v0.22.1)
|
||||
if hooks := providers.GetHooks(providerID); hooks != nil {
|
||||
@@ -1137,6 +1149,7 @@ func (h *CompletionHandler) syncCompletion(
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
call := tools.ToolCall{
|
||||
@@ -1335,6 +1348,71 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context,
|
||||
return err == nil && id != ""
|
||||
}
|
||||
|
||||
// buildWorkflowFormHint loads the current workflow stage's form_template
|
||||
// and returns a system message instructing the persona what to collect.
|
||||
// Returns empty string if not a workflow or no template defined.
|
||||
func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string {
|
||||
var workflowID *string
|
||||
var currentStage int
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT workflow_id, COALESCE(current_stage, 0)
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`), channelID).Scan(&workflowID, ¤tStage)
|
||||
|
||||
if workflowID == nil || *workflowID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if h.stores.Workflows == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, *workflowID)
|
||||
if err != nil || currentStage >= len(stages) {
|
||||
return ""
|
||||
}
|
||||
|
||||
stage := stages[currentStage]
|
||||
if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse form template to extract field descriptions
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
hint := "You are in workflow stage: " + stage.Name + ".\n\n"
|
||||
hint += "Collect the following information from the user through natural conversation:\n\n"
|
||||
for name, spec := range fields {
|
||||
switch v := spec.(type) {
|
||||
case map[string]interface{}:
|
||||
desc, _ := v["description"].(string)
|
||||
required, _ := v["required"].(bool)
|
||||
if desc != "" {
|
||||
hint += "- " + name + ": " + desc
|
||||
} else {
|
||||
hint += "- " + name
|
||||
}
|
||||
if required {
|
||||
hint += " (required)"
|
||||
}
|
||||
hint += "\n"
|
||||
default:
|
||||
hint += "- " + name + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
hint += "\nWhen you have gathered all required information, call the workflow_advance tool " +
|
||||
"with the collected data as a JSON object. Do not advance until all required fields are filled."
|
||||
|
||||
return hint
|
||||
}
|
||||
|
||||
// buildParticipantHint builds a system message listing available @mentionable
|
||||
// personas so the LLM knows who it can direct responses to.
|
||||
// Only includes personas accessible to this user. Excludes the current persona.
|
||||
|
||||
@@ -545,18 +545,21 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// Attach tool definitions (same as normal completion)
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
|
||||
// Query channel metadata for tool context and execution context
|
||||
var chType string
|
||||
var chTeamID *string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&chType, &chTeamID)
|
||||
msgTeamID := ""
|
||||
if chTeamID != nil {
|
||||
msgTeamID = *chTeamID
|
||||
}
|
||||
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
// v0.25.0: Build ToolContext with channel metadata
|
||||
var chType string
|
||||
var chTeamID *string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&chType, &chTeamID)
|
||||
msgTeamID := ""
|
||||
if chTeamID != nil {
|
||||
msgTeamID = *chTeamID
|
||||
}
|
||||
tctx := tools.ToolContext{
|
||||
ChannelType: chType,
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -574,7 +577,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
hooks.PreRequest(providerCfg, &provReq)
|
||||
}
|
||||
|
||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, h.hub, comp.health)
|
||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health)
|
||||
|
||||
// Persist as sibling (regen) with tool activity
|
||||
if result.Content != "" {
|
||||
|
||||
177
server/handlers/route_test.go
Normal file
177
server/handlers/route_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/pages"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// TestRouteRegistration verifies that ALL production routes can be
|
||||
// registered on a single Gin engine without panicking. This catches
|
||||
// wildcard parameter name conflicts (e.g. /w/:id vs /w/:scope/:slug)
|
||||
// which cause runtime panics during startup.
|
||||
//
|
||||
// The test mirrors the route structure in main.go and pages.go.
|
||||
// Any new route group should be added here.
|
||||
func TestRouteRegistration(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "/test",
|
||||
Port: "0",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
// This must not panic. If it does, there's a wildcard conflict.
|
||||
r := gin.New()
|
||||
base := r.Group(cfg.BasePath)
|
||||
|
||||
// ── API routes (mirrors main.go) ────────
|
||||
|
||||
api := base.Group("/api/v1")
|
||||
|
||||
// Auth (public)
|
||||
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||
api.POST("/auth/login", auth.Login)
|
||||
api.POST("/auth/register", auth.Register)
|
||||
|
||||
// Protected API routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
|
||||
// Channels (the route group that conflicts with workflow)
|
||||
channels := NewChannelHandler()
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
|
||||
// Messages
|
||||
msgs := NewMessageHandler(nil, stores, nil, nil)
|
||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
|
||||
// Workflow assignments (v0.26.4)
|
||||
wfAssignH := NewWorkflowAssignmentHandler()
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
|
||||
// Session API (the /w/:id group)
|
||||
wfAPI := base.Group("/api/v1/w")
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores))
|
||||
wfAPI.POST("/:id/messages", msgs.CreateMessage)
|
||||
wfAPI.GET("/:id/messages", msgs.ListMessages)
|
||||
|
||||
// Visitor entry (separate namespace to avoid /w/:id collision)
|
||||
wfEntry := NewWorkflowEntryHandler(stores)
|
||||
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
||||
|
||||
// ── Page routes (mirrors pages.go surface registration) ────
|
||||
|
||||
pageEngine := pages.New(cfg, stores)
|
||||
pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{
|
||||
Authenticated: middleware.AuthOrRedirect(cfg),
|
||||
Admin: []gin.HandlerFunc{middleware.AuthOrRedirect(cfg), middleware.RequireAdminPage()},
|
||||
Session: middleware.AuthOrSession(cfg, stores),
|
||||
})
|
||||
|
||||
// ── Verify routes actually resolve ────
|
||||
|
||||
// Health-check style: send a request and confirm we get a response
|
||||
// (not a panic). We don't care about the status code — just that
|
||||
// the router doesn't blow up.
|
||||
paths := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"GET", "/test/api/v1/workflows"},
|
||||
{"GET", "/test/api/v1/channels"},
|
||||
{"GET", "/test/w/some-channel-id"}, // workflow chat surface
|
||||
{"GET", "/test/w/some-scope/some-slug"}, // workflow landing surface
|
||||
{"POST", "/test/api/v1/workflow-entry/global/test-slug"},
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
req := httptest.NewRequest(p.method, p.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// We just want to confirm the router didn't panic and returned
|
||||
// something (even 401/404 is fine — means routing worked).
|
||||
if w.Code == 0 {
|
||||
t.Errorf("%s %s: got status 0 (router failed to match)", p.method, p.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowPageRoutesNoConflict specifically tests the /w/ namespace
|
||||
// to ensure different-depth routes with the same param name work.
|
||||
func TestWorkflowPageRoutesNoConflict(t *testing.T) {
|
||||
r := gin.New()
|
||||
|
||||
// These two routes MUST use the same wildcard name at position 1.
|
||||
// /w/:id (2 segments) = workflow chat
|
||||
// /w/:id/:slug (3 segments) = workflow landing
|
||||
// Using different names (e.g. :scope) at the same position panics.
|
||||
r.GET("/w/:id", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "chat:"+c.Param("id"))
|
||||
})
|
||||
r.GET("/w/:id/:slug", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "landing:"+c.Param("id")+"/"+c.Param("slug"))
|
||||
})
|
||||
|
||||
// 2-segment path → chat
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/w/channel-123", nil))
|
||||
if w.Code != 200 || w.Body.String() != "chat:channel-123" {
|
||||
t.Errorf("2-segment: got %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 3-segment path → landing
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("GET", "/w/my-team/intake-form", nil))
|
||||
if w.Code != 200 || w.Body.String() != "landing:my-team/intake-form" {
|
||||
t.Errorf("3-segment: got %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func streamWithToolLoop(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req *providers.CompletionRequest,
|
||||
model, providerType, userID, channelID, personaID, workspaceID, configID string,
|
||||
model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
|
||||
hub *events.Hub,
|
||||
health HealthRecorder,
|
||||
) streamResult {
|
||||
@@ -199,6 +199,7 @@ func streamWithToolLoop(
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
@@ -307,7 +308,7 @@ func streamModelResponse(
|
||||
provider providers.Provider,
|
||||
cfg providers.ProviderConfig,
|
||||
req *providers.CompletionRequest,
|
||||
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID string,
|
||||
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
|
||||
hub *events.Hub,
|
||||
health HealthRecorder,
|
||||
) streamResult {
|
||||
@@ -424,6 +425,7 @@ func streamModelResponse(
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range toolCalls {
|
||||
call := tools.ToolCall{
|
||||
|
||||
@@ -105,8 +105,13 @@ func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/
|
||||
// TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/
|
||||
// Clean up extracted static assets for this surface
|
||||
if h.surfacesDir != "" {
|
||||
assetDir := filepath.Join(h.surfacesDir, id)
|
||||
if err := os.RemoveAll(assetDir); err != nil {
|
||||
log.Printf("⚠️ Failed to clean up surface assets at %s: %v", assetDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
|
||||
}
|
||||
|
||||
144
server/handlers/workflow_assignments.go
Normal file
144
server/handlers/workflow_assignments.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Workflow Assignment Handler ─────────────
|
||||
// Manages the assignment queue for human review stages.
|
||||
|
||||
type WorkflowAssignmentHandler struct{}
|
||||
|
||||
func NewWorkflowAssignmentHandler() *WorkflowAssignmentHandler {
|
||||
return &WorkflowAssignmentHandler{}
|
||||
}
|
||||
|
||||
type assignmentRow 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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
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 ASC
|
||||
`), teamID, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []assignmentRow
|
||||
for rows.Next() {
|
||||
var a assignmentRow
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
if result == nil {
|
||||
result = []assignmentRow{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// ListForUser returns assignments claimed by the current user.
|
||||
// GET /api/v1/workflow-assignments/mine
|
||||
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments
|
||||
WHERE assigned_to = $1 AND status = 'claimed'
|
||||
ORDER BY claimed_at DESC
|
||||
`), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []assignmentRow
|
||||
for rows.Next() {
|
||||
var a assignmentRow
|
||||
if err := rows.Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID,
|
||||
&a.AssignedTo, &a.Status, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
if result == nil {
|
||||
result = []assignmentRow{}
|
||||
}
|
||||
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()
|
||||
|
||||
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE workflow_assignments
|
||||
SET assigned_to = $1, status = 'claimed', claimed_at = $2
|
||||
WHERE id = $3 AND status = 'unassigned'
|
||||
`), userID, now, assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"})
|
||||
return
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
|
||||
return
|
||||
}
|
||||
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")
|
||||
now := time.Now().UTC()
|
||||
|
||||
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
UPDATE workflow_assignments
|
||||
SET status = 'completed', completed_at = $1
|
||||
WHERE id = $2 AND status = 'claimed'
|
||||
`), now, assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"})
|
||||
return
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
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})
|
||||
}
|
||||
135
server/handlers/workflow_entry.go
Normal file
135
server/handlers/workflow_entry.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/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 = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = '{}', workflow_status = 'active',
|
||||
last_activity_at = $3, allow_anonymous = true, ai_mode = 'auto'
|
||||
WHERE id = $4
|
||||
`), wf.ID, ver.VersionNumber, time.Now().UTC(), ch.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set workflow columns: %v", err)
|
||||
}
|
||||
|
||||
// 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 chat — visitor lands on existing /w/:channelId
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"channel_id": ch.ID,
|
||||
"session_id": sess.ID,
|
||||
"redirect_to": "/w/" + ch.ID,
|
||||
})
|
||||
}
|
||||
319
server/handlers/workflow_instances.go
Normal file
319
server/handlers/workflow_instances.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// ── Workflow Instance Handler ───────────────
|
||||
// Manages the runtime lifecycle of workflow channels: starting instances,
|
||||
// advancing/rejecting stages, and querying status.
|
||||
|
||||
type WorkflowInstanceHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewWorkflowInstanceHandler(stores store.Stores) *WorkflowInstanceHandler {
|
||||
return &WorkflowInstanceHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ── 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"
|
||||
var allowAnonVal interface{} = allowAnon
|
||||
if database.CurrentDialect == database.DialectSQLite {
|
||||
if allowAnon {
|
||||
allowAnonVal = 1
|
||||
} else {
|
||||
allowAnonVal = 0
|
||||
}
|
||||
}
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = '{}', workflow_status = 'active',
|
||||
last_activity_at = $3, allow_anonymous = $4, ai_mode = 'auto'
|
||||
WHERE id = $5
|
||||
`), wfID, ver.VersionNumber, time.Now().UTC(), allowAnonVal, ch.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err)
|
||||
}
|
||||
|
||||
// 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 ──────────────────────────────────
|
||||
|
||||
// WorkflowChannelStatus holds the runtime state of a workflow instance.
|
||||
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 *time.Time `json:"last_activity_at"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
var ws WorkflowChannelStatus
|
||||
var stageData []byte
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`), channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
ws.StageData = stageData
|
||||
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, channelID, body.Data)
|
||||
nextStage := currentStage + 1
|
||||
|
||||
if nextStage >= len(stages) {
|
||||
// Workflow complete
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET current_stage = $1, workflow_status = 'completed',
|
||||
stage_data = $2, last_activity_at = $3, ai_mode = 'off'
|
||||
WHERE id = $4
|
||||
`), nextStage, mergedData, time.Now().UTC(), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"})
|
||||
return
|
||||
}
|
||||
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage})
|
||||
return
|
||||
}
|
||||
|
||||
// Advance to next stage
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`), nextStage, mergedData, time.Now().UTC(), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"})
|
||||
return
|
||||
}
|
||||
|
||||
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
|
||||
|
||||
nextStageDef := stages[nextStage]
|
||||
|
||||
// Bind next persona
|
||||
if 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Notify assignment team (stub — fully wired in v0.26.4)
|
||||
if nextStageDef.AssignmentTeamID != nil {
|
||||
tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
|
||||
log.Printf("Workflow stage '%s' assigned to team %s (channel %s)",
|
||||
nextStageDef.Name, *nextStageDef.AssignmentTeamID, channelID)
|
||||
}
|
||||
|
||||
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 = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
|
||||
`), prevStage, time.Now().UTC(), channelID)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {
|
||||
var wfID *string
|
||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active')
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`), channelID).Scan(&wfID, ¤tStage, &status)
|
||||
if wfID != nil {
|
||||
workflowID = *wfID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// createStageNote and mergeStageData are now in tools/workflow.go
|
||||
// (shared between handler and workflow_advance tool).
|
||||
339
server/handlers/workflow_test.go
Normal file
339
server/handlers/workflow_test.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
authpkg "git.gobha.me/xcaliber/chat-switchboard/auth"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// TestWorkflowCRUD exercises the full workflow lifecycle:
|
||||
// create → add stages → publish → start instance → advance → complete.
|
||||
func TestWorkflowCRUD(t *testing.T) {
|
||||
h := setupWorkflowHarness(t)
|
||||
|
||||
// ── Create workflow ─────────────────────
|
||||
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Customer Intake",
|
||||
"slug": "customer-intake",
|
||||
"description": "Collect customer info and route to support",
|
||||
"entry_mode": "public_link",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Create workflow: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var wf struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
if wf.ID == "" {
|
||||
t.Fatal("workflow ID is empty")
|
||||
}
|
||||
if wf.Slug != "customer-intake" {
|
||||
t.Errorf("slug: got %q, want %q", wf.Slug, "customer-intake")
|
||||
}
|
||||
if wf.Version != 1 {
|
||||
t.Errorf("version: got %d, want 1", wf.Version)
|
||||
}
|
||||
|
||||
// ── Get workflow ────────────────────────
|
||||
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Get workflow: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// ── List workflows ──────────────────────
|
||||
|
||||
resp = h.request("GET", "/api/v1/workflows", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("List workflows: got %d", resp.Code)
|
||||
}
|
||||
var listResp struct {
|
||||
Data []struct{ ID string } `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &listResp)
|
||||
if len(listResp.Data) == 0 {
|
||||
t.Error("List workflows returned empty")
|
||||
}
|
||||
|
||||
// ── Add stages ──────────────────────────
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Collect Info",
|
||||
"history_mode": "full",
|
||||
"ordinal": 0,
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Create stage 0: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var stage0 struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &stage0)
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Review",
|
||||
"history_mode": "fresh",
|
||||
"ordinal": 1,
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Create stage 1: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var stage1 struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &stage1)
|
||||
|
||||
// ── List stages ─────────────────────────
|
||||
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/stages", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("List stages: got %d", resp.Code)
|
||||
}
|
||||
var stagesResp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &stagesResp)
|
||||
if len(stagesResp.Data) != 2 {
|
||||
t.Fatalf("List stages: got %d, want 2", len(stagesResp.Data))
|
||||
}
|
||||
|
||||
// ── Reorder stages ──────────────────────
|
||||
|
||||
resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID+"/stages/reorder", h.adminToken, map[string]interface{}{
|
||||
"ordered_ids": []string{stage1.ID, stage0.ID},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Reorder stages: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// ── Update workflow ─────────────────────
|
||||
|
||||
resp = h.request("PATCH", "/api/v1/workflows/"+wf.ID, h.adminToken, map[string]interface{}{
|
||||
"is_active": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Update workflow: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Re-read to get incremented version
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf)
|
||||
if wf.Version != 2 {
|
||||
t.Errorf("version after update: got %d, want 2", wf.Version)
|
||||
}
|
||||
|
||||
// ── Publish ─────────────────────────────
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Publish: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var ver struct {
|
||||
VersionNumber int `json:"version_number"`
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &ver)
|
||||
if ver.VersionNumber != 2 {
|
||||
t.Errorf("published version: got %d, want 2", ver.VersionNumber)
|
||||
}
|
||||
if len(ver.Snapshot) == 0 {
|
||||
t.Error("snapshot is empty")
|
||||
}
|
||||
|
||||
// ── Duplicate publish should conflict ───
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+wf.ID+"/publish", h.adminToken, nil)
|
||||
if resp.Code != http.StatusConflict {
|
||||
t.Errorf("Duplicate publish: got %d, want 409", resp.Code)
|
||||
}
|
||||
|
||||
// ── Get version ─────────────────────────
|
||||
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID+"/versions/2", h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Get version: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// ── Duplicate slug should conflict ──────
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Another Intake",
|
||||
"slug": "customer-intake",
|
||||
})
|
||||
if resp.Code != http.StatusConflict {
|
||||
t.Errorf("Duplicate slug: got %d, want 409", resp.Code)
|
||||
}
|
||||
|
||||
// ── Auto-slug from name ─────────────────
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Bug Report Triage",
|
||||
})
|
||||
if resp.Code != http.StatusCreated {
|
||||
t.Fatalf("Auto-slug: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var wf2 struct{ Slug string `json:"slug"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &wf2)
|
||||
if wf2.Slug != "bug-report-triage" {
|
||||
t.Errorf("auto-slug: got %q, want %q", wf2.Slug, "bug-report-triage")
|
||||
}
|
||||
|
||||
// ── Delete stage ────────────────────────
|
||||
|
||||
resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID+"/stages/"+stage1.ID, h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Delete stage: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// ── Delete workflow (cascades) ──────────
|
||||
|
||||
resp = h.request("DELETE", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("Delete workflow: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// Confirm gone
|
||||
resp = h.request("GET", "/api/v1/workflows/"+wf.ID, h.adminToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("After delete: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowValidation tests input validation on workflow endpoints.
|
||||
func TestWorkflowValidation(t *testing.T) {
|
||||
h := setupWorkflowHarness(t)
|
||||
|
||||
// Missing name
|
||||
resp := h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"slug": "test",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Missing name: got %d, want 400", resp.Code)
|
||||
}
|
||||
|
||||
// Invalid slug
|
||||
resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Test",
|
||||
"slug": "A",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Short slug: got %d, want 400", resp.Code)
|
||||
}
|
||||
|
||||
// Invalid entry_mode
|
||||
resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "Test",
|
||||
"entry_mode": "invalid",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Bad entry_mode: got %d, want 400", resp.Code)
|
||||
}
|
||||
|
||||
// Invalid history_mode on stage
|
||||
resp = h.request("POST", "/api/v1/workflows", h.adminToken, map[string]interface{}{
|
||||
"name": "For Stage Test",
|
||||
})
|
||||
var stageWf struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(resp.Body.Bytes(), &stageWf)
|
||||
|
||||
resp = h.request("POST", "/api/v1/workflows/"+stageWf.ID+"/stages", h.adminToken, map[string]interface{}{
|
||||
"name": "Bad Mode",
|
||||
"history_mode": "invalid",
|
||||
})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Errorf("Bad history_mode: got %d, want 400", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow Test Harness ───────────────────
|
||||
|
||||
type workflowHarness struct {
|
||||
*testHarness
|
||||
adminToken string
|
||||
adminID string
|
||||
}
|
||||
|
||||
func setupWorkflowHarness(t *testing.T) *workflowHarness {
|
||||
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)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
// Auth (for token generation)
|
||||
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))
|
||||
|
||||
// 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)
|
||||
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)
|
||||
|
||||
// Channels (needed for workflow instance creation)
|
||||
channels := NewChannelHandler()
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
|
||||
// Seed an admin user (handle + auth_source required since v0.24.0)
|
||||
adminID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, role, handle, auth_source) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
"wf-admin", "wf-admin@test.com", "$2a$10$test", "admin", "wf-admin", "builtin",
|
||||
)
|
||||
|
||||
token := makeToken(adminID, "wf-admin@test.com", "admin")
|
||||
|
||||
return &workflowHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
adminToken: token,
|
||||
adminID: adminID,
|
||||
}
|
||||
}
|
||||
341
server/handlers/workflows.go
Normal file
341
server/handlers/workflows.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Workflow Handler ────────────────────────
|
||||
|
||||
type WorkflowHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewWorkflowHandler(stores store.Stores) *WorkflowHandler {
|
||||
return &WorkflowHandler{stores: stores}
|
||||
}
|
||||
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
|
||||
|
||||
func validSlug(s string) bool {
|
||||
if len(s) < 2 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
return slugRe.MatchString(s)
|
||||
}
|
||||
|
||||
// ── Workflow CRUD ───────────────────────────
|
||||
|
||||
// List returns workflows visible to the caller.
|
||||
// GET /api/v1/workflows?team_id=...
|
||||
func (h *WorkflowHandler) List(c *gin.Context) {
|
||||
teamID := c.Query("team_id")
|
||||
var result []models.Workflow
|
||||
var err error
|
||||
if teamID != "" {
|
||||
result, err = h.stores.Workflows.ListForTeam(c.Request.Context(), teamID)
|
||||
} else {
|
||||
result, err = h.stores.Workflows.ListGlobal(c.Request.Context())
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
result = []models.Workflow{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// Get returns a single workflow with its stages.
|
||||
// GET /api/v1/workflows/:id
|
||||
func (h *WorkflowHandler) Get(c *gin.Context) {
|
||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), w.ID)
|
||||
if stages == nil {
|
||||
stages = []models.WorkflowStage{}
|
||||
}
|
||||
w.Stages = stages
|
||||
c.JSON(http.StatusOK, w)
|
||||
}
|
||||
|
||||
// Create creates a new workflow definition.
|
||||
// POST /api/v1/workflows
|
||||
func (h *WorkflowHandler) Create(c *gin.Context) {
|
||||
var w models.Workflow
|
||||
if err := c.ShouldBindJSON(&w); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if w.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
if w.Slug == "" {
|
||||
w.Slug = slugify(w.Name)
|
||||
}
|
||||
if !validSlug(w.Slug) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be 2-64 chars, lowercase alphanumeric and hyphens"})
|
||||
return
|
||||
}
|
||||
if w.EntryMode == "" {
|
||||
w.EntryMode = "public_link"
|
||||
}
|
||||
if w.EntryMode != "public_link" && w.EntryMode != "team_only" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"})
|
||||
return
|
||||
}
|
||||
|
||||
w.CreatedBy = c.GetString("user_id")
|
||||
|
||||
if err := h.stores.Workflows.Create(c.Request.Context(), &w); err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "slug already exists in this scope"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workflow: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, w)
|
||||
}
|
||||
|
||||
// Update patches a workflow definition.
|
||||
// PATCH /api/v1/workflows/:id
|
||||
func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var patch models.WorkflowPatch
|
||||
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if patch.EntryMode != nil && *patch.EntryMode != "public_link" && *patch.EntryMode != "team_only" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "entry_mode must be public_link or team_only"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workflow"})
|
||||
return
|
||||
}
|
||||
w, _ := h.stores.Workflows.GetByID(c.Request.Context(), id)
|
||||
if w == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, w)
|
||||
}
|
||||
|
||||
// Delete deletes a workflow and all its stages/versions (CASCADE).
|
||||
// DELETE /api/v1/workflows/:id
|
||||
func (h *WorkflowHandler) Delete(c *gin.Context) {
|
||||
if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// ── Stage CRUD ──────────────────────────────
|
||||
|
||||
// ListStages returns stages for a workflow, ordered by ordinal.
|
||||
// GET /api/v1/workflows/:id/stages
|
||||
func (h *WorkflowHandler) ListStages(c *gin.Context) {
|
||||
stages, err := h.stores.Workflows.ListStages(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list stages"})
|
||||
return
|
||||
}
|
||||
if stages == nil {
|
||||
stages = []models.WorkflowStage{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": stages})
|
||||
}
|
||||
|
||||
// CreateStage adds a stage to a workflow.
|
||||
// POST /api/v1/workflows/:id/stages
|
||||
func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
||||
var st models.WorkflowStage
|
||||
if err := c.ShouldBindJSON(&st); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st.WorkflowID = c.Param("id")
|
||||
if st.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
if st.HistoryMode == "" {
|
||||
st.HistoryMode = "full"
|
||||
}
|
||||
if st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||
return
|
||||
}
|
||||
if st.Ordinal == 0 {
|
||||
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
||||
st.Ordinal = len(existing)
|
||||
}
|
||||
if err := h.stores.Workflows.CreateStage(c.Request.Context(), &st); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create stage: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, st)
|
||||
}
|
||||
|
||||
// UpdateStage updates a stage.
|
||||
// PUT /api/v1/workflows/:id/stages/:sid
|
||||
func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
||||
var st models.WorkflowStage
|
||||
if err := c.ShouldBindJSON(&st); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st.ID = c.Param("sid")
|
||||
st.WorkflowID = c.Param("id")
|
||||
if st.HistoryMode != "" && st.HistoryMode != "full" && st.HistoryMode != "summary" && st.HistoryMode != "fresh" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, st)
|
||||
}
|
||||
|
||||
// DeleteStage removes a stage.
|
||||
// DELETE /api/v1/workflows/:id/stages/:sid
|
||||
func (h *WorkflowHandler) DeleteStage(c *gin.Context) {
|
||||
if err := h.stores.Workflows.DeleteStage(c.Request.Context(), c.Param("sid")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete stage"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// ReorderStages sets the ordinal for all stages.
|
||||
// PATCH /api/v1/workflows/:id/stages/reorder
|
||||
func (h *WorkflowHandler) ReorderStages(c *gin.Context) {
|
||||
var body struct {
|
||||
OrderedIDs []string `json:"ordered_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.OrderedIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_ids required"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.ReorderStages(c.Request.Context(), c.Param("id"), body.OrderedIDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder stages"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"reordered": true})
|
||||
}
|
||||
|
||||
// ── Publish ─────────────────────────────────
|
||||
|
||||
// Publish creates an immutable version snapshot of the current workflow state.
|
||||
// POST /api/v1/workflows/:id/publish
|
||||
func (h *WorkflowHandler) Publish(c *gin.Context) {
|
||||
wfID := c.Param("id")
|
||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
|
||||
stages, _ := h.stores.Workflows.ListStages(c.Request.Context(), wfID)
|
||||
if stages == nil {
|
||||
stages = []models.WorkflowStage{}
|
||||
}
|
||||
|
||||
// Snapshot includes persona tool grants at publish time (frozen for running instances)
|
||||
type stageSnapshot struct {
|
||||
models.WorkflowStage
|
||||
ToolGrants []string `json:"tool_grants,omitempty"`
|
||||
}
|
||||
snapshotStages := make([]stageSnapshot, len(stages))
|
||||
for i, st := range stages {
|
||||
snapshotStages[i] = stageSnapshot{WorkflowStage: st}
|
||||
if st.PersonaID != nil && h.stores.Personas != nil {
|
||||
grants, _ := h.stores.Personas.GetToolGrants(c.Request.Context(), *st.PersonaID)
|
||||
snapshotStages[i].ToolGrants = grants
|
||||
}
|
||||
}
|
||||
|
||||
snapshot := map[string]interface{}{
|
||||
"workflow": w,
|
||||
"stages": snapshotStages,
|
||||
}
|
||||
snapshotJSON, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize snapshot"})
|
||||
return
|
||||
}
|
||||
|
||||
v := &models.WorkflowVersion{
|
||||
WorkflowID: wfID,
|
||||
VersionNumber: w.Version,
|
||||
Snapshot: snapshotJSON,
|
||||
}
|
||||
|
||||
existing, _ := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, w.Version)
|
||||
if existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "version already published — edit the workflow to increment"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Workflows.Publish(c.Request.Context(), v); err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "version already published"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to publish: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, v)
|
||||
}
|
||||
|
||||
// GetVersion returns a specific version snapshot.
|
||||
// GET /api/v1/workflows/:id/versions/:version
|
||||
func (h *WorkflowHandler) GetVersion(c *gin.Context) {
|
||||
wfID := c.Param("id")
|
||||
vNum, err := strconv.Atoi(c.Param("version"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version number"})
|
||||
return
|
||||
}
|
||||
v, err := h.stores.Workflows.GetVersion(c.Request.Context(), wfID, vNum)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get version"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func slugify(name string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 64 {
|
||||
s = s[:64]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -138,6 +138,51 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// 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
|
||||
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)
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour)
|
||||
res, err := database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET workflow_status = 'stale'
|
||||
WHERE type = 'workflow'
|
||||
AND workflow_status = 'active'
|
||||
AND last_activity_at < $1
|
||||
`), cutoff)
|
||||
if err != nil {
|
||||
log.Printf("⚠ workflow staleness sweep failed: %v", err)
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
log.Printf("🧹 workflows: marked %d instances as stale", n)
|
||||
}
|
||||
cancel()
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||
handlers.BootstrapAdmin(cfg, stores)
|
||||
|
||||
@@ -276,6 +321,9 @@ func main() {
|
||||
|
||||
// Memory tools + extraction scanner (v0.18.0)
|
||||
tools.RegisterMemoryTools(stores, kbEmbedder)
|
||||
|
||||
// Workflow tools (v0.26.4 — AI-triggered stage advancement)
|
||||
tools.RegisterWorkflowTools(stores)
|
||||
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
|
||||
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
|
||||
memScanner.Start()
|
||||
@@ -488,6 +536,34 @@ func main() {
|
||||
protected.POST("/persona-groups/:id/members", pgH.AddMember)
|
||||
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
|
||||
|
||||
// Workflows (v0.26.1 — team-owned staged processes)
|
||||
wfH := handlers.NewWorkflowHandler(stores)
|
||||
protected.GET("/workflows", wfH.List)
|
||||
protected.POST("/workflows", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Create)
|
||||
protected.GET("/workflows/:id", wfH.Get)
|
||||
protected.PATCH("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Update)
|
||||
protected.DELETE("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Delete)
|
||||
protected.GET("/workflows/:id/stages", wfH.ListStages)
|
||||
protected.POST("/workflows/:id/stages", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.CreateStage)
|
||||
protected.PUT("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.UpdateStage)
|
||||
protected.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.DeleteStage)
|
||||
protected.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.ReorderStages)
|
||||
protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish)
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances (v0.26.2 — runtime lifecycle)
|
||||
wfInstH := handlers.NewWorkflowInstanceHandler(stores)
|
||||
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)
|
||||
|
||||
// Workflow assignments (v0.26.4 — team assignment queue)
|
||||
wfAssignH := handlers.NewWorkflowAssignmentHandler()
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
|
||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||
chModelH := handlers.NewChannelModelHandler(stores)
|
||||
protected.GET("/channels/:id/models", chModelH.List)
|
||||
@@ -772,6 +848,10 @@ func main() {
|
||||
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||
|
||||
// Team workflow assignments (v0.26.4)
|
||||
teamAssignH := handlers.NewWorkflowAssignmentHandler()
|
||||
teamScoped.GET("/assignments", teamAssignH.ListForTeam)
|
||||
}
|
||||
|
||||
// Public global settings (non-admin users can read safe subset)
|
||||
@@ -998,6 +1078,13 @@ func main() {
|
||||
wfAPI.POST("/:id/completions", wfComp.Complete)
|
||||
}
|
||||
|
||||
// Workflow visitor entry (v0.26.3) — public start endpoint
|
||||
// NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie
|
||||
// conflicts with /api/v1/w/:id/messages (different wildcard names at
|
||||
// same path position). Separate namespace avoids the collision.
|
||||
wfEntry := handlers.NewWorkflowEntryHandler(stores)
|
||||
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
||||
|
||||
bp := cfg.BasePath
|
||||
if bp == "" {
|
||||
bp = "/"
|
||||
|
||||
69
server/models/workflow.go
Normal file
69
server/models/workflow.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Workflow ────────────────────────────────
|
||||
|
||||
// Workflow is a team-owned or global staged process definition.
|
||||
type Workflow struct {
|
||||
ID string `json:"id"`
|
||||
TeamID *string `json:"team_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Branding json.RawMessage `json:"branding"`
|
||||
EntryMode string `json:"entry_mode"` // public_link | team_only
|
||||
IsActive bool `json:"is_active"`
|
||||
Version int `json:"version"`
|
||||
OnComplete json.RawMessage `json:"on_complete,omitempty"`
|
||||
Retention json.RawMessage `json:"retention"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Joined data (not stored directly)
|
||||
Stages []WorkflowStage `json:"stages,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowPatch contains optional fields for updating a workflow.
|
||||
type WorkflowPatch struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Branding *json.RawMessage `json:"branding,omitempty"`
|
||||
EntryMode *string `json:"entry_mode,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
OnComplete *json.RawMessage `json:"on_complete,omitempty"`
|
||||
Retention *json.RawMessage `json:"retention,omitempty"`
|
||||
}
|
||||
|
||||
// ── Workflow Stage ──────────────────────────
|
||||
|
||||
// WorkflowStage is an ordered step within a workflow definition.
|
||||
type WorkflowStage struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
Name string `json:"name"`
|
||||
PersonaID *string `json:"persona_id,omitempty"`
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
FormTemplate json.RawMessage `json:"form_template"`
|
||||
HistoryMode string `json:"history_mode"` // full | summary | fresh
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
TransitionRules json.RawMessage `json:"transition_rules"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── Workflow Version (immutable snapshot) ───
|
||||
|
||||
// WorkflowVersion is an immutable snapshot of a workflow definition
|
||||
// at a point in time. Active workflow instances run against a pinned version.
|
||||
type WorkflowVersion struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
VersionNumber int `json:"version_number"`
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -182,6 +182,11 @@ func (e *Engine) registerCoreSurfaces() {
|
||||
DataRequires: []string{"workflow"},
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "workflow-landing", Route: "/w/:id/:slug",
|
||||
Title: "Workflow Landing", Template: "workflow-landing", Auth: "public",
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +382,7 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
||||
registerRoutes(base, s, e.disabledRedirect())
|
||||
continue
|
||||
}
|
||||
handler := e.RenderSurface(s.ID)
|
||||
handler := e.surfaceHandler(s)
|
||||
registerRoutes(base, s, handler)
|
||||
}
|
||||
}
|
||||
@@ -390,6 +395,9 @@ func (e *Engine) surfaceHandler(s SurfaceManifest) gin.HandlerFunc {
|
||||
if s.ID == "workflow" {
|
||||
return e.RenderWorkflow()
|
||||
}
|
||||
if s.ID == "workflow-landing" {
|
||||
return e.RenderWorkflowLanding()
|
||||
}
|
||||
return e.RenderSurface(s.ID)
|
||||
}
|
||||
|
||||
@@ -469,6 +477,24 @@ type WorkflowPageData struct {
|
||||
SessionName string
|
||||
}
|
||||
|
||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||
type WorkflowLandingPageData struct {
|
||||
WorkflowID string
|
||||
Scope string
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
Branding struct {
|
||||
AccentColor string `json:"accent_color"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
Tagline string `json:"tagline"`
|
||||
}
|
||||
PersonaName string
|
||||
PersonaIcon string
|
||||
StageCount int
|
||||
ResumeURL string // non-empty if visitor has an active session
|
||||
}
|
||||
|
||||
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
|
||||
// The middleware (AuthOrSession) has already created/resumed the session.
|
||||
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
@@ -514,6 +540,80 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RenderWorkflowLanding renders the public landing page for a workflow.
|
||||
// Visitors see the workflow name, description, persona info, and a Start button.
|
||||
func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
// Route: /w/:id/:slug — :id is scope (team-id or "global"),
|
||||
// :slug is the workflow slug. Same :id param name as /w/:id
|
||||
// (chat surface) to avoid Gin wildcard conflicts.
|
||||
scope := c.Param("id")
|
||||
slug := c.Param("slug")
|
||||
|
||||
// Resolve scope to team_id
|
||||
var teamID *string
|
||||
if scope != "global" {
|
||||
teamID = &scope
|
||||
}
|
||||
|
||||
if e.stores.Workflows == nil {
|
||||
c.String(http.StatusNotFound, "Workflows not available")
|
||||
return
|
||||
}
|
||||
|
||||
wf, err := e.stores.Workflows.GetBySlug(ctx, teamID, slug)
|
||||
if err != nil || wf == nil {
|
||||
c.String(http.StatusNotFound, "Workflow not found")
|
||||
return
|
||||
}
|
||||
if !wf.IsActive {
|
||||
c.String(http.StatusNotFound, "Workflow not available")
|
||||
return
|
||||
}
|
||||
|
||||
data := WorkflowLandingPageData{
|
||||
WorkflowID: wf.ID,
|
||||
Scope: scope,
|
||||
Slug: slug,
|
||||
Name: wf.Name,
|
||||
Description: wf.Description,
|
||||
}
|
||||
|
||||
// Parse branding
|
||||
if len(wf.Branding) > 0 {
|
||||
_ = json.Unmarshal(wf.Branding, &data.Branding)
|
||||
}
|
||||
|
||||
// Load stages for count + first persona info
|
||||
stages, _ := e.stores.Workflows.ListStages(ctx, wf.ID)
|
||||
data.StageCount = len(stages)
|
||||
if len(stages) > 0 && stages[0].PersonaID != nil {
|
||||
if p, err := e.stores.Personas.GetByID(ctx, *stages[0].PersonaID); err == nil {
|
||||
data.PersonaName = p.Name
|
||||
data.PersonaIcon = p.Icon
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing active session
|
||||
sbSession, err := c.Cookie("sb_session")
|
||||
if err == nil && sbSession != "" && e.stores.Sessions != nil {
|
||||
sess, err := e.stores.Sessions.GetByToken(ctx, sbSession)
|
||||
if err == nil && sess != nil {
|
||||
data.ResumeURL = "/w/" + sess.ChannelID
|
||||
}
|
||||
}
|
||||
|
||||
instanceName, _, _ := e.loadBranding()
|
||||
|
||||
e.Render(c, "workflow-landing.html", PageData{
|
||||
Surface: "workflow-landing",
|
||||
InstanceName: instanceName,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// getUserContext extracts user info from gin context (set by auth middleware)
|
||||
// and enriches it with display name, username, and email from the database.
|
||||
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
|
||||
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
// Called once at startup. Does NOT overwrite the enabled flag — admin
|
||||
// toggles survive restarts.
|
||||
func (e *Engine) SeedSurfaces() {
|
||||
if e.stores.Surfaces == nil {
|
||||
log.Printf("[pages] Surface registry not available — skipping seed")
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
for _, s := range e.surfaces {
|
||||
manifest := map[string]any{
|
||||
@@ -38,6 +42,9 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
|
||||
if surfaceID == "chat" || surfaceID == "admin" {
|
||||
return true
|
||||
}
|
||||
if e.stores.Surfaces == nil {
|
||||
return true // No registry = all surfaces enabled (backward compat)
|
||||
}
|
||||
ctx := context.Background()
|
||||
sr, err := e.stores.Surfaces.Get(ctx, surfaceID)
|
||||
if err != nil || sr == nil {
|
||||
@@ -48,6 +55,14 @@ func (e *Engine) IsSurfaceEnabled(surfaceID string) bool {
|
||||
|
||||
// EnabledSurfaceIDs returns a list of enabled surface IDs for nav rendering.
|
||||
func (e *Engine) EnabledSurfaceIDs() []string {
|
||||
if e.stores.Surfaces == nil {
|
||||
// No registry — return all core surface IDs
|
||||
var all []string
|
||||
for _, s := range e.surfaces {
|
||||
all = append(all, s.ID)
|
||||
}
|
||||
return all
|
||||
}
|
||||
ctx := context.Background()
|
||||
ids, err := e.stores.Surfaces.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/chat-pane.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/user-menu.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/tool-grants.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/workflow.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
|
||||
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
|
||||
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/></svg>
|
||||
AI
|
||||
</a>
|
||||
<a href="{{$base}}/admin/workflows" class="admin-cat-btn{{if eq $section "workflows"}} active{{end}}" data-cat="workflows">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||
Workflows
|
||||
</a>
|
||||
<a href="{{$base}}/admin/health" class="admin-cat-btn{{if eq $section "health"}} active{{else if eq $section "routing"}} active{{else if eq $section "capabilities"}} active{{end}}" data-cat="routing">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Routing
|
||||
@@ -212,6 +216,8 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/files.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-api.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/workflow-admin.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-surfaces.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -519,5 +519,8 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
|
||||
{{end}}
|
||||
|
||||
196
server/pages/templates/workflow-landing.html
Normal file
196
server/pages/templates/workflow-landing.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{{define "workflow-landing.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Data.Name}} — {{.InstanceName}}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
:root {
|
||||
--wf-accent: {{if .Data.Branding.AccentColor}}{{.Data.Branding.AccentColor}}{{else}}var(--accent){{end}};
|
||||
}
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.wf-landing {
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.wf-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: var(--wf-accent);
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
.wf-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.wf-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wf-description {
|
||||
font-size: 15px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.wf-tagline {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 24px;
|
||||
font-style: italic;
|
||||
}
|
||||
.wf-persona {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-persona-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-raised);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.wf-start-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 40px;
|
||||
background: var(--wf-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.wf-start-btn:hover { opacity: 0.9; }
|
||||
.wf-start-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.wf-resume {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: var(--wf-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wf-resume:hover { text-decoration: underline; }
|
||||
.wf-footer {
|
||||
margin-top: 48px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: var(--bg); color: var(--text); }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Apply dark/light based on OS preference
|
||||
(function() {
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wf-landing">
|
||||
{{if .Data.Branding.LogoURL}}
|
||||
<div class="wf-logo">
|
||||
<img src="{{.Data.Branding.LogoURL}}" alt="{{.Data.Name}}">
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="wf-logo">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}⚡{{end}}</div>
|
||||
{{end}}
|
||||
|
||||
<h1 class="wf-title">{{.Data.Name}}</h1>
|
||||
|
||||
{{if .Data.Branding.Tagline}}
|
||||
<p class="wf-tagline">{{.Data.Branding.Tagline}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Data.Description}}
|
||||
<p class="wf-description">{{.Data.Description}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Data.PersonaName}}
|
||||
<div class="wf-persona">
|
||||
<div class="wf-persona-icon">{{if .Data.PersonaIcon}}{{.Data.PersonaIcon}}{{else}}🤖{{end}}</div>
|
||||
<span>You'll be chatting with <strong>{{.Data.PersonaName}}</strong></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<button class="wf-start-btn" id="startBtn" onclick="startWorkflow()">
|
||||
Start
|
||||
</button>
|
||||
|
||||
{{if .Data.ResumeURL}}
|
||||
<a class="wf-resume" href="{{.Data.ResumeURL}}">Resume previous session →</a>
|
||||
{{end}}
|
||||
|
||||
<div class="wf-footer">
|
||||
Powered by {{.InstanceName}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function startWorkflow() {
|
||||
const btn = document.getElementById('startBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Starting…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{.BasePath}}/api/v1/workflow-entry/{{.Data.Scope}}/{{.Data.Slug}}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.redirect_to) {
|
||||
window.location.href = '{{.BasePath}}' + data.redirect_to;
|
||||
} else {
|
||||
alert(data.error || 'Failed to start workflow');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Start';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error — please try again');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -48,6 +48,7 @@ type Stores struct {
|
||||
RoutingPolicies RoutingPolicyStore
|
||||
Sessions SessionStore
|
||||
Surfaces SurfaceRegistryStore // v0.25.0: Surface lifecycle management
|
||||
Workflows WorkflowStore // v0.26.1: Workflow definitions + stages
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -647,6 +648,11 @@ type SessionStore interface {
|
||||
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)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
@@ -87,6 +88,21 @@ func (s *SessionStore) Delete(ctx context.Context, id string) error {
|
||||
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 == "" {
|
||||
|
||||
@@ -41,5 +41,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
||||
Sessions: NewSessionStore(),
|
||||
Surfaces: NewSurfaceRegistryStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
}
|
||||
}
|
||||
|
||||
316
server/store/postgres/workflows.go
Normal file
316
server/store/postgres/workflows.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// WorkflowStore implements store.WorkflowStore for Postgres.
|
||||
type WorkflowStore struct{}
|
||||
|
||||
func NewWorkflowStore() *WorkflowStore { return &WorkflowStore{} }
|
||||
|
||||
// ── Workflow CRUD ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
||||
branding := jsonOrEmpty(w.Branding)
|
||||
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, version, created_at, updated_at`,
|
||||
w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||
w.IsActive, jsonOrNull(w.OnComplete), retention, w.CreatedBy,
|
||||
).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
||||
w := &models.Workflow{}
|
||||
var branding, retention, onComplete []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE id = $1`, id,
|
||||
).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = branding
|
||||
w.OnComplete = onComplete
|
||||
w.Retention = retention
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error) {
|
||||
var q string
|
||||
var args []interface{}
|
||||
if teamID != nil {
|
||||
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = $1 AND slug = $2`
|
||||
args = []interface{}{*teamID, slug}
|
||||
} else {
|
||||
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL AND slug = $1`
|
||||
args = []interface{}{slug}
|
||||
}
|
||||
w := &models.Workflow{}
|
||||
var branding, retention, onComplete []byte
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = branding
|
||||
w.OnComplete = onComplete
|
||||
w.Retention = retention
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.WorkflowPatch) error {
|
||||
// Build dynamic SET clause
|
||||
sets := []string{}
|
||||
args := []interface{}{}
|
||||
idx := 1
|
||||
|
||||
add := func(col string, val interface{}) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.Name != nil {
|
||||
add("name", *patch.Name)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
add("description", *patch.Description)
|
||||
}
|
||||
if patch.Branding != nil {
|
||||
add("branding", string(*patch.Branding))
|
||||
}
|
||||
if patch.EntryMode != nil {
|
||||
add("entry_mode", *patch.EntryMode)
|
||||
}
|
||||
if patch.IsActive != nil {
|
||||
add("is_active", *patch.IsActive)
|
||||
}
|
||||
if patch.OnComplete != nil {
|
||||
add("on_complete", string(*patch.OnComplete))
|
||||
}
|
||||
if patch.Retention != nil {
|
||||
add("retention", string(*patch.Retention))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Increment version on any edit
|
||||
sets = append(sets, fmt.Sprintf("version = version + 1"))
|
||||
|
||||
q := "UPDATE workflows SET "
|
||||
for i, s := range sets {
|
||||
if i > 0 {
|
||||
q += ", "
|
||||
}
|
||||
q += s
|
||||
}
|
||||
q += fmt.Sprintf(" WHERE id = $%d", idx)
|
||||
args = append(args, id)
|
||||
|
||||
_, err := DB.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM workflows WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = $1
|
||||
ORDER BY name ASC`, teamID)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL
|
||||
ORDER BY name ASC`)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...interface{}) ([]models.Workflow, error) {
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Workflow
|
||||
for rows.Next() {
|
||||
var w models.Workflow
|
||||
var branding, retention, onComplete []byte
|
||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||
&branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
|
||||
&retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = branding
|
||||
w.OnComplete = onComplete
|
||||
w.Retention = retention
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Stages ──────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, history_mode, auto_transition, transition_rules)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, created_at`,
|
||||
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, st.HistoryMode, st.AutoTransition, transRules,
|
||||
).Scan(&st.ID, &st.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, history_mode, auto_transition, transition_rules, created_at
|
||||
FROM workflow_stages WHERE workflow_id = $1
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.WorkflowStage
|
||||
for rows.Next() {
|
||||
var st models.WorkflowStage
|
||||
var formTpl, transRules []byte
|
||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
|
||||
&st.AutoTransition, &transRules, &st.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.FormTemplate = formTpl
|
||||
st.TransitionRules = transRules
|
||||
result = append(result, st)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
|
||||
form_template = $6, history_mode = $7, auto_transition = $8, transition_rules = $9
|
||||
WHERE id = $1`,
|
||||
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, st.HistoryMode, st.AutoTransition, transRules)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) DeleteStage(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM workflow_stages WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for i, id := range orderedIDs {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE workflow_stages SET ordinal = $1 WHERE id = $2 AND workflow_id = $3`,
|
||||
i, id, workflowID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ── Versions ────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) Publish(ctx context.Context, v *models.WorkflowVersion) error {
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_versions (workflow_id, version_number, snapshot)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at`,
|
||||
v.WorkflowID, v.VersionNumber, string(v.Snapshot),
|
||||
).Scan(&v.ID, &v.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) {
|
||||
v := &models.WorkflowVersion{}
|
||||
var snapshot []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, workflow_id, version_number, snapshot, created_at
|
||||
FROM workflow_versions WHERE workflow_id = $1 AND version_number = $2`,
|
||||
workflowID, versionNumber,
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Snapshot = snapshot
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) {
|
||||
v := &models.WorkflowVersion{}
|
||||
var snapshot []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, workflow_id, version_number, snapshot, created_at
|
||||
FROM workflow_versions WHERE workflow_id = $1
|
||||
ORDER BY version_number DESC LIMIT 1`,
|
||||
workflowID,
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Snapshot = snapshot
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func jsonOrEmpty(b json.RawMessage) string {
|
||||
if len(b) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func jsonOrDefault(b json.RawMessage, def string) string {
|
||||
if len(b) == 0 {
|
||||
return def
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func jsonOrNull(b json.RawMessage) interface{} {
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -95,6 +96,21 @@ func (s *SessionStore) Delete(ctx context.Context, id string) error {
|
||||
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
|
||||
|
||||
@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
CapOverrides: NewCapOverrideStore(),
|
||||
RoutingPolicies: NewRoutingPolicyStore(),
|
||||
Sessions: NewSessionStore(),
|
||||
Workflows: NewWorkflowStore(),
|
||||
}
|
||||
}
|
||||
|
||||
328
server/store/sqlite/workflows.go
Normal file
328
server/store/sqlite/workflows.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// WorkflowStore implements store.WorkflowStore for SQLite.
|
||||
type WorkflowStore struct{}
|
||||
|
||||
func NewWorkflowStore() *WorkflowStore { return &WorkflowStore{} }
|
||||
|
||||
// ── Workflow CRUD ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
||||
w.ID = store.NewID()
|
||||
now := time.Now().UTC()
|
||||
w.CreatedAt = now
|
||||
w.UpdatedAt = now
|
||||
w.Version = 1
|
||||
branding := jsonOrEmpty(w.Branding)
|
||||
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
|
||||
is_active, version, on_complete, retention, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||
boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
|
||||
w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflow, error) {
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error) {
|
||||
if teamID != nil {
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
|
||||
}
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.WorkflowPatch) error {
|
||||
sets := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if patch.Name != nil {
|
||||
sets = append(sets, "name = ?")
|
||||
args = append(args, *patch.Name)
|
||||
}
|
||||
if patch.Description != nil {
|
||||
sets = append(sets, "description = ?")
|
||||
args = append(args, *patch.Description)
|
||||
}
|
||||
if patch.Branding != nil {
|
||||
sets = append(sets, "branding = ?")
|
||||
args = append(args, string(*patch.Branding))
|
||||
}
|
||||
if patch.EntryMode != nil {
|
||||
sets = append(sets, "entry_mode = ?")
|
||||
args = append(args, *patch.EntryMode)
|
||||
}
|
||||
if patch.IsActive != nil {
|
||||
sets = append(sets, "is_active = ?")
|
||||
args = append(args, boolToInt(*patch.IsActive))
|
||||
}
|
||||
if patch.OnComplete != nil {
|
||||
sets = append(sets, "on_complete = ?")
|
||||
args = append(args, string(*patch.OnComplete))
|
||||
}
|
||||
if patch.Retention != nil {
|
||||
sets = append(sets, "retention = ?")
|
||||
args = append(args, string(*patch.Retention))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sets = append(sets, "version = version + 1")
|
||||
sets = append(sets, "updated_at = ?")
|
||||
args = append(args, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
q := "UPDATE workflows SET "
|
||||
for i, s := range sets {
|
||||
if i > 0 {
|
||||
q += ", "
|
||||
}
|
||||
q += s
|
||||
}
|
||||
q += " WHERE id = ?"
|
||||
args = append(args, id)
|
||||
|
||||
_, err := DB.ExecContext(ctx, q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM workflows WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id = ?
|
||||
ORDER BY name ASC`, teamID)
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, error) {
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
|
||||
version, on_complete, retention, created_by, created_at, updated_at
|
||||
FROM workflows WHERE team_id IS NULL
|
||||
ORDER BY name ASC`)
|
||||
}
|
||||
|
||||
// ── Stages ──────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||
st.ID = store.NewID()
|
||||
st.CreatedAt = time.Now().UTC()
|
||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, history_mode, auto_transition, transition_rules, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.CreatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, history_mode, auto_transition, transition_rules, created_at
|
||||
FROM workflow_stages WHERE workflow_id = ?
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.WorkflowStage
|
||||
for rows.Next() {
|
||||
var st models.WorkflowStage
|
||||
var formTpl, transRules string
|
||||
var autoTrans int
|
||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.HistoryMode,
|
||||
&autoTrans, &transRules, &st.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.FormTemplate = json.RawMessage(formTpl)
|
||||
st.TransitionRules = json.RawMessage(transRules)
|
||||
st.AutoTransition = autoTrans != 0
|
||||
result = append(result, st)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStage) error {
|
||||
formTpl := jsonOrEmpty(st.FormTemplate)
|
||||
transRules := jsonOrEmpty(st.TransitionRules)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
|
||||
form_template = ?, history_mode = ?, auto_transition = ?, transition_rules = ?
|
||||
WHERE id = ?`,
|
||||
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, st.HistoryMode, boolToInt(st.AutoTransition), transRules, st.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) DeleteStage(ctx context.Context, id string) error {
|
||||
_, err := DB.ExecContext(ctx, `DELETE FROM workflow_stages WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error {
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for i, id := range orderedIDs {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE workflow_stages SET ordinal = ? WHERE id = ? AND workflow_id = ?`,
|
||||
i, id, workflowID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ── Versions ────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) Publish(ctx context.Context, v *models.WorkflowVersion) error {
|
||||
v.ID = store.NewID()
|
||||
v.CreatedAt = time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_versions (id, workflow_id, version_number, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
v.ID, v.WorkflowID, v.VersionNumber, string(v.Snapshot),
|
||||
v.CreatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) {
|
||||
v := &models.WorkflowVersion{}
|
||||
var snapshot string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, workflow_id, version_number, snapshot, created_at
|
||||
FROM workflow_versions WHERE workflow_id = ? AND version_number = ?`,
|
||||
workflowID, versionNumber,
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Snapshot = json.RawMessage(snapshot)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) {
|
||||
v := &models.WorkflowVersion{}
|
||||
var snapshot string
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, workflow_id, version_number, snapshot, created_at
|
||||
FROM workflow_versions WHERE workflow_id = ?
|
||||
ORDER BY version_number DESC LIMIT 1`,
|
||||
workflowID,
|
||||
).Scan(&v.ID, &v.WorkflowID, &v.VersionNumber, &snapshot, &v.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Snapshot = json.RawMessage(snapshot)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface{}) (*models.Workflow, error) {
|
||||
w := &models.Workflow{}
|
||||
var branding, retention string
|
||||
var onComplete sql.NullString
|
||||
var isActive int
|
||||
err := DB.QueryRowContext(ctx, q, args...).Scan(
|
||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
|
||||
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = json.RawMessage(branding)
|
||||
w.Retention = json.RawMessage(retention)
|
||||
w.IsActive = isActive != 0
|
||||
if onComplete.Valid {
|
||||
w.OnComplete = json.RawMessage(onComplete.String)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...interface{}) ([]models.Workflow, error) {
|
||||
rows, err := DB.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Workflow
|
||||
for rows.Next() {
|
||||
var w models.Workflow
|
||||
var branding, retention string
|
||||
var onComplete sql.NullString
|
||||
var isActive int
|
||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||
&branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
|
||||
&retention, &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Branding = json.RawMessage(branding)
|
||||
w.Retention = json.RawMessage(retention)
|
||||
w.IsActive = isActive != 0
|
||||
if onComplete.Valid {
|
||||
w.OnComplete = json.RawMessage(onComplete.String)
|
||||
}
|
||||
result = append(result, w)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func jsonOrEmpty(b json.RawMessage) string {
|
||||
if len(b) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func jsonOrDefault(b json.RawMessage, def string) string {
|
||||
if len(b) == 0 {
|
||||
return def
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func jsonOrNull(b json.RawMessage) interface{} {
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
31
server/store/workflow_iface.go
Normal file
31
server/store/workflow_iface.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// WorkflowStore manages workflow definitions, stages, and version snapshots.
|
||||
type WorkflowStore interface {
|
||||
// Workflow CRUD
|
||||
Create(ctx context.Context, w *models.Workflow) error
|
||||
GetByID(ctx context.Context, id string) (*models.Workflow, error)
|
||||
GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error)
|
||||
Update(ctx context.Context, id string, patch models.WorkflowPatch) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error)
|
||||
ListGlobal(ctx context.Context) ([]models.Workflow, error)
|
||||
|
||||
// Stage CRUD
|
||||
CreateStage(ctx context.Context, s *models.WorkflowStage) error
|
||||
ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error)
|
||||
UpdateStage(ctx context.Context, s *models.WorkflowStage) error
|
||||
DeleteStage(ctx context.Context, id string) error
|
||||
ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error
|
||||
|
||||
// Versioning
|
||||
Publish(ctx context.Context, v *models.WorkflowVersion) error
|
||||
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
|
||||
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
|
||||
}
|
||||
@@ -36,15 +36,6 @@ func AllDefinitions() []ToolDef {
|
||||
return defs
|
||||
}
|
||||
|
||||
// AllDefinitionsFiltered returns tool definitions excluding any names in the
|
||||
// disabled set. Used when the frontend sends a disabled_tools list.
|
||||
//
|
||||
// Deprecated: use AvailableFor() for context-aware filtering. This wrapper
|
||||
// remains for call sites that don't yet have a ToolContext.
|
||||
func AllDefinitionsFiltered(disabled map[string]bool) []ToolDef {
|
||||
return AvailableFor(ToolContext{}, disabled)
|
||||
}
|
||||
|
||||
// AvailableFor returns tool definitions available in the given context,
|
||||
// excluding any names in the disabled set. Tools that implement
|
||||
// ContextualTool have their Availability() predicate evaluated against
|
||||
|
||||
243
server/tools/workflow.go
Normal file
243
server/tools/workflow.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Late Registration ────────────────────────
|
||||
|
||||
// RegisterWorkflowTools registers the workflow_advance tool.
|
||||
// Called from main.go after stores are initialized.
|
||||
func RegisterWorkflowTools(stores store.Stores) {
|
||||
if stores.Workflows == nil {
|
||||
log.Println("⚠ workflow tools: WorkflowStore not available, skipping registration")
|
||||
return
|
||||
}
|
||||
Register(&workflowAdvanceTool{stores: stores})
|
||||
log.Println("✅ workflow tools registered (workflow_advance)")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// workflow_advance
|
||||
// ═══════════════════════════════════════════
|
||||
//
|
||||
// Called by the stage persona when it has collected all required form
|
||||
// data from the visitor. Triggers a stage transition — same effect as
|
||||
// the human-triggered POST /channels/:id/workflow/advance endpoint.
|
||||
|
||||
type workflowAdvanceTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// Override availability: only available inside workflow channels.
|
||||
func (t *workflowAdvanceTool) Availability() Require { return RequireWorkflow }
|
||||
|
||||
func (t *workflowAdvanceTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "workflow_advance",
|
||||
DisplayName: "Advance Workflow",
|
||||
Category: "workflow",
|
||||
Description: "Advance the workflow to the next stage. Call this when you have " +
|
||||
"collected all required information from the visitor. Pass the collected " +
|
||||
"data as a JSON object — it will be saved and made available to the next stage.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Collected form data as key-value pairs. Must include all fields defined in the stage's form template.",
|
||||
},
|
||||
"summary": Prop("string", "Brief summary of what was collected (1-2 sentences, shown in stage notes)"),
|
||||
}, []string{"data"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if len(args.Data) == 0 || string(args.Data) == "null" {
|
||||
return "", fmt.Errorf("data is required")
|
||||
}
|
||||
|
||||
channelID := execCtx.ChannelID
|
||||
if channelID == "" {
|
||||
return "", fmt.Errorf("no channel context")
|
||||
}
|
||||
|
||||
// Read current workflow state
|
||||
var workflowID string
|
||||
var currentStage int
|
||||
var status string
|
||||
var wfID *string
|
||||
err := database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT workflow_id, COALESCE(current_stage, 0), COALESCE(workflow_status, 'active')
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`), channelID).Scan(&wfID, ¤tStage, &status)
|
||||
if err != nil || wfID == nil {
|
||||
return "", fmt.Errorf("not a workflow channel")
|
||||
}
|
||||
workflowID = *wfID
|
||||
|
||||
if status != "active" {
|
||||
return "", fmt.Errorf("workflow is %s, cannot advance", status)
|
||||
}
|
||||
|
||||
// Load stages
|
||||
stages, err := t.stores.Workflows.ListStages(ctx, workflowID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load stages: %w", err)
|
||||
}
|
||||
|
||||
// Merge collected data into stage_data
|
||||
mergedData := MergeWorkflowStageData(ctx, channelID, args.Data)
|
||||
nextStage := currentStage + 1
|
||||
|
||||
if nextStage >= len(stages) {
|
||||
// Workflow complete
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET current_stage = $1, workflow_status = 'completed',
|
||||
stage_data = $2, last_activity_at = $3, ai_mode = 'off'
|
||||
WHERE id = $4
|
||||
`), nextStage, mergedData, time.Now().UTC(), channelID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to complete workflow: %w", err)
|
||||
}
|
||||
|
||||
// Create note with collected data
|
||||
CreateWorkflowStageNote(ctx, t.stores, channelID, currentStage, args.Data, args.Summary)
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "completed",
|
||||
"current_stage": nextStage,
|
||||
"message": "Workflow completed successfully.",
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// Advance to next stage
|
||||
_, err = database.DB.ExecContext(ctx, database.Q(`
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`), nextStage, mergedData, time.Now().UTC(), channelID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to advance: %w", err)
|
||||
}
|
||||
|
||||
// Create note with collected data
|
||||
CreateWorkflowStageNote(ctx, t.stores, channelID, currentStage, args.Data, args.Summary)
|
||||
|
||||
// Create assignment if next stage has an assignment team
|
||||
nextStageDef := stages[nextStage]
|
||||
if nextStageDef.AssignmentTeamID != nil {
|
||||
CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"status": "active",
|
||||
"current_stage": nextStage,
|
||||
"stage_name": nextStageDef.Name,
|
||||
"message": fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name),
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// ── Shared helpers (used by both tool and handler) ─
|
||||
|
||||
// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string.
|
||||
func MergeWorkflowStageData(ctx context.Context, channelID string, newData json.RawMessage) string {
|
||||
var existing []byte
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT COALESCE(stage_data, '{}') FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&existing)
|
||||
|
||||
if len(newData) == 0 || string(newData) == "null" {
|
||||
if len(existing) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
return string(existing)
|
||||
}
|
||||
|
||||
var base map[string]interface{}
|
||||
if err := json.Unmarshal(existing, &base); err != nil {
|
||||
base = map[string]interface{}{}
|
||||
}
|
||||
var incoming map[string]interface{}
|
||||
if err := json.Unmarshal(newData, &incoming); err == nil {
|
||||
for k, v := range incoming {
|
||||
base[k] = v
|
||||
}
|
||||
}
|
||||
merged, _ := json.Marshal(base)
|
||||
return string(merged)
|
||||
}
|
||||
|
||||
// CreateWorkflowStageNote persists collected form data as a channel-scoped note.
|
||||
func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID string, stageOrdinal int, data json.RawMessage, summary string) {
|
||||
if stores.Notes == nil || len(data) == 0 || string(data) == "null" {
|
||||
return
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Stage %d collected data", stageOrdinal)
|
||||
if summary != "" {
|
||||
title = fmt.Sprintf("Stage %d: %s", stageOrdinal, summary)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Find workflow channel owner for the note's user_id
|
||||
var userID string
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT user_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&userID)
|
||||
if userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
note := &models.Note{
|
||||
Title: title,
|
||||
Content: content,
|
||||
SourceChannelID: &channelID,
|
||||
}
|
||||
note.UserID = userID
|
||||
|
||||
if err := stores.Notes.Create(ctx, note); err != nil {
|
||||
log.Printf("⚠️ Failed to create stage note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWorkflowAssignment creates an unassigned workflow assignment entry.
|
||||
func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) {
|
||||
if database.IsSQLite() {
|
||||
id := store.NewID()
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, id, channelID, stage, teamID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (channel_id, stage, team_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`, channelID, stage, teamID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user