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:
2026-03-20 09:59:53 +00:00
committed by xcaliber
parent d16bb93177
commit bf8082e69f
37 changed files with 2324 additions and 129 deletions

View File

@@ -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';

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
})
}

View File

@@ -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))

View 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()
}
}

View File

@@ -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)

View File

@@ -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)

View 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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
},
})
}

View File

@@ -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 &amp; 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 &amp; 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 &middot; 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 {}; });

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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
View 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)
}