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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user