Changeset 0.26.0 (#165)

This commit is contained in:
2026-03-10 13:38:01 +00:00
parent dbc1a97343
commit 400f7dd176
48 changed files with 4923 additions and 208 deletions

View File

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

View File

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

View 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();

View 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';

View 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';

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

View 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;

View File

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

View File

@@ -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, &currentStage)
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.

View File

@@ -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 != "" {

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

View File

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

View File

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

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

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

View 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, &currentStage, &status)
if wfID != nil {
workflowID = *wfID
}
return
}
// createStageNote and mergeStageData are now in tools/workflow.go
// (shared between handler and workflow_advance tool).

View 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,
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

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

View File

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

View File

@@ -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 == "" {

View File

@@ -41,5 +41,6 @@ func NewStores(db *sql.DB) store.Stores {
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
Surfaces: NewSurfaceRegistryStore(),
Workflows: NewWorkflowStore(),
}
}

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

View File

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

View File

@@ -40,5 +40,6 @@ func NewStores(db *sql.DB) store.Stores {
CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
Workflows: NewWorkflowStore(),
}
}

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

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

View File

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