This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/workflow_instances.go
2026-03-14 12:30:57 +00:00

497 lines
16 KiB
Go

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/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"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
hub *events.Hub
notifSvc *notifications.Service
}
func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler {
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc}
}
// ── 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 *string `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, "")
// 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 = 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",
})
}
}
// v0.27.0: Assignment + round-robin + WS notifications
if nextStageDef.AssignmentTeamID != nil {
assignmentID := tools.CreateWorkflowAssignment(ctx, 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 = 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
}
// emitWorkflowEvent pushes a workflow event to all user participants in the channel.
// Uses SendToUser (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
rows, err := database.DB.Query(database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
log.Printf("[ws] %s: failed to query participants for channel %s: %v", label, channelID[:min(8, len(channelID))], err)
return
}
defer rows.Close()
for rows.Next() {
var uid string
if rows.Scan(&uid) == nil {
h.hub.SendToUser(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 ""
}
// Get team members
members, err := h.stores.Teams.ListMembers(ctx, *stage.AssignmentTeamID)
if err != nil || len(members) == 0 {
return ""
}
// Find the least-recently-assigned member.
// Query: for each member, find their most recent claimed_at in workflow_assignments.
// Pick the member with the oldest (or null) claimed_at.
var bestUserID string
bestUserID = members[0].UserID // fallback to first member
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT m.user_id, COALESCE(MAX(wa.claimed_at), '1970-01-01T00:00:00Z') as last_claim
FROM team_members m
LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1
WHERE m.team_id = $2
GROUP BY m.user_id
ORDER BY last_claim ASC
LIMIT 1
`), *stage.AssignmentTeamID, *stage.AssignmentTeamID)
if err == nil {
defer rows.Close()
if rows.Next() {
var uid string
var lastClaim string // COALESCE returns TEXT on both dialects
if err := rows.Scan(&uid, &lastClaim); err == nil {
bestUserID = uid
}
}
}
// Claim the assignment for this user
now := time.Now().UTC()
_, err = database.DB.ExecContext(ctx, database.Q(`
UPDATE workflow_assignments
SET assigned_to = $1, status = 'claimed', claimed_at = $2
WHERE id = $3 AND status = 'unassigned'
`), bestUserID, now, assignmentID)
if err != nil {
log.Printf("[workflow] round-robin: failed to auto-assign %s to %s: %v", assignmentID, bestUserID, err)
return ""
}
log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, bestUserID)
return bestUserID
}
// 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.SendToUser(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).