package tools import ( "context" "encoding/json" "fmt" "log" "time" "switchboard-core/models" "switchboard-core/store" "switchboard-core/webhook" "switchboard-core/workflow" ) // ── 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}) Register(&workflowRouteTool{stores: stores}) log.Println("✅ workflow tools registered (workflow_advance, workflow_route)") } // ═══════════════════════════════════════════ // 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, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData)) if err != nil { return "", fmt.Errorf("routing error: %w", err) } 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 } // ═══════════════════════════════════════════ // workflow_route (v0.35.0) // ═══════════════════════════════════════════ // // Called by a stage persona to route the workflow to a specific named stage. // Enables AI-triggered routing (escalation, correction loops) and // conditional branching driven by persona judgment. type workflowRouteTool struct { BaseTool stores store.Stores } func (t *workflowRouteTool) Availability() Require { return RequireWorkflow } func (t *workflowRouteTool) Definition() ToolDef { return ToolDef{ Name: "workflow_route", DisplayName: "Route Workflow", Category: "workflow", Description: "Route the workflow to a specific stage by name. Use this when the " + "conversation should jump to a particular stage instead of advancing sequentially. " + "Common patterns: escalation to human review, loop back for correction, " + "skip stages based on collected information.", Parameters: JSONSchema(map[string]interface{}{ "target_stage": Prop("string", "Name of the stage to route to (case-insensitive)."), "reason": Prop("string", "Brief explanation of why this routing decision was made."), }, []string{"target_stage", "reason"}), } } func (t *workflowRouteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { TargetStage string `json:"target_stage"` Reason string `json:"reason"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.TargetStage == "" { return "", fmt.Errorf("target_stage is required") } channelID := execCtx.ChannelID if channelID == "" { return "", fmt.Errorf("no channel context") } ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID) if err != nil || ws == nil || ws.WorkflowID == nil { return "", fmt.Errorf("not a workflow channel") } if ws.Status != "active" { return "", fmt.Errorf("workflow is %s, cannot route", ws.Status) } stages, err := t.stores.Workflows.ListStages(ctx, *ws.WorkflowID) if err != nil { return "", fmt.Errorf("failed to load stages: %w", err) } targetOrdinal, err := workflow.ResolveStageByName(stages, args.TargetStage) if err != nil { return "", fmt.Errorf("cannot resolve target: %w", err) } // Record routing decision in stage_data routeEntry, _ := json.Marshal(map[string]any{ "from": ws.CurrentStage, "to": targetOrdinal, "reason": args.Reason, "ts": time.Now().UTC().Format(time.RFC3339), }) historyPatch, _ := json.Marshal(map[string]any{ "_route_history_latest": json.RawMessage(routeEntry), }) mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, json.RawMessage(historyPatch)) if targetOrdinal >= len(stages) { // Route beyond last stage = complete err = t.stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, json.RawMessage(mergedData)) if err != nil { return "", fmt.Errorf("failed to complete workflow: %w", err) } go TriggerWorkflowOnComplete(ctx, t.stores, *ws.WorkflowID, channelID, mergedData) result, _ := json.Marshal(map[string]any{ "status": "completed", "message": "Workflow completed via routing.", }) return string(result), nil } err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, json.RawMessage(mergedData)) if err != nil { return "", fmt.Errorf("failed to route: %w", err) } targetDef := stages[targetOrdinal] // Create assignment if target stage has an assignment team if targetDef.AssignmentTeamID != nil { CreateWorkflowAssignment(ctx, t.stores, channelID, targetOrdinal, *targetDef.AssignmentTeamID) } result, _ := json.Marshal(map[string]any{ "status": "active", "current_stage": targetOrdinal, "stage_name": targetDef.Name, "stage_mode": targetDef.StageMode, "message": fmt.Sprintf("Routed to stage %d: %s (reason: %s)", targetOrdinal, targetDef.Name, args.Reason), }) 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, }) }