- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
552 lines
18 KiB
Go
552 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/events"
|
|
"switchboard-core/models"
|
|
"switchboard-core/notifications"
|
|
"switchboard-core/sandbox"
|
|
"switchboard-core/store"
|
|
"switchboard-core/tools"
|
|
"switchboard-core/workflow"
|
|
)
|
|
|
|
// ── Workflow Instance Handler ───────────────
|
|
// Manages the runtime lifecycle of workflow channels: starting instances,
|
|
// advancing/rejecting stages, and querying status.
|
|
|
|
type WorkflowInstanceHandler struct {
|
|
stores store.Stores
|
|
hub *events.Hub
|
|
notifSvc *notifications.Service
|
|
runner *sandbox.Runner
|
|
}
|
|
|
|
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service, runner *sandbox.Runner) *WorkflowInstanceHandler {
|
|
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc, runner: runner}
|
|
}
|
|
|
|
// ── Start ───────────────────────────────────
|
|
|
|
// Start creates a new workflow channel from a published workflow version.
|
|
// POST /api/v1/workflows/:id/start
|
|
func (h *WorkflowInstanceHandler) Start(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
wfID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
|
|
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
|
return
|
|
}
|
|
if !wf.IsActive {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is not active"})
|
|
return
|
|
}
|
|
|
|
ver, err := h.stores.Workflows.GetLatestVersion(ctx, wfID)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no published version — publish first"})
|
|
return
|
|
}
|
|
|
|
stages, err := h.stores.Workflows.ListStages(ctx, wfID)
|
|
if err != nil || len(stages) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow has no stages"})
|
|
return
|
|
}
|
|
|
|
// Create the workflow channel
|
|
ch := &models.Channel{
|
|
UserID: userID,
|
|
Title: wf.Name,
|
|
Description: wf.Description,
|
|
Type: "workflow",
|
|
TeamID: wf.TeamID,
|
|
}
|
|
if err := h.stores.Channels.Create(ctx, ch); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Set workflow-specific columns (not part of base Channel.Create)
|
|
allowAnon := wf.EntryMode == "public_link"
|
|
err = h.stores.Channels.SetWorkflowInstance(ctx, ch.ID, wfID, ver.VersionNumber, []byte("{}"), "active")
|
|
if err != nil {
|
|
log.Printf("Failed to set workflow columns on channel %s: %v", ch.ID, err)
|
|
}
|
|
_ = h.stores.Channels.Update(ctx, ch.ID, map[string]interface{}{
|
|
"allow_anonymous": allowAnon,
|
|
"ai_mode": "auto",
|
|
})
|
|
|
|
// Add caller as channel owner
|
|
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
|
ChannelID: ch.ID,
|
|
ParticipantType: "user",
|
|
ParticipantID: userID,
|
|
Role: "owner",
|
|
})
|
|
|
|
// Bind stage 0 persona as participant
|
|
firstStage := stages[0]
|
|
if firstStage.PersonaID != nil {
|
|
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
|
ChannelID: ch.ID,
|
|
ParticipantType: "persona",
|
|
ParticipantID: *firstStage.PersonaID,
|
|
Role: "member",
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"channel_id": ch.ID,
|
|
"workflow_id": wfID,
|
|
"workflow_version": ver.VersionNumber,
|
|
"current_stage": 0,
|
|
"stage": firstStage,
|
|
})
|
|
}
|
|
|
|
// ── Status ──────────────────────────────────
|
|
|
|
// GetStatus returns the workflow state for a channel.
|
|
// GET /api/v1/channels/:id/workflow/status
|
|
func (h *WorkflowInstanceHandler) GetStatus(c *gin.Context) {
|
|
channelID := c.Param("id")
|
|
ws, err := h.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
|
|
if err != nil || ws == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, ws)
|
|
}
|
|
|
|
// ── Advance ─────────────────────────────────
|
|
|
|
// Advance moves the workflow to the next stage.
|
|
// POST /api/v1/channels/:id/workflow/advance
|
|
func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
channelID := c.Param("id")
|
|
|
|
workflowID, currentStage, status, err := h.readWorkflowState(ctx, channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
|
return
|
|
}
|
|
if status != "active" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot advance"})
|
|
return
|
|
}
|
|
|
|
stages, err := h.stores.Workflows.ListStages(ctx, workflowID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
_ = c.ShouldBindJSON(&body)
|
|
|
|
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data)
|
|
nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "routing error: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
if nextStage >= len(stages) {
|
|
// Workflow complete
|
|
err = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete workflow"})
|
|
return
|
|
}
|
|
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
|
|
|
|
// v0.27.0: Emit workflow.completed WS event
|
|
h.emitWorkflowEvent("workflow.completed", channelID, map[string]any{
|
|
"channel_id": channelID, "workflow_id": workflowID, "stage": nextStage,
|
|
})
|
|
|
|
// v0.27.0: on_complete chaining — trigger target workflow if configured
|
|
h.triggerOnComplete(ctx, workflowID, channelID, mergedData)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage})
|
|
return
|
|
}
|
|
|
|
// Advance to next stage
|
|
err = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to advance stage"})
|
|
return
|
|
}
|
|
|
|
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "")
|
|
|
|
// v0.35.0: Fire on_advance hook (can enrich stage_data)
|
|
currentStageDef := stages[currentStage]
|
|
if hookResult := FireOnAdvanceHook(ctx, h.stores, h.runner, currentStageDef.TransitionRules,
|
|
channelID, currentStage, nextStage, json.RawMessage(mergedData)); hookResult != nil {
|
|
if hookResult.Error != "" {
|
|
log.Printf("[workflow] on_advance hook rejected: %s", hookResult.Error)
|
|
} else if hookResult.EnrichedData != nil {
|
|
_ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, hookResult.EnrichedData)
|
|
}
|
|
}
|
|
|
|
nextStageDef := stages[nextStage]
|
|
|
|
// Bind next persona (skip for form_only — no LLM needed)
|
|
if nextStageDef.StageMode != models.StageModeFormOnly && nextStageDef.PersonaID != nil {
|
|
alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID)
|
|
if !alreadyIn {
|
|
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
|
ChannelID: channelID,
|
|
ParticipantType: "persona",
|
|
ParticipantID: *nextStageDef.PersonaID,
|
|
Role: "member",
|
|
})
|
|
}
|
|
}
|
|
|
|
// v0.27.0: Assignment + round-robin + WS notifications
|
|
if nextStageDef.AssignmentTeamID != nil {
|
|
assignmentID := tools.CreateWorkflowAssignment(ctx, h.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID)
|
|
|
|
// Round-robin auto-assignment if configured
|
|
assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID)
|
|
|
|
// Notify team members about new assignment
|
|
h.notifyAssignment(ctx, *nextStageDef.AssignmentTeamID, channelID, nextStageDef.Name, assignedTo)
|
|
}
|
|
|
|
// v0.27.0: Emit workflow.advanced WS event
|
|
h.emitWorkflowEvent("workflow.advanced", channelID, map[string]any{
|
|
"channel_id": channelID, "workflow_id": workflowID,
|
|
"stage": nextStage, "stage_name": nextStageDef.Name,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "active",
|
|
"current_stage": nextStage,
|
|
"stage": nextStageDef,
|
|
})
|
|
}
|
|
|
|
// ── Reject ──────────────────────────────────
|
|
|
|
// Reject returns the workflow to the previous stage with a reason.
|
|
// POST /api/v1/channels/:id/workflow/reject
|
|
func (h *WorkflowInstanceHandler) Reject(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
channelID := c.Param("id")
|
|
|
|
var body struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.Reason == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "reason is required"})
|
|
return
|
|
}
|
|
|
|
_, currentStage, status, err := h.readWorkflowState(ctx, channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
|
return
|
|
}
|
|
if status != "active" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot reject"})
|
|
return
|
|
}
|
|
if currentStage <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot reject from the first stage"})
|
|
return
|
|
}
|
|
|
|
prevStage := currentStage - 1
|
|
err = h.stores.Channels.RejectWorkflowToStage(ctx, channelID, prevStage)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject stage"})
|
|
return
|
|
}
|
|
|
|
// Persist rejection as system message
|
|
if h.stores.Messages != nil {
|
|
_ = h.stores.Messages.Create(ctx, &models.Message{
|
|
ChannelID: channelID,
|
|
Role: "system",
|
|
Content: "Stage rejected: " + body.Reason,
|
|
ParticipantType: "user",
|
|
ParticipantID: c.GetString("user_id"),
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "active",
|
|
"current_stage": prevStage,
|
|
"reason": body.Reason,
|
|
})
|
|
}
|
|
|
|
// ── Cancel (v0.37.15) ────────────────────────
|
|
|
|
// CancelInstance cancels a workflow instance and all its open assignments.
|
|
// POST /api/v1/channels/:id/workflow/cancel
|
|
func (h *WorkflowInstanceHandler) CancelInstance(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
channelID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
role, _ := c.Get("role")
|
|
|
|
workflowID, _, status, err := h.readWorkflowState(ctx, channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
|
return
|
|
}
|
|
if status != "active" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
|
|
return
|
|
}
|
|
|
|
// Auth: instance owner, team admin, or global admin
|
|
if role != "admin" {
|
|
ch, err := h.stores.Channels.GetByID(ctx, channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
|
return
|
|
}
|
|
isOwner := ch.UserID == userID
|
|
isTeamAdmin := false
|
|
if ch.TeamID != nil && h.stores.Teams != nil {
|
|
isTeamAdmin, _ = h.stores.Teams.IsTeamAdmin(ctx, *ch.TeamID, userID)
|
|
}
|
|
if !isOwner && !isTeamAdmin {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "only instance owner, team admin, or global admin can cancel"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Cancel the workflow instance
|
|
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
|
|
return
|
|
}
|
|
|
|
// Cancel all open assignments
|
|
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
|
|
|
|
// Emit WS event
|
|
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
|
|
"channel_id": channelID,
|
|
"workflow_id": workflowID,
|
|
"cancelled_by": userID,
|
|
"assignments_cancelled": cancelled,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"cancelled": true,
|
|
"channel_id": channelID,
|
|
"assignments_cancelled": cancelled,
|
|
})
|
|
}
|
|
|
|
// CancelTeamInstance cancels a workflow instance via the team-admin monitor.
|
|
// POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel
|
|
func (h *WorkflowInstanceHandler) CancelTeamInstance(c *gin.Context) {
|
|
teamID := c.Param("teamId")
|
|
channelID := c.Param("channelId")
|
|
userID := c.GetString("user_id")
|
|
role, _ := c.Get("role")
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Auth: team admin or global admin
|
|
if role != "admin" {
|
|
if h.stores.Teams == nil {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
|
return
|
|
}
|
|
isTA, _ := h.stores.Teams.IsTeamAdmin(ctx, teamID, userID)
|
|
if !isTA {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Verify the channel belongs to this team
|
|
ch, err := h.stores.Channels.GetByID(ctx, channelID)
|
|
if err != nil || ch.TeamID == nil || *ch.TeamID != teamID {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow instance not found for this team"})
|
|
return
|
|
}
|
|
|
|
_, _, status, err := h.readWorkflowState(ctx, channelID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
|
return
|
|
}
|
|
if status != "active" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + status + ", cannot cancel"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Channels.CancelWorkflow(ctx, channelID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel workflow"})
|
|
return
|
|
}
|
|
|
|
cancelled, _ := h.stores.Workflows.CancelAssignmentsForChannel(ctx, channelID)
|
|
|
|
h.emitWorkflowEvent("workflow.cancelled", channelID, map[string]any{
|
|
"channel_id": channelID,
|
|
"cancelled_by": userID,
|
|
"assignments_cancelled": cancelled,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"cancelled": true,
|
|
"channel_id": channelID,
|
|
"assignments_cancelled": cancelled,
|
|
})
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────
|
|
|
|
func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channelID string) (workflowID string, currentStage int, status string, err error) {
|
|
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
|
if err != nil || ws == nil {
|
|
return "", 0, "", fmt.Errorf("workflow channel not found")
|
|
}
|
|
if ws.WorkflowID != nil {
|
|
workflowID = *ws.WorkflowID
|
|
}
|
|
return workflowID, ws.CurrentStage, ws.Status, nil
|
|
}
|
|
|
|
// emitWorkflowEvent pushes a workflow event to all user participants in the channel.
|
|
// Uses PublishToUser (not room-scoped Bus.Publish) because room subscriptions are
|
|
// not yet wired on the client side. See websocket.md § Room Model.
|
|
func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) {
|
|
if h.hub == nil {
|
|
return
|
|
}
|
|
payload, _ := json.Marshal(data)
|
|
evt := events.Event{
|
|
Label: label,
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
}
|
|
|
|
// Send to all user participants in the channel
|
|
pids, err := h.stores.Channels.ListUserParticipantIDs(context.Background(), channelID, "")
|
|
if err != nil {
|
|
log.Printf("[ws] %s: failed to query participants for channel %s: %v", label, channelID[:min(8, len(channelID))], err)
|
|
return
|
|
}
|
|
for _, uid := range pids {
|
|
h.hub.PublishToUser(uid, evt)
|
|
}
|
|
}
|
|
|
|
// triggerOnComplete checks if the workflow has an on_complete chain config
|
|
// and starts the target workflow if so.
|
|
func (h *WorkflowInstanceHandler) triggerOnComplete(ctx context.Context, workflowID, channelID, mergedData string) {
|
|
// v0.27.3: Delegate to shared implementation (also handles webhooks)
|
|
go tools.TriggerWorkflowOnComplete(ctx, h.stores, workflowID, channelID, mergedData)
|
|
}
|
|
|
|
// tryRoundRobin checks if the stage has auto_assign:"round_robin" in
|
|
// transition_rules and assigns the newly created assignment to the
|
|
// least-recently-assigned team member. Returns the assigned user ID or "".
|
|
func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage models.WorkflowStage, assignmentID string) string {
|
|
if assignmentID == "" || stage.AssignmentTeamID == nil {
|
|
return ""
|
|
}
|
|
|
|
// Check transition_rules for auto_assign
|
|
var rules struct {
|
|
AutoAssign string `json:"auto_assign"`
|
|
}
|
|
if len(stage.TransitionRules) > 0 {
|
|
_ = json.Unmarshal(stage.TransitionRules, &rules)
|
|
}
|
|
if rules.AutoAssign != "round_robin" {
|
|
return ""
|
|
}
|
|
|
|
assignedTo, err := h.stores.Workflows.TryRoundRobin(ctx, *stage.AssignmentTeamID, assignmentID)
|
|
if err != nil {
|
|
log.Printf("[workflow] round-robin: failed to auto-assign %s: %v", assignmentID, err)
|
|
return ""
|
|
}
|
|
if assignedTo != "" {
|
|
log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, assignedTo)
|
|
}
|
|
return assignedTo
|
|
}
|
|
|
|
// notifyAssignment sends notifications to team members about a new workflow assignment.
|
|
func (h *WorkflowInstanceHandler) notifyAssignment(ctx context.Context, teamID, channelID, stageName, assignedTo string) {
|
|
if h.notifSvc == nil || h.stores.Teams == nil {
|
|
return
|
|
}
|
|
|
|
members, err := h.stores.Teams.ListMembers(ctx, teamID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, m := range members {
|
|
title := "New workflow assignment"
|
|
body := "Stage '" + stageName + "' needs review"
|
|
if assignedTo != "" && m.UserID == assignedTo {
|
|
title = "Workflow assigned to you"
|
|
body = "Stage '" + stageName + "' has been assigned to you (round-robin)"
|
|
}
|
|
|
|
n := &models.Notification{
|
|
UserID: m.UserID,
|
|
Type: "workflow.assigned",
|
|
Title: title,
|
|
Body: body,
|
|
ResourceType: models.ResourceTypeChannel,
|
|
ResourceID: channelID,
|
|
}
|
|
if err := h.notifSvc.Notify(ctx, n); err != nil {
|
|
log.Printf("[workflow] notify assignment: %v", err)
|
|
}
|
|
}
|
|
|
|
// Also emit targeted WS event for immediate UI update
|
|
if h.hub != nil {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"channel_id": channelID, "team_id": teamID,
|
|
"stage_name": stageName, "assigned_to": assignedTo,
|
|
})
|
|
for _, m := range members {
|
|
h.hub.PublishToUser(m.UserID, events.Event{
|
|
Label: "workflow.assigned",
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// createStageNote and mergeStageData are now in tools/workflow.go
|
|
// (shared between handler and workflow_advance tool).
|