Changeset 0.35.0 (#209)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
|
||||
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
stage_entered_at TIMESTAMPTZ,
|
||||
|
||||
-- KB auto-injection (v0.28.0)
|
||||
kb_auto_inject BOOLEAN NOT NULL DEFAULT false,
|
||||
@@ -94,6 +95,7 @@ CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id) WHERE pr
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id) WHERE workspace_id IS NOT NULL;
|
||||
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';
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workflow_active ON channels(workflow_id, workflow_status) WHERE workflow_id IS NOT NULL AND workflow_status = 'active';
|
||||
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
@@ -109,6 +111,7 @@ COMMENT ON COLUMN channels.current_stage IS 'Ordinal of the active workflow stag
|
||||
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';
|
||||
COMMENT ON COLUMN channels.stage_entered_at IS 'Timestamp when current workflow stage was entered — used for SLA computation';
|
||||
COMMENT ON COLUMN channels.kb_auto_inject IS 'When true, top-K KB chunks auto-prepend to context';
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
auto_transition BOOLEAN NOT NULL DEFAULT false,
|
||||
transition_rules JSONB NOT NULL DEFAULT '{}',
|
||||
surface_pkg_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||
sla_seconds INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -81,6 +82,7 @@ COMMENT ON TABLE workflow_stages IS 'Ordered stages within a workflow. Each stag
|
||||
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.';
|
||||
COMMENT ON COLUMN workflow_stages.surface_pkg_id IS 'Optional package that provides a custom stage surface. NULL = built-in surface based on stage_mode.';
|
||||
COMMENT ON COLUMN workflow_stages.sla_seconds IS 'SLA budget in seconds for this stage. NULL = no SLA.';
|
||||
|
||||
|
||||
-- =========================================
|
||||
@@ -116,6 +118,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
review_comments JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
claimed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
workflow_status TEXT DEFAULT 'active'
|
||||
CHECK (workflow_status IN ('active', 'completed', 'stale', 'cancelled')),
|
||||
last_activity_at TEXT DEFAULT (datetime('now')),
|
||||
stage_entered_at TEXT,
|
||||
kb_auto_inject INTEGER NOT NULL DEFAULT 0,
|
||||
project_id TEXT,
|
||||
workspace_id TEXT,
|
||||
|
||||
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS workflow_stages (
|
||||
auto_transition INTEGER NOT NULL DEFAULT 0,
|
||||
transition_rules TEXT NOT NULL DEFAULT '{}',
|
||||
surface_pkg_id TEXT,
|
||||
sla_seconds INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
@@ -67,6 +68,7 @@ CREATE TABLE IF NOT EXISTS workflow_assignments (
|
||||
assigned_to TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unassigned'
|
||||
CHECK (status IN ('unassigned', 'claimed', 'completed')),
|
||||
review_comments TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
claimed_at TEXT,
|
||||
completed_at TEXT
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestRouteRegistration(t *testing.T) {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
|
||||
@@ -6,6 +6,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -121,3 +122,62 @@ func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
// CommentOnAssignment adds a review comment to an assignment.
|
||||
// POST /api/v1/workflow-assignments/:id/comment
|
||||
func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
|
||||
return
|
||||
}
|
||||
|
||||
comment := store.ReviewComment{
|
||||
Text: body.Text,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment})
|
||||
}
|
||||
|
||||
// GetAssignment returns a single assignment with review comments.
|
||||
// GET /api/v1/workflow-assignments/:id
|
||||
func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) {
|
||||
assignmentID := c.Param("id")
|
||||
|
||||
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse review_comments for the response
|
||||
var comments []store.ReviewComment
|
||||
if len(a.ReviewComments) > 0 {
|
||||
_ = json.Unmarshal(a.ReviewComments, &comments)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": a.ID,
|
||||
"channel_id": a.ChannelID,
|
||||
"stage": a.Stage,
|
||||
"team_id": a.TeamID,
|
||||
"assigned_to": a.AssignedTo,
|
||||
"status": a.Status,
|
||||
"review_comments": comments,
|
||||
"created_at": a.CreatedAt,
|
||||
"claimed_at": a.ClaimedAt,
|
||||
"completed_at": a.CompletedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/tools"
|
||||
"chat-switchboard/workflow"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
@@ -148,7 +149,7 @@ func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) {
|
||||
|
||||
// 9. Auto-advance if form_only + auto_transition
|
||||
if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition {
|
||||
nextStage := ws.CurrentStage + 1
|
||||
nextStage, _ := workflow.ResolveNextStage(stages, ws.CurrentStage, json.RawMessage(mergedData))
|
||||
if nextStage >= len(stages) {
|
||||
// Complete
|
||||
_ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
||||
|
||||
171
server/handlers/workflow_hooks.go
Normal file
171
server/handlers/workflow_hooks.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── on_advance Hook (v0.35.0) ───────────────
|
||||
//
|
||||
// Fires synchronously after a stage transition succeeds.
|
||||
// The hook can enrich/transform stage_data or reject the transition.
|
||||
//
|
||||
// Config in transition_rules:
|
||||
// {"on_advance": {"package_id": "...", "entry_point": "on_advance"}}
|
||||
//
|
||||
// Hook receives dict: {stage_data, previous_stage, current_stage, channel_id}
|
||||
// Hook returns: {stage_data: {...}} (enriched), None (no change),
|
||||
// or {error: "msg"} (reject — caller should handle rollback)
|
||||
|
||||
// OnAdvanceHookConfig is the on_advance section of transition_rules.
|
||||
type OnAdvanceHookConfig struct {
|
||||
PackageID string `json:"package_id"`
|
||||
EntryPoint string `json:"entry_point"`
|
||||
}
|
||||
|
||||
// TransitionRulesOnAdvance extracts on_advance config from transition_rules JSON.
|
||||
type TransitionRulesOnAdvance struct {
|
||||
OnAdvance *OnAdvanceHookConfig `json:"on_advance,omitempty"`
|
||||
}
|
||||
|
||||
// OnAdvanceResult is the outcome of firing the on_advance hook.
|
||||
type OnAdvanceResult struct {
|
||||
EnrichedData json.RawMessage // updated stage_data (nil = no change)
|
||||
Error string // non-empty = hook rejected the transition
|
||||
}
|
||||
|
||||
// FireOnAdvanceHook fires the on_advance Starlark hook for a stage transition.
|
||||
// Returns nil if no hook is configured or the runner is unavailable.
|
||||
func FireOnAdvanceHook(
|
||||
ctx context.Context,
|
||||
stores store.Stores,
|
||||
runner *sandbox.Runner,
|
||||
previousStageRules json.RawMessage,
|
||||
channelID string,
|
||||
previousStage, currentStage int,
|
||||
stageData json.RawMessage,
|
||||
) *OnAdvanceResult {
|
||||
if runner == nil || stores.Packages == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rules TransitionRulesOnAdvance
|
||||
if len(previousStageRules) > 0 {
|
||||
_ = json.Unmarshal(previousStageRules, &rules)
|
||||
}
|
||||
if rules.OnAdvance == nil || rules.OnAdvance.PackageID == "" || rules.OnAdvance.EntryPoint == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkg, err := stores.Packages.Get(ctx, rules.OnAdvance.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
log.Printf("[workflow-hooks] on_advance: package %s not found", rules.OnAdvance.PackageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build context dict for the hook
|
||||
ctxDict := starlark.NewDict(4)
|
||||
_ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID))
|
||||
_ = ctxDict.SetKey(starlark.String("previous_stage"), starlark.MakeInt(previousStage))
|
||||
_ = ctxDict.SetKey(starlark.String("current_stage"), starlark.MakeInt(currentStage))
|
||||
|
||||
// Parse stage_data into Starlark dict
|
||||
var dataMap map[string]interface{}
|
||||
if json.Unmarshal(stageData, &dataMap) == nil {
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), jsonToStarlark(dataMap))
|
||||
} else {
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.NewDict(0))
|
||||
}
|
||||
|
||||
val, _, err := runner.CallEntryPoint(ctx, pkg, rules.OnAdvance.EntryPoint,
|
||||
starlark.Tuple{ctxDict}, nil, nil)
|
||||
if err != nil {
|
||||
log.Printf("[workflow-hooks] on_advance hook error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return parseOnAdvanceResult(val)
|
||||
}
|
||||
|
||||
// parseOnAdvanceResult extracts enriched data or error from the Starlark return value.
|
||||
func parseOnAdvanceResult(val starlark.Value) *OnAdvanceResult {
|
||||
if val == nil || val == starlark.None {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &OnAdvanceResult{}
|
||||
|
||||
// Check for error
|
||||
if errVal, found, _ := d.Get(starlark.String("error")); found {
|
||||
if s, ok := errVal.(starlark.String); ok {
|
||||
result.Error = string(s)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Check for enriched stage_data
|
||||
if sdVal, found, _ := d.Get(starlark.String("stage_data")); found {
|
||||
if sd, ok := sdVal.(*starlark.Dict); ok {
|
||||
goMap := starlarkDictToMap(sd)
|
||||
if data, err := json.Marshal(goMap); err == nil {
|
||||
result.EnrichedData = data
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// starlarkDictToMap converts a Starlark dict to a Go map.
|
||||
func starlarkDictToMap(d *starlark.Dict) map[string]any {
|
||||
result := make(map[string]any, d.Len())
|
||||
for _, item := range d.Items() {
|
||||
k, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result[string(k)] = starlarkToGo(item[1])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// starlarkToGo converts a Starlark value to a Go value.
|
||||
func starlarkToGo(v starlark.Value) any {
|
||||
switch val := v.(type) {
|
||||
case starlark.NoneType:
|
||||
return nil
|
||||
case starlark.Bool:
|
||||
return bool(val)
|
||||
case starlark.Int:
|
||||
if i, ok := val.Int64(); ok {
|
||||
return i
|
||||
}
|
||||
return val.String()
|
||||
case starlark.Float:
|
||||
return float64(val)
|
||||
case starlark.String:
|
||||
return string(val)
|
||||
case *starlark.List:
|
||||
result := make([]any, val.Len())
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
result[i] = starlarkToGo(val.Index(i))
|
||||
}
|
||||
return result
|
||||
case *starlark.Dict:
|
||||
return starlarkDictToMap(val)
|
||||
default:
|
||||
return v.String()
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func setupWorkflowInstanceHarness(t *testing.T) *workflowInstanceHarness {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"chat-switchboard/events"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/notifications"
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/tools"
|
||||
"chat-switchboard/workflow"
|
||||
)
|
||||
|
||||
// ── Workflow Instance Handler ───────────────
|
||||
@@ -25,10 +27,11 @@ 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) *WorkflowInstanceHandler {
|
||||
return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc}
|
||||
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 ───────────────────────────────────
|
||||
@@ -158,7 +161,11 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, body.Data)
|
||||
nextStage := currentStage + 1
|
||||
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
|
||||
@@ -190,6 +197,17 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
||||
|
||||
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)
|
||||
|
||||
239
server/handlers/workflow_monitor.go
Normal file
239
server/handlers/workflow_monitor.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package handlers
|
||||
|
||||
// workflow_monitor.go — v0.35.0 Monitoring Dashboard
|
||||
//
|
||||
// Provides admin and team-scoped views of active workflow instances,
|
||||
// stage funnels, and stale instance detection for SLA tracking.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Workflow Monitor Handler ─────────────────
|
||||
|
||||
type WorkflowMonitorHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewWorkflowMonitorHandler(stores store.Stores) *WorkflowMonitorHandler {
|
||||
return &WorkflowMonitorHandler{stores: stores}
|
||||
}
|
||||
|
||||
// ── Instance types ──────────────────────────
|
||||
|
||||
// MonitorInstance is a single active workflow instance for the monitoring dashboard.
|
||||
type MonitorInstance struct {
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelTitle string `json:"channel_title"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
CurrentStage int `json:"current_stage"`
|
||||
StageName string `json:"stage_name"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"`
|
||||
AgeSeconds int64 `json:"age_seconds"`
|
||||
StageAgeSeconds int64 `json:"stage_age_seconds"`
|
||||
SLASeconds *int `json:"sla_seconds,omitempty"`
|
||||
SLARemaining *int64 `json:"sla_remaining_seconds,omitempty"`
|
||||
SLABreached bool `json:"sla_breached"`
|
||||
LastActivityAt string `json:"last_activity_at"`
|
||||
}
|
||||
|
||||
// FunnelEntry is a count of instances at a given stage.
|
||||
type FunnelEntry struct {
|
||||
StageOrdinal int `json:"stage_ordinal"`
|
||||
StageName string `json:"stage_name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ── Endpoints ───────────────────────────────
|
||||
|
||||
// ListActiveInstances returns all active workflow instances.
|
||||
// GET /api/v1/admin/workflows/monitor/instances
|
||||
func (h *WorkflowMonitorHandler) ListActiveInstances(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
instances, err := h.queryActiveInstances(ctx, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": instances})
|
||||
}
|
||||
|
||||
// ListTeamActiveInstances returns active workflow instances for a team.
|
||||
// GET /api/v1/teams/:teamId/workflows/monitor/instances
|
||||
func (h *WorkflowMonitorHandler) ListTeamActiveInstances(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
instances, err := h.queryActiveInstances(ctx, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": instances})
|
||||
}
|
||||
|
||||
// GetFunnel returns stage-by-stage instance counts for a workflow.
|
||||
// GET /api/v1/admin/workflows/monitor/funnel/:id
|
||||
func (h *WorkflowMonitorHandler) GetFunnel(c *gin.Context) {
|
||||
workflowID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, workflowID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := h.queryActiveInstances(ctx, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"})
|
||||
return
|
||||
}
|
||||
|
||||
// Count instances at each stage
|
||||
counts := make(map[int]int)
|
||||
for _, inst := range instances {
|
||||
if inst.WorkflowID == workflowID {
|
||||
counts[inst.CurrentStage]++
|
||||
}
|
||||
}
|
||||
|
||||
funnel := make([]FunnelEntry, len(stages))
|
||||
for i, s := range stages {
|
||||
funnel[i] = FunnelEntry{
|
||||
StageOrdinal: s.Ordinal,
|
||||
StageName: s.Name,
|
||||
Count: counts[s.Ordinal],
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": funnel})
|
||||
}
|
||||
|
||||
// ListStaleInstances returns workflow instances that haven't been touched recently.
|
||||
// GET /api/v1/admin/workflows/monitor/stale
|
||||
func (h *WorkflowMonitorHandler) ListStaleInstances(c *gin.Context) {
|
||||
thresholdHours, _ := strconv.Atoi(c.DefaultQuery("threshold_hours", "48"))
|
||||
if thresholdHours <= 0 {
|
||||
thresholdHours = 48
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
instances, err := h.queryActiveInstances(ctx, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query instances"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
threshold := time.Duration(thresholdHours) * time.Hour
|
||||
var stale []MonitorInstance
|
||||
|
||||
for _, inst := range instances {
|
||||
if inst.AgeSeconds > 0 && time.Duration(inst.AgeSeconds)*time.Second > threshold {
|
||||
stale = append(stale, inst)
|
||||
} else {
|
||||
// Also check last_activity_at
|
||||
if t, err := time.Parse(time.RFC3339, inst.LastActivityAt); err == nil {
|
||||
if now.Sub(t) > threshold {
|
||||
stale = append(stale, inst)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stale == nil {
|
||||
stale = []MonitorInstance{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": stale, "threshold_hours": thresholdHours})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (h *WorkflowMonitorHandler) queryActiveInstances(ctx context.Context, teamIDFilter string) ([]MonitorInstance, error) {
|
||||
// Query all active workflow channels via store
|
||||
// This is a new query we build from existing store methods
|
||||
channels, err := h.stores.Channels.ListByType(ctx, "workflow")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var result []MonitorInstance
|
||||
|
||||
for _, ch := range channels {
|
||||
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, ch.ID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil || ws.Status != "active" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Team filter
|
||||
if teamIDFilter != "" {
|
||||
if ch.TeamID == nil || *ch.TeamID != teamIDFilter {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
wf, err := h.stores.Workflows.GetByID(ctx, *ws.WorkflowID)
|
||||
if err != nil || wf == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stages, _ := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
var stageName string
|
||||
var slaSeconds *int
|
||||
if ws.CurrentStage < len(stages) {
|
||||
stageName = stages[ws.CurrentStage].Name
|
||||
slaSeconds = stages[ws.CurrentStage].SLASeconds
|
||||
}
|
||||
|
||||
inst := MonitorInstance{
|
||||
ChannelID: ch.ID,
|
||||
ChannelTitle: ch.Title,
|
||||
WorkflowID: *ws.WorkflowID,
|
||||
WorkflowName: wf.Name,
|
||||
CurrentStage: ws.CurrentStage,
|
||||
StageName: stageName,
|
||||
SLASeconds: slaSeconds,
|
||||
LastActivityAt: "",
|
||||
}
|
||||
|
||||
// Compute ages
|
||||
inst.AgeSeconds = int64(now.Sub(ch.CreatedAt).Seconds())
|
||||
|
||||
if ws.LastActivityAt != nil {
|
||||
inst.LastActivityAt = *ws.LastActivityAt
|
||||
}
|
||||
|
||||
// Stage age from stage_entered_at
|
||||
if ws.StageEnteredAt != nil {
|
||||
if t, err := time.Parse(time.RFC3339, *ws.StageEnteredAt); err == nil {
|
||||
inst.StageAgeSeconds = int64(now.Sub(t).Seconds())
|
||||
|
||||
// SLA computation
|
||||
if slaSeconds != nil {
|
||||
remaining := int64(*slaSeconds) - inst.StageAgeSeconds
|
||||
inst.SLARemaining = &remaining
|
||||
inst.SLABreached = remaining < 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, inst)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []MonitorInstance{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil)
|
||||
wfInstH := NewWorkflowInstanceHandler(stores, nil, nil, nil)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
|
||||
@@ -701,7 +701,7 @@ func main() {
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances (v0.26.2 — runtime lifecycle)
|
||||
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc)
|
||||
wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner)
|
||||
protected.POST("/workflows/:id/start", wfInstH.Start)
|
||||
protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus)
|
||||
protected.POST("/channels/:id/workflow/advance", wfInstH.Advance)
|
||||
@@ -712,6 +712,8 @@ func main() {
|
||||
protected.GET("/workflow-assignments/mine", wfAssignH.ListMine)
|
||||
protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim)
|
||||
protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete)
|
||||
protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment)
|
||||
protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment)
|
||||
|
||||
// Tasks (v0.27.1, permissions v0.27.2)
|
||||
taskH := handlers.NewTaskHandler(stores)
|
||||
@@ -1063,6 +1065,11 @@ func main() {
|
||||
teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage)
|
||||
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
|
||||
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
|
||||
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
|
||||
|
||||
// Team workflow monitoring (v0.35.0)
|
||||
teamWfMon := handlers.NewWorkflowMonitorHandler(stores)
|
||||
teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances)
|
||||
|
||||
// Team tasks — admin CRUD (v0.27.5)
|
||||
teamTaskH := handlers.NewTaskHandler(stores)
|
||||
@@ -1318,6 +1325,12 @@ func main() {
|
||||
wfPkgH := handlers.NewWorkflowPackageHandler(stores)
|
||||
admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage)
|
||||
|
||||
// Workflow monitoring (v0.35.0)
|
||||
wfMonH := handlers.NewWorkflowMonitorHandler(stores)
|
||||
admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances)
|
||||
admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel)
|
||||
admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances)
|
||||
|
||||
// Surface aliases (backward compat — same handlers)
|
||||
admin.GET("/surfaces", pkgAdm.ListPackages)
|
||||
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
|
||||
|
||||
@@ -62,6 +62,7 @@ type WorkflowStage struct {
|
||||
AutoTransition bool `json:"auto_transition"`
|
||||
TransitionRules json.RawMessage `json:"transition_rules"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
SLASeconds *int `json:"sla_seconds,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -85,9 +86,26 @@ var ValidStageModes = map[string]bool{
|
||||
// ── Typed Form Template ─────────────────────
|
||||
|
||||
// TypedFormTemplate is the structured form_template schema (v0.29.3).
|
||||
// v0.35.0: added Fieldsets for progressive multi-step forms.
|
||||
type TypedFormTemplate struct {
|
||||
Fields []FormField `json:"fields"`
|
||||
Fieldsets []FormFieldset `json:"fieldsets,omitempty"` // v0.35.0: multi-step form groups
|
||||
Hooks *FormHooks `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// FormFieldset groups fields into a labelled step for progressive forms (v0.35.0).
|
||||
// When fieldsets is present, top-level fields is ignored.
|
||||
type FormFieldset struct {
|
||||
Label string `json:"label"`
|
||||
Fields []FormField `json:"fields"`
|
||||
Hooks *FormHooks `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
// FieldCondition controls conditional visibility of a form field (v0.35.0).
|
||||
// When set, the field is shown/validated only if the condition is met.
|
||||
type FieldCondition struct {
|
||||
When string `json:"when"` // key of the field to check
|
||||
Op string `json:"op,omitempty"` // eq (default) | neq | in | exists
|
||||
Value any `json:"value,omitempty"` // value to compare against
|
||||
}
|
||||
|
||||
// FormField is a single field in a typed form template.
|
||||
@@ -100,6 +118,7 @@ type FormField struct {
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []FormOption `json:"options,omitempty"`
|
||||
Validation *FormValidation `json:"validation,omitempty"`
|
||||
Condition *FieldCondition `json:"condition,omitempty"` // v0.35.0: conditional visibility
|
||||
}
|
||||
|
||||
// FormOption is a choice for select fields.
|
||||
@@ -145,6 +164,19 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||
if err := json.Unmarshal(raw, &tpl); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// v0.35.0: If fieldsets present, flatten into fields for backward compat
|
||||
if len(tpl.Fieldsets) > 0 {
|
||||
var allFields []FormField
|
||||
for _, fs := range tpl.Fieldsets {
|
||||
allFields = append(allFields, fs.Fields...)
|
||||
}
|
||||
if len(allFields) > 0 {
|
||||
tpl.Fields = allFields
|
||||
return &tpl
|
||||
}
|
||||
}
|
||||
|
||||
if len(tpl.Fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -155,6 +187,11 @@ func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate {
|
||||
return &tpl
|
||||
}
|
||||
|
||||
// AllFields returns all fields from the template, whether from top-level fields or fieldsets.
|
||||
func (t *TypedFormTemplate) AllFields() []FormField {
|
||||
return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields
|
||||
}
|
||||
|
||||
// FieldError is a validation error for a specific form field.
|
||||
type FieldError struct {
|
||||
Key string `json:"key"`
|
||||
@@ -165,6 +202,11 @@ type FieldError struct {
|
||||
func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError {
|
||||
var errs []FieldError
|
||||
for _, f := range tpl.Fields {
|
||||
// v0.35.0: Skip fields whose condition is not met
|
||||
if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, present := data[f.Key]
|
||||
|
||||
// Required check
|
||||
@@ -315,6 +357,49 @@ func validateSelect(f FormField, val interface{}) []FieldError {
|
||||
return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}}
|
||||
}
|
||||
|
||||
// ── Field Condition Evaluator (v0.35.0) ─────
|
||||
|
||||
// evaluateFieldCondition checks if a conditional field should be visible/validated.
|
||||
func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool {
|
||||
val, exists := data[cond.When]
|
||||
op := cond.Op
|
||||
if op == "" {
|
||||
op = "eq"
|
||||
}
|
||||
|
||||
switch op {
|
||||
case "exists":
|
||||
return exists
|
||||
case "eq":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value)
|
||||
case "neq":
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value)
|
||||
case "in":
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
arr, ok := cond.Value.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
vs := fmt.Sprintf("%v", val)
|
||||
for _, item := range arr {
|
||||
if fmt.Sprintf("%v", item) == vs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow Version (immutable snapshot) ───
|
||||
|
||||
// WorkflowVersion is an immutable snapshot of a workflow definition
|
||||
|
||||
@@ -589,6 +589,7 @@ type WorkflowPageData struct {
|
||||
TotalStages int
|
||||
CurrentStage int
|
||||
SurfacePkgID string // v0.30.2: custom package surface override (empty = use StageMode)
|
||||
BrandingJSON string // v0.35.0: workflow branding JSON (accent_color, logo_url, tagline)
|
||||
}
|
||||
|
||||
// WorkflowLandingPageData is passed to workflow-landing.html.
|
||||
@@ -640,13 +641,21 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Load workflow stage info for form rendering (v0.29.3)
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID string
|
||||
var stageMode, stageName, formTplJSON, surfacePkgID, brandingJSON string
|
||||
var totalStages, currentStage int
|
||||
stageMode = "chat_only" // default
|
||||
if e.stores.Channels != nil && channelID != "" {
|
||||
ws, wsErr := e.stores.Channels.GetWorkflowStatus(c.Request.Context(), channelID)
|
||||
if wsErr == nil && ws != nil && ws.WorkflowID != nil {
|
||||
currentStage = ws.CurrentStage
|
||||
|
||||
// v0.35.0: Load workflow branding
|
||||
if wf, wfErr := e.stores.Workflows.GetByID(c.Request.Context(), *ws.WorkflowID); wfErr == nil && wf != nil {
|
||||
if len(wf.Branding) > 0 && string(wf.Branding) != "{}" {
|
||||
brandingJSON = string(wf.Branding)
|
||||
}
|
||||
}
|
||||
|
||||
if stages, sErr := e.stores.Workflows.ListStages(c.Request.Context(), *ws.WorkflowID); sErr == nil && len(stages) > 0 {
|
||||
totalStages = len(stages)
|
||||
if currentStage < len(stages) {
|
||||
@@ -684,6 +693,7 @@ func (e *Engine) RenderWorkflow() gin.HandlerFunc {
|
||||
TotalStages: totalStages,
|
||||
CurrentStage: currentStage,
|
||||
SurfacePkgID: surfacePkgID,
|
||||
BrandingJSON: brandingJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,6 +134,32 @@
|
||||
.wf-body-split .wf-form { flex: 1; border-right: 1px solid var(--border); overflow-y: auto; }
|
||||
.wf-body-split .wf-chat-col { flex: 1; display: flex; flex-direction: column; }
|
||||
.wf-body-split .wf-chat { flex: 1; }
|
||||
|
||||
/* ── Branding (v0.35.0) ────────── */
|
||||
.wf-branding-logo { max-height: 32px; margin-bottom: 4px; }
|
||||
.wf-branding-tagline { font-size: 13px; color: var(--text-2); margin-top: 2px; }
|
||||
|
||||
/* ── Progressive Forms (v0.35.0) ── */
|
||||
.wf-fieldset-nav {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wf-fieldset-nav .step-label { font-size: 14px; font-weight: 600; }
|
||||
.wf-fieldset-nav .step-counter { font-size: 12px; color: var(--text-3); }
|
||||
.wf-fieldset-buttons { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.wf-fieldset-buttons button {
|
||||
padding: 8px 20px; border-radius: 8px; font-size: 14px; cursor: pointer;
|
||||
font-weight: 600; border: 1px solid var(--border);
|
||||
background: var(--bg-surface); color: var(--text);
|
||||
}
|
||||
.wf-fieldset-buttons button.primary {
|
||||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||||
}
|
||||
.wf-fieldset-buttons button.primary:hover { background: var(--accent-hover); }
|
||||
|
||||
/* ── Conditional field (hidden) ── */
|
||||
.wf-form-field.cond-hidden { display: none; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
@@ -149,7 +175,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="wf-shell">
|
||||
<div class="wf-header">
|
||||
<div class="wf-header" id="wfHeader">
|
||||
<h1>{{.Data.ChannelTitle}}</h1>
|
||||
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
|
||||
</div>
|
||||
@@ -183,7 +209,10 @@
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .Data.StageMode "review"}}
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto"></div>
|
||||
<div id="reviewArea" style="flex:1;overflow-y:auto;display:flex">
|
||||
<div id="reviewDataPanel" style="flex:1;overflow-y:auto;border-right:1px solid var(--border)"></div>
|
||||
<div id="reviewActionPanel" style="flex:1;overflow-y:auto;display:flex;flex-direction:column"></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="wf-chat" id="chatMessages"></div>
|
||||
<div class="wf-input">
|
||||
@@ -211,6 +240,33 @@
|
||||
var FORM_TPL = null;
|
||||
try { FORM_TPL = JSON.parse('{{.Data.FormTemplateJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Branding
|
||||
var BRANDING = null;
|
||||
try { BRANDING = JSON.parse('{{.Data.BrandingJSON}}' || 'null'); } catch(e) {}
|
||||
|
||||
// v0.35.0: Apply branding
|
||||
(function() {
|
||||
if (!BRANDING) return;
|
||||
var header = document.getElementById('wfHeader');
|
||||
if (!header) return;
|
||||
if (BRANDING.accent_color) {
|
||||
document.documentElement.style.setProperty('--accent', BRANDING.accent_color);
|
||||
}
|
||||
if (BRANDING.logo_url) {
|
||||
var img = document.createElement('img');
|
||||
img.src = BRANDING.logo_url;
|
||||
img.className = 'wf-branding-logo';
|
||||
img.alt = '';
|
||||
header.insertBefore(img, header.firstChild);
|
||||
}
|
||||
if (BRANDING.tagline) {
|
||||
var tag = document.createElement('div');
|
||||
tag.className = 'wf-branding-tagline';
|
||||
tag.textContent = BRANDING.tagline;
|
||||
header.appendChild(tag);
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Progress dots ──────────────────
|
||||
(function() {
|
||||
var dotsEl = document.getElementById('progressDots');
|
||||
@@ -224,10 +280,17 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Form rendering ─────────────────
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL && FORM_TPL.fields) {
|
||||
// ── Form rendering (v0.35.0: progressive forms + conditional fields) ──
|
||||
var _fieldsetIndex = 0;
|
||||
var _fieldsetCount = 0;
|
||||
|
||||
if ((STAGE_MODE === 'form_only' || STAGE_MODE === 'form_chat') && FORM_TPL) {
|
||||
var formArea = document.getElementById('formArea');
|
||||
renderForm(formArea, FORM_TPL);
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
renderProgressiveForm(formArea, FORM_TPL);
|
||||
} else if (FORM_TPL.fields && FORM_TPL.fields.length > 0) {
|
||||
renderForm(formArea, FORM_TPL);
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(container, tpl) {
|
||||
@@ -238,6 +301,84 @@
|
||||
}
|
||||
html += '<div class="wf-form-submit"><button id="formSubmitBtn" onclick="submitForm()">Submit</button></div>';
|
||||
container.innerHTML = html;
|
||||
applyConditionalFields(tpl.fields);
|
||||
}
|
||||
|
||||
function renderProgressiveForm(container, tpl) {
|
||||
_fieldsetCount = tpl.fieldsets.length;
|
||||
_fieldsetIndex = 0;
|
||||
var html = '';
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
var fs = tpl.fieldsets[s];
|
||||
var display = s === 0 ? '' : ' style="display:none"';
|
||||
html += '<div class="wf-fieldset" id="fieldset_' + s + '"' + display + '>';
|
||||
html += '<div class="wf-fieldset-nav"><span class="step-label">' + escHtml(fs.label) + '</span>';
|
||||
html += '<span class="step-counter">Step ' + (s + 1) + ' of ' + _fieldsetCount + '</span></div>';
|
||||
for (var i = 0; i < fs.fields.length; i++) {
|
||||
html += renderField(fs.fields[i]);
|
||||
}
|
||||
html += '<div class="wf-fieldset-buttons">';
|
||||
if (s > 0) html += '<button onclick="showFieldset(' + (s - 1) + ')">Back</button>';
|
||||
if (s < tpl.fieldsets.length - 1) {
|
||||
html += '<button class="primary" onclick="showFieldset(' + (s + 1) + ')">Next</button>';
|
||||
} else {
|
||||
html += '<button class="primary" id="formSubmitBtn" onclick="submitForm()">Submit</button>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
// Apply conditional fields for all fieldsets
|
||||
for (var s = 0; s < tpl.fieldsets.length; s++) {
|
||||
applyConditionalFields(tpl.fieldsets[s].fields);
|
||||
}
|
||||
}
|
||||
|
||||
window.showFieldset = function(idx) {
|
||||
if (idx < 0 || idx >= _fieldsetCount) return;
|
||||
document.getElementById('fieldset_' + _fieldsetIndex).style.display = 'none';
|
||||
document.getElementById('fieldset_' + idx).style.display = '';
|
||||
_fieldsetIndex = idx;
|
||||
};
|
||||
|
||||
function applyConditionalFields(fields) {
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
var f = fields[i];
|
||||
if (!f.condition) continue;
|
||||
var when = f.condition.when;
|
||||
var srcEl = document.getElementById('ff_' + when);
|
||||
if (!srcEl) continue;
|
||||
(function(field) {
|
||||
var handler = function() { evaluateCondition(field); };
|
||||
srcEl.addEventListener('change', handler);
|
||||
srcEl.addEventListener('input', handler);
|
||||
handler(); // initial evaluation
|
||||
})(f);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateCondition(f) {
|
||||
var cond = f.condition;
|
||||
var srcEl = document.getElementById('ff_' + cond.when);
|
||||
var tgtEl = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (!srcEl || !tgtEl) return;
|
||||
|
||||
var val = srcEl.type === 'checkbox' ? srcEl.checked : srcEl.value;
|
||||
var op = cond.op || 'eq';
|
||||
var visible = false;
|
||||
|
||||
switch (op) {
|
||||
case 'exists': visible = val !== '' && val !== false; break;
|
||||
case 'eq': visible = String(val) === String(cond.value); break;
|
||||
case 'neq': visible = String(val) !== String(cond.value); break;
|
||||
case 'in': visible = Array.isArray(cond.value) && cond.value.map(String).indexOf(String(val)) >= 0; break;
|
||||
default: visible = true;
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
tgtEl.classList.remove('cond-hidden');
|
||||
} else {
|
||||
tgtEl.classList.add('cond-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(f) {
|
||||
@@ -308,12 +449,22 @@
|
||||
el.textContent = '';
|
||||
});
|
||||
|
||||
// Collect form data
|
||||
// Collect form data (v0.35.0: get all fields from fieldsets or top-level)
|
||||
var allFields = FORM_TPL.fields || [];
|
||||
if (FORM_TPL.fieldsets && FORM_TPL.fieldsets.length > 0) {
|
||||
allFields = [];
|
||||
for (var s = 0; s < FORM_TPL.fieldsets.length; s++) {
|
||||
allFields = allFields.concat(FORM_TPL.fieldsets[s].fields);
|
||||
}
|
||||
}
|
||||
var data = {};
|
||||
for (var i = 0; i < FORM_TPL.fields.length; i++) {
|
||||
var f = FORM_TPL.fields[i];
|
||||
for (var i = 0; i < allFields.length; i++) {
|
||||
var f = allFields[i];
|
||||
var el = document.getElementById('ff_' + f.key);
|
||||
if (!el) continue;
|
||||
// v0.35.0: Skip hidden conditional fields
|
||||
var fieldWrap = document.querySelector('.wf-form-field[data-key="' + f.key + '"]');
|
||||
if (fieldWrap && fieldWrap.classList.contains('cond-hidden')) continue;
|
||||
if (f.type === 'checkbox') {
|
||||
data[f.key] = el.checked;
|
||||
} else if (f.type === 'number') {
|
||||
@@ -480,19 +631,21 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Review surface (v0.30.2) ──────────
|
||||
// ── Review surface (v0.35.0: structured review with side-by-side + comments) ──
|
||||
if (STAGE_MODE === 'review') {
|
||||
var reviewArea = document.getElementById('reviewArea');
|
||||
if (reviewArea) {
|
||||
loadReviewSurface(reviewArea);
|
||||
var dataPanel = document.getElementById('reviewDataPanel');
|
||||
var actionPanel = document.getElementById('reviewActionPanel');
|
||||
if (dataPanel && actionPanel) {
|
||||
loadReviewSurface(dataPanel, actionPanel);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReviewSurface(container) {
|
||||
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
|
||||
async function loadReviewSurface(dataPanel, actionPanel) {
|
||||
dataPanel.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading\u2026</div>';
|
||||
|
||||
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
|
||||
html += '<h3 style="margin-bottom:16px">Review</h3>';
|
||||
// Left panel: structured data card
|
||||
var html = '<div style="padding:24px">';
|
||||
html += '<h3 style="margin-bottom:16px">Collected Data</h3>';
|
||||
|
||||
try {
|
||||
var resp = await fetch(BASE + '/api/v1/channels/' + CHAN_ID + '/workflow/status', {
|
||||
@@ -500,33 +653,54 @@
|
||||
});
|
||||
if (resp.ok) {
|
||||
var status = await resp.json();
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
|
||||
|
||||
if (status.stage_data && typeof status.stage_data === 'object' && Object.keys(status.stage_data).length > 0) {
|
||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
||||
html += '<table style="width:100%;border-collapse:collapse">';
|
||||
for (var key in status.stage_data) {
|
||||
if (!status.stage_data.hasOwnProperty(key)) continue;
|
||||
if (!status.stage_data.hasOwnProperty(key) || key.startsWith('_')) continue;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + escHtml(key) + '</td>';
|
||||
html += '<td style="padding:8px;font-size:13px">' + escHtml(String(status.stage_data[key])) + '</td>';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%;vertical-align:top">' + escHtml(key) + '</td>';
|
||||
var val = status.stage_data[key];
|
||||
var display = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
|
||||
html += '<td style="padding:8px;font-size:13px;white-space:pre-wrap">' + escHtml(display) + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
dataPanel.innerHTML = html;
|
||||
|
||||
html += '<div style="display:flex;gap:8px;margin-top:24px">';
|
||||
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
||||
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
// Right panel: comment input + approve/reject buttons
|
||||
var actHtml = '<div style="padding:24px;flex:1;display:flex;flex-direction:column">';
|
||||
actHtml += '<h3 style="margin-bottom:16px">Review Actions</h3>';
|
||||
actHtml += '<div style="margin-bottom:16px">';
|
||||
actHtml += '<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px">Add Comment</label>';
|
||||
actHtml += '<textarea id="reviewComment" rows="3" style="width:100%;padding:8px;font-size:14px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);color:var(--text);font-family:var(--font);resize:vertical" placeholder="Optional comment\u2026"></textarea>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="display:flex;gap:8px">';
|
||||
actHtml += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Approve & Advance</button>';
|
||||
actHtml += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer;font-size:14px">Reject</button>';
|
||||
actHtml += '</div>';
|
||||
actHtml += '<div style="font-size:12px;color:var(--text-3);margin-top:8px">Ctrl+Enter: Approve · Ctrl+Shift+Enter: Reject</div>';
|
||||
actHtml += '</div>';
|
||||
actionPanel.innerHTML = actHtml;
|
||||
|
||||
// Keyboard shortcuts (v0.35.0)
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
document.getElementById('reviewRejectBtn').click();
|
||||
} else {
|
||||
document.getElementById('reviewAdvanceBtn').click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('reviewAdvanceBtn').addEventListener('click', async function() {
|
||||
this.disabled = true;
|
||||
@@ -536,7 +710,7 @@
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
if (r.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
@@ -547,7 +721,8 @@
|
||||
});
|
||||
|
||||
document.getElementById('reviewRejectBtn').addEventListener('click', async function() {
|
||||
var reason = prompt('Rejection reason:');
|
||||
var comment = document.getElementById('reviewComment').value;
|
||||
var reason = comment || prompt('Rejection reason:');
|
||||
if (!reason) return;
|
||||
this.disabled = true;
|
||||
try {
|
||||
@@ -556,7 +731,7 @@
|
||||
body: JSON.stringify({ reason: reason }),
|
||||
});
|
||||
if (r.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
actionPanel.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Sent back for revision.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await r.json().catch(function() { return {}; });
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/workflow"
|
||||
)
|
||||
|
||||
// BuildWorkflowModule creates the "workflow" Starlark module for a package.
|
||||
@@ -30,6 +31,7 @@ func BuildWorkflowModule(ctx context.Context, stores store.Stores) *starlarkstru
|
||||
"get_stage_data": starlark.NewBuiltin("workflow.get_stage_data", workflowGetStageData(ctx, stores)),
|
||||
"advance": starlark.NewBuiltin("workflow.advance", workflowAdvance(ctx, stores)),
|
||||
"reject": starlark.NewBuiltin("workflow.reject", workflowReject(ctx, stores)),
|
||||
"route": starlark.NewBuiltin("workflow.route", workflowRoute(ctx, stores)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +131,10 @@ func workflowAdvance(ctx context.Context, stores store.Stores) func(*starlark.Th
|
||||
return starlark.None, fmt.Errorf("workflow.advance: %w", err)
|
||||
}
|
||||
|
||||
nextStage := ws.CurrentStage + 1
|
||||
nextStage, err := workflow.ResolveNextStage(stages, ws.CurrentStage, ws.StageData)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.advance: routing error: %w", err)
|
||||
}
|
||||
if nextStage >= len(stages) {
|
||||
// Complete the workflow
|
||||
if err := stores.Channels.CompleteWorkflow(ctx, channelID, ws.CurrentStage, ws.StageData); err != nil {
|
||||
@@ -168,3 +173,44 @@ func workflowReject(ctx context.Context, stores store.Stores) func(*starlark.Thr
|
||||
return starlark.String("rejected"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// workflowRoute routes the workflow to a named stage (v0.35.0).
|
||||
// Starlark: workflow.route(channel_id, target_stage, reason)
|
||||
func workflowRoute(ctx context.Context, stores store.Stores) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var channelID, targetStage, reason string
|
||||
if err := starlark.UnpackPositionalArgs("workflow.route", args, kwargs, 3, &channelID, &targetStage, &reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, err := stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: channel not found or not a workflow")
|
||||
}
|
||||
if ws.Status != "active" {
|
||||
return starlark.None, fmt.Errorf("workflow.route: workflow is %s", ws.Status)
|
||||
}
|
||||
|
||||
stages, err := stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
|
||||
targetOrdinal, err := workflow.ResolveStageByName(stages, targetStage)
|
||||
if err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
|
||||
if targetOrdinal >= len(stages) {
|
||||
if err := stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
return starlark.String("completed"), nil
|
||||
}
|
||||
|
||||
if err := stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, ws.StageData); err != nil {
|
||||
return starlark.None, fmt.Errorf("workflow.route: %w", err)
|
||||
}
|
||||
return starlark.String("routed"), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,11 @@ type ChannelStore interface {
|
||||
|
||||
// MergeSettings merges a JSON object into the channel's settings column.
|
||||
MergeSettings(ctx context.Context, channelID string, settingsJSON json.RawMessage) error
|
||||
|
||||
// ── Monitoring (v0.35.0) ──
|
||||
|
||||
// ListByType returns all channels of a given type (e.g. "workflow").
|
||||
ListByType(ctx context.Context, channelType string) ([]models.Channel, error)
|
||||
}
|
||||
|
||||
// ChannelListFilter holds filter options for ChannelStore.ListFiltered.
|
||||
|
||||
@@ -562,12 +562,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
||||
|
||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = $1, workflow_version = $2, current_stage = 0,
|
||||
stage_data = $3, workflow_status = $4, last_activity_at = $5
|
||||
WHERE id = $6
|
||||
`, workflowID, version, stageData, status, time.Now().UTC(), channelID)
|
||||
stage_data = $3, workflow_status = $4, last_activity_at = $5,
|
||||
stage_entered_at = $6
|
||||
WHERE id = $7
|
||||
`, workflowID, version, stageData, status, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -577,10 +579,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
last_activity_at, stage_entered_at
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -592,11 +594,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3
|
||||
WHERE id = $4
|
||||
`, nextStage, stageData, time.Now().UTC(), channelID)
|
||||
SET current_stage = $1, stage_data = $2, last_activity_at = $3,
|
||||
stage_entered_at = $4
|
||||
WHERE id = $5
|
||||
`, nextStage, stageData, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -611,9 +615,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2 WHERE id = $3
|
||||
`, stage, time.Now().UTC(), channelID)
|
||||
UPDATE channels SET current_stage = $1, last_activity_at = $2,
|
||||
stage_entered_at = $3 WHERE id = $4
|
||||
`, stage, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -908,6 +914,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Monitoring (v0.35.0) ────────────────────
|
||||
|
||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
||||
team_id, created_at
|
||||
FROM channels WHERE type = $1
|
||||
ORDER BY created_at DESC`, channelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
||||
&ch.Type, &ch.TeamID, &ch.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS7b helpers ────────────────────────────
|
||||
|
||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
||||
|
||||
@@ -224,11 +224,13 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_stages (workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules, surface_pkg_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, sla_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING id, created_at`,
|
||||
st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
|
||||
st.SLASeconds,
|
||||
).Scan(&st.ID, &st.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -236,7 +238,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at
|
||||
surface_pkg_id, sla_seconds, created_at
|
||||
FROM workflow_stages WHERE workflow_id = $1
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
@@ -250,7 +252,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
if err := rows.Scan(&st.ID, &st.WorkflowID, &st.Ordinal, &st.Name,
|
||||
&st.PersonaID, &st.AssignmentTeamID, &formTpl, &st.StageMode,
|
||||
&st.HistoryMode, &st.AutoTransition, &transRules,
|
||||
&st.SurfacePkgID, &st.CreatedAt); err != nil {
|
||||
&st.SurfacePkgID, &st.SLASeconds, &st.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.FormTemplate = formTpl
|
||||
@@ -271,10 +273,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = $2, name = $3, persona_id = $4, assignment_team_id = $5,
|
||||
form_template = $6, stage_mode = $7, history_mode = $8, auto_transition = $9,
|
||||
transition_rules = $10, surface_pkg_id = $11
|
||||
transition_rules = $10, surface_pkg_id = $11, sla_seconds = $12
|
||||
WHERE id = $1`,
|
||||
st.ID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID)
|
||||
formTpl, stageMode, st.HistoryMode, st.AutoTransition, transRules, st.SurfacePkgID,
|
||||
st.SLASeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -497,3 +500,34 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc []byte
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
review_comments, created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, &a.CreatedAt, &a.ClaimedAt, &a.CompletedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = rc
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE workflow_assignments
|
||||
SET review_comments = review_comments || $1::jsonb
|
||||
WHERE id = $2
|
||||
`, "["+string(commentJSON)+"]", assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -563,12 +563,14 @@ func (s *ChannelStore) CountAll(ctx context.Context) (int, error) {
|
||||
// ── Workflow instance state (v0.29.0-cs3) ───────────────────────────────
|
||||
|
||||
func (s *ChannelStore) SetWorkflowInstance(ctx context.Context, channelID, workflowID string, version int, stageData json.RawMessage, status string) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET workflow_id = ?, workflow_version = ?, current_stage = 0,
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?
|
||||
stage_data = ?, workflow_status = ?, last_activity_at = ?,
|
||||
stage_entered_at = ?
|
||||
WHERE id = ?
|
||||
`, workflowID, version, stageData, status, time.Now().UTC().Format(timeFmt), channelID)
|
||||
`, workflowID, version, stageData, status, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -578,10 +580,10 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT workflow_id, workflow_version, current_stage,
|
||||
COALESCE(stage_data, '{}'), COALESCE(workflow_status, 'active'),
|
||||
last_activity_at
|
||||
last_activity_at, stage_entered_at
|
||||
FROM channels WHERE id = ? AND type = 'workflow'
|
||||
`, channelID).Scan(&ws.WorkflowID, &ws.WorkflowVersion,
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt)
|
||||
&ws.CurrentStage, &stageData, &ws.Status, &ws.LastActivityAt, &ws.StageEnteredAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -593,11 +595,13 @@ func (s *ChannelStore) GetWorkflowStatus(ctx context.Context, channelID string)
|
||||
}
|
||||
|
||||
func (s *ChannelStore) AdvanceWorkflowStage(ctx context.Context, channelID string, nextStage int, stageData json.RawMessage) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?
|
||||
SET current_stage = ?, stage_data = ?, last_activity_at = ?,
|
||||
stage_entered_at = ?
|
||||
WHERE id = ?
|
||||
`, nextStage, stageData, time.Now().UTC().Format(timeFmt), channelID)
|
||||
`, nextStage, stageData, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -612,9 +616,11 @@ func (s *ChannelStore) CompleteWorkflow(ctx context.Context, channelID string, f
|
||||
}
|
||||
|
||||
func (s *ChannelStore) RejectWorkflowToStage(ctx context.Context, channelID string, stage int) error {
|
||||
now := time.Now().UTC().Format(timeFmt)
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ? WHERE id = ?
|
||||
`, stage, time.Now().UTC().Format(timeFmt), channelID)
|
||||
UPDATE channels SET current_stage = ?, last_activity_at = ?,
|
||||
stage_entered_at = ? WHERE id = ?
|
||||
`, stage, now, now, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -866,6 +872,30 @@ func (s *ChannelStore) MergeSettings(ctx context.Context, channelID string, sett
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Monitoring (v0.35.0) ────────────────────
|
||||
|
||||
func (s *ChannelStore) ListByType(ctx context.Context, channelType string) ([]models.Channel, error) {
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, user_id, title, COALESCE(description, ''), type,
|
||||
team_id, created_at
|
||||
FROM channels WHERE type = ?
|
||||
ORDER BY created_at DESC`, channelType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []models.Channel
|
||||
for rows.Next() {
|
||||
var ch models.Channel
|
||||
if err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &ch.Description,
|
||||
&ch.Type, &ch.TeamID, st(&ch.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── CS7b helpers ────────────────────────────
|
||||
|
||||
func scanChannelListItems(rows *sql.Rows) ([]store.ChannelListItem, error) {
|
||||
|
||||
@@ -160,11 +160,11 @@ func (s *WorkflowStore) CreateStage(ctx context.Context, st *models.WorkflowStag
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_stages (id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
surface_pkg_id, sla_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.WorkflowID, st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.SurfacePkgID, st.CreatedAt.Format(time.RFC3339))
|
||||
st.SurfacePkgID, st.SLASeconds, st.CreatedAt.Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
rows, err := DB.QueryContext(ctx, `
|
||||
SELECT id, workflow_id, ordinal, name, persona_id, assignment_team_id,
|
||||
form_template, stage_mode, history_mode, auto_transition, transition_rules,
|
||||
surface_pkg_id, created_at
|
||||
surface_pkg_id, sla_seconds, created_at
|
||||
FROM workflow_stages WHERE workflow_id = ?
|
||||
ORDER BY ordinal ASC`, workflowID)
|
||||
if err != nil {
|
||||
@@ -187,7 +187,7 @@ func (s *WorkflowStore) ListStages(ctx context.Context, workflowID string) ([]mo
|
||||
if err := rows.Scan(&stg.ID, &stg.WorkflowID, &stg.Ordinal, &stg.Name,
|
||||
&stg.PersonaID, &stg.AssignmentTeamID, &formTpl, &stg.StageMode,
|
||||
&stg.HistoryMode, &autoTrans, &transRules,
|
||||
&stg.SurfacePkgID, st(&stg.CreatedAt)); err != nil {
|
||||
&stg.SurfacePkgID, &stg.SLASeconds, st(&stg.CreatedAt)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stg.FormTemplate = json.RawMessage(formTpl)
|
||||
@@ -209,11 +209,11 @@ func (s *WorkflowStore) UpdateStage(ctx context.Context, st *models.WorkflowStag
|
||||
UPDATE workflow_stages
|
||||
SET ordinal = ?, name = ?, persona_id = ?, assignment_team_id = ?,
|
||||
form_template = ?, stage_mode = ?, history_mode = ?, auto_transition = ?,
|
||||
transition_rules = ?, surface_pkg_id = ?
|
||||
transition_rules = ?, surface_pkg_id = ?, sla_seconds = ?
|
||||
WHERE id = ?`,
|
||||
st.Ordinal, st.Name, st.PersonaID, st.AssignmentTeamID,
|
||||
formTpl, stageMode, st.HistoryMode, boolToInt(st.AutoTransition), transRules,
|
||||
st.SurfacePkgID, st.ID)
|
||||
st.SurfacePkgID, st.SLASeconds, st.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -504,3 +504,44 @@ func scanAssignments(rows *sql.Rows) ([]store.WorkflowAssignment, error) {
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ── Review Comments (v0.35.0) ───────────────────────────
|
||||
|
||||
func (s *WorkflowStore) GetAssignmentByID(ctx context.Context, id string) (*store.WorkflowAssignment, error) {
|
||||
var a store.WorkflowAssignment
|
||||
var rc string
|
||||
var claimedAt, completedAt *time.Time
|
||||
err := DB.QueryRowContext(ctx, `
|
||||
SELECT id, channel_id, stage, team_id, assigned_to, status,
|
||||
COALESCE(review_comments, '[]'), created_at, claimed_at, completed_at
|
||||
FROM workflow_assignments WHERE id = ?
|
||||
`, id).Scan(&a.ID, &a.ChannelID, &a.Stage, &a.TeamID, &a.AssignedTo, &a.Status,
|
||||
&rc, st(&a.CreatedAt), stN(&claimedAt), stN(&completedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.ReviewComments = json.RawMessage(rc)
|
||||
a.ClaimedAt = claimedAt
|
||||
a.CompletedAt = completedAt
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (s *WorkflowStore) AddReviewComment(ctx context.Context, assignmentID string, comment store.ReviewComment) error {
|
||||
commentJSON, err := json.Marshal(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// SQLite: read, append, write back
|
||||
var existing string
|
||||
err = DB.QueryRowContext(ctx, `SELECT COALESCE(review_comments, '[]') FROM workflow_assignments WHERE id = ?`, assignmentID).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var arr []json.RawMessage
|
||||
_ = json.Unmarshal([]byte(existing), &arr)
|
||||
arr = append(arr, json.RawMessage(commentJSON))
|
||||
updated, _ := json.Marshal(arr)
|
||||
_, err = DB.ExecContext(ctx, `UPDATE workflow_assignments SET review_comments = ? WHERE id = ?`, string(updated), assignmentID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -54,4 +54,19 @@ type WorkflowStore interface {
|
||||
// TryRoundRobin finds the least-recently-assigned team member and claims.
|
||||
// Returns the assigned user ID, or "" if no members available.
|
||||
TryRoundRobin(ctx context.Context, teamID, assignmentID string) (string, error)
|
||||
|
||||
// ── Review Comments (v0.35.0) ──
|
||||
|
||||
// GetAssignmentByID returns a single assignment by ID.
|
||||
GetAssignmentByID(ctx context.Context, id string) (*WorkflowAssignment, error)
|
||||
|
||||
// AddReviewComment appends a comment to an assignment's review_comments array.
|
||||
AddReviewComment(ctx context.Context, assignmentID string, comment ReviewComment) error
|
||||
}
|
||||
|
||||
// ReviewComment is a single review comment on a workflow assignment (v0.35.0).
|
||||
type ReviewComment struct {
|
||||
Text string `json:"text"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -13,17 +13,19 @@ type WorkflowChannelStatus struct {
|
||||
StageData json.RawMessage `json:"stage_data"`
|
||||
Status string `json:"status"`
|
||||
LastActivityAt *string `json:"last_activity_at"`
|
||||
StageEnteredAt *string `json:"stage_entered_at,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowAssignment is a row from the workflow_assignments table.
|
||||
type WorkflowAssignment 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"`
|
||||
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"`
|
||||
ReviewComments json.RawMessage `json:"review_comments"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClaimedAt *time.Time `json:"claimed_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
"chat-switchboard/webhook"
|
||||
"chat-switchboard/workflow"
|
||||
)
|
||||
|
||||
// ── Late Registration ────────────────────────
|
||||
@@ -22,7 +23,8 @@ func RegisterWorkflowTools(stores store.Stores) {
|
||||
return
|
||||
}
|
||||
Register(&workflowAdvanceTool{stores: stores})
|
||||
log.Println("✅ workflow tools registered (workflow_advance)")
|
||||
Register(&workflowRouteTool{stores: stores})
|
||||
log.Println("✅ workflow tools registered (workflow_advance, workflow_route)")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -98,7 +100,10 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
|
||||
// Merge collected data into stage_data
|
||||
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data)
|
||||
nextStage := currentStage + 1
|
||||
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
|
||||
@@ -151,6 +156,117 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
|
||||
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.
|
||||
|
||||
203
server/workflow/routing.go
Normal file
203
server/workflow/routing.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Conditional Routing Engine (v0.35.0) ────
|
||||
//
|
||||
// Evaluates transition_rules.conditions[] against accumulated stage_data
|
||||
// to determine which stage to advance to. Falls back to ordinal + 1
|
||||
// when no conditions are defined or none match.
|
||||
|
||||
// Condition is a single routing condition within transition_rules.
|
||||
type Condition struct {
|
||||
Field string `json:"field"`
|
||||
Op string `json:"op"`
|
||||
Value any `json:"value"`
|
||||
TargetStage string `json:"target_stage"` // stage name or ordinal as string
|
||||
}
|
||||
|
||||
// TransitionRulesWithConditions extends the existing transition_rules JSON.
|
||||
type TransitionRulesWithConditions struct {
|
||||
AutoAssign string `json:"auto_assign,omitempty"`
|
||||
Conditions []Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveNextStage evaluates conditions from the current stage's transition_rules
|
||||
// against accumulated stageData. Returns the target stage ordinal.
|
||||
// Falls back to currentStage + 1 if no conditions match or none are defined.
|
||||
func ResolveNextStage(stages []models.WorkflowStage, currentStage int, stageData json.RawMessage) (int, error) {
|
||||
if currentStage < 0 || currentStage >= len(stages) {
|
||||
return currentStage + 1, nil
|
||||
}
|
||||
|
||||
stage := stages[currentStage]
|
||||
|
||||
var rules TransitionRulesWithConditions
|
||||
if len(stage.TransitionRules) > 0 {
|
||||
_ = json.Unmarshal(stage.TransitionRules, &rules)
|
||||
}
|
||||
|
||||
if len(rules.Conditions) == 0 {
|
||||
return currentStage + 1, nil
|
||||
}
|
||||
|
||||
var data map[string]any
|
||||
if len(stageData) > 0 {
|
||||
_ = json.Unmarshal(stageData, &data)
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
|
||||
for _, cond := range rules.Conditions {
|
||||
if evaluateCondition(cond, data) {
|
||||
target, err := resolveTarget(stages, cond.TargetStage)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("condition matched but target invalid: %w", err)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
|
||||
return currentStage + 1, nil
|
||||
}
|
||||
|
||||
// ResolveStageByName finds a stage ordinal by name (case-insensitive).
|
||||
func ResolveStageByName(stages []models.WorkflowStage, name string) (int, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, s := range stages {
|
||||
if strings.ToLower(s.Name) == lower {
|
||||
return s.Ordinal, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("stage %q not found", name)
|
||||
}
|
||||
|
||||
// resolveTarget resolves a target_stage string to an ordinal.
|
||||
// Accepts either a stage name or a numeric ordinal string.
|
||||
func resolveTarget(stages []models.WorkflowStage, target string) (int, error) {
|
||||
// Try as stage name first
|
||||
ordinal, err := ResolveStageByName(stages, target)
|
||||
if err == nil {
|
||||
return ordinal, nil
|
||||
}
|
||||
|
||||
// Try as numeric ordinal
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(target, "%d", &n); err == nil {
|
||||
if n >= 0 && n < len(stages) {
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("ordinal %d out of range (0..%d)", n, len(stages)-1)
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("cannot resolve target stage %q", target)
|
||||
}
|
||||
|
||||
// evaluateCondition checks if a single condition matches against data.
|
||||
func evaluateCondition(cond Condition, data map[string]any) bool {
|
||||
val, exists := data[cond.Field]
|
||||
|
||||
switch cond.Op {
|
||||
case "exists":
|
||||
return exists
|
||||
case "not_exists":
|
||||
return !exists
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
switch cond.Op {
|
||||
case "eq":
|
||||
return compareEq(val, cond.Value)
|
||||
case "neq":
|
||||
return !compareEq(val, cond.Value)
|
||||
case "gt":
|
||||
return compareNum(val, cond.Value) > 0
|
||||
case "lt":
|
||||
return compareNum(val, cond.Value) < 0
|
||||
case "gte":
|
||||
return compareNum(val, cond.Value) >= 0
|
||||
case "lte":
|
||||
return compareNum(val, cond.Value) <= 0
|
||||
case "in":
|
||||
return compareIn(val, cond.Value)
|
||||
case "contains":
|
||||
return compareContains(val, cond.Value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// compareEq does loose equality: normalizes both sides to string for comparison.
|
||||
func compareEq(a, b any) bool {
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
}
|
||||
|
||||
// compareNum extracts float64 from both sides and returns -1, 0, or 1.
|
||||
// Returns 0 if either side is not numeric.
|
||||
func compareNum(a, b any) int {
|
||||
af := toFloat(a)
|
||||
bf := toFloat(b)
|
||||
if af == nil || bf == nil {
|
||||
return 0
|
||||
}
|
||||
switch {
|
||||
case *af < *bf:
|
||||
return -1
|
||||
case *af > *bf:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) *float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return &n
|
||||
case int:
|
||||
f := float64(n)
|
||||
return &f
|
||||
case json.Number:
|
||||
if f, err := n.Float64(); err == nil {
|
||||
return &f
|
||||
}
|
||||
case string:
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(n, "%f", &f); err == nil {
|
||||
return &f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compareIn checks if val is in the list (cond.Value must be []any).
|
||||
func compareIn(val, list any) bool {
|
||||
arr, ok := list.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
vs := fmt.Sprintf("%v", val)
|
||||
for _, item := range arr {
|
||||
if fmt.Sprintf("%v", item) == vs {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compareContains checks if val (as string) contains the target substring.
|
||||
func compareContains(val, target any) bool {
|
||||
s := fmt.Sprintf("%v", val)
|
||||
t := fmt.Sprintf("%v", target)
|
||||
return strings.Contains(s, t)
|
||||
}
|
||||
Reference in New Issue
Block a user