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/tools/workflow.go
2026-03-19 18:50:27 +00:00

354 lines
11 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"chat-switchboard/models"
"chat-switchboard/store"
"chat-switchboard/webhook"
)
// ── 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
ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID)
if err != nil || ws == nil || ws.WorkflowID == nil {
return "", fmt.Errorf("not a workflow channel")
}
workflowID := *ws.WorkflowID
currentStage := ws.CurrentStage
status := ws.Status
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, t.stores.Channels, channelID, args.Data)
nextStage := currentStage + 1
if nextStage >= len(stages) {
// Workflow complete
err = t.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
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)
// v0.27.3: Fire on_complete chaining + webhook (was missing from tool path)
go TriggerWorkflowOnComplete(ctx, t.stores, workflowID, channelID, mergedData)
result, _ := json.Marshal(map[string]interface{}{
"status": "completed",
"current_stage": nextStage,
"message": "Workflow completed successfully.",
})
return string(result), nil
}
// Advance to next stage
err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData))
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, t.stores, channelID, nextStage, *nextStageDef.AssignmentTeamID)
}
msg := fmt.Sprintf("Advanced to stage %d: %s", nextStage, nextStageDef.Name)
if nextStageDef.StageMode == models.StageModeFormOnly {
msg += " (form-only stage — visitor will fill out a form, no chat needed)"
}
result, _ := json.Marshal(map[string]interface{}{
"status": "active",
"current_stage": nextStage,
"stage_name": nextStageDef.Name,
"stage_mode": nextStageDef.StageMode,
"message": msg,
})
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.
// v0.29.0: channelStore parameter replaces raw database.DB access.
func MergeWorkflowStageData(ctx context.Context, channelStore store.ChannelStore, channelID string, newData json.RawMessage) string {
existing, _ := channelStore.GetStageData(ctx, channelID)
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
ch, err := stores.Channels.GetByID(ctx, channelID)
if err != nil || ch == nil {
return
}
userID := ch.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.
// Returns the assignment ID (for round-robin auto-assignment).
// v0.29.0: accepts stores parameter, delegates to WorkflowStore.
func CreateWorkflowAssignment(ctx context.Context, stores store.Stores, channelID string, stage int, teamID string) string {
a := &store.WorkflowAssignment{
ChannelID: channelID,
Stage: stage,
TeamID: teamID,
}
if err := stores.Workflows.CreateAssignment(ctx, a); err != nil {
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
return ""
}
return a.ID
}
// ── Workflow Completion Triggers (v0.27.3) ──
// TriggerWorkflowOnComplete checks if the workflow has an on_complete chain
// config and fires the target workflow. Also delivers webhook if configured.
// Shared by both the HTTP handler (Advance endpoint) and the workflow_advance tool.
func TriggerWorkflowOnComplete(ctx context.Context, stores store.Stores, workflowID, channelID, mergedData string) {
if stores.Workflows == nil {
return
}
wf, err := stores.Workflows.GetByID(ctx, workflowID)
if err != nil || wf == nil {
return
}
// v0.27.3: Webhook delivery on workflow completion
if wf.WebhookURL != "" {
var stageData any
_ = json.Unmarshal([]byte(mergedData), &stageData)
go deliverWorkflowWebhook(wf.WebhookURL, wf.WebhookSecret, workflowID, channelID, stageData)
}
// on_complete chaining
if len(wf.OnComplete) == 0 || string(wf.OnComplete) == "null" {
return
}
var chain struct {
Action string `json:"action"`
TargetSlug string `json:"target_slug"`
DataMap map[string]string `json:"data_map"`
}
if err := json.Unmarshal(wf.OnComplete, &chain); err != nil || chain.Action != "start_workflow" || chain.TargetSlug == "" {
return
}
target, err := stores.Workflows.GetBySlug(ctx, wf.TeamID, chain.TargetSlug)
if err != nil || target == nil || !target.IsActive {
log.Printf("[workflow] on_complete: target workflow '%s' not found or inactive", chain.TargetSlug)
return
}
var sourceData map[string]any
if err := json.Unmarshal([]byte(mergedData), &sourceData); err != nil {
sourceData = map[string]any{}
}
targetData := map[string]any{}
if len(chain.DataMap) > 0 {
for srcKey, tgtKey := range chain.DataMap {
if v, ok := sourceData[srcKey]; ok {
targetData[tgtKey] = v
}
}
} else {
targetData = sourceData
}
ch2, err2 := stores.Channels.GetByID(ctx, channelID)
if err2 != nil || ch2 == nil {
return
}
ownerID := ch2.UserID
if ownerID == "" {
return
}
ver, err := stores.Workflows.GetLatestVersion(ctx, target.ID)
if err != nil {
log.Printf("[workflow] on_complete: target '%s' has no published version", chain.TargetSlug)
return
}
stages, err := stores.Workflows.ListStages(ctx, target.ID)
if err != nil || len(stages) == 0 {
return
}
ch := &models.Channel{
UserID: ownerID,
Title: target.Name + " (chained)",
Description: target.Description,
Type: "workflow",
TeamID: target.TeamID,
}
if err := stores.Channels.Create(ctx, ch); err != nil {
log.Printf("[workflow] on_complete: failed to create chained channel: %v", err)
return
}
initialData, _ := json.Marshal(targetData)
err = stores.Channels.SetWorkflowInstance(ctx, ch.ID, target.ID, ver.VersionNumber, initialData, "active")
if err != nil {
log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err)
return
}
_ = stores.Channels.Update(ctx, ch.ID, map[string]interface{}{"ai_mode": "auto"})
// Bind first stage persona
if len(stages) > 0 && stages[0].PersonaID != nil {
_ = stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
ChannelID: ch.ID,
ParticipantType: "persona",
ParticipantID: *stages[0].PersonaID,
Role: "member",
})
}
log.Printf("[workflow] on_complete: started chained workflow %s → %s (channel %s)",
workflowID, target.ID, ch.ID)
}
// deliverWorkflowWebhook fires a webhook for workflow completion.
func deliverWorkflowWebhook(url, secret, workflowID, channelID string, stageData any) {
webhook.Deliver(url, secret, webhook.Payload{
WorkflowID: workflowID,
ChannelID: channelID,
Status: "completed",
CompletedAt: time.Now().UTC(),
StageData: stageData,
})
}