Feat v0.3.3 public entry background jobs (#17)
Some checks failed
CI/CD / detect-changes (push) Successful in 21s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-go-pg (push) Failing after 2m24s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #17.
This commit is contained in:
2026-03-27 23:07:55 +00:00
committed by xcaliber
parent ab28e4b784
commit dba718b914
14 changed files with 743 additions and 40 deletions

View File

@@ -2,6 +2,27 @@
All notable changes to Switchboard Core are documented here. All notable changes to Switchboard Core are documented here.
## v0.3.3 — Public Entry + Background Jobs
### Added
- **Public workflow entry**: Unauthenticated routes for anonymous workflow
participation. `POST /api/v1/public/workflows/:id/start` creates an instance
with `started_by = "public:<uuid>"` and returns an entry token.
`GET .../resume/:token` and `POST .../advance/:token` allow continuation.
Only stages with `audience = "public"` can be advanced anonymously.
- **SLA scanner**: Background goroutine (5-minute interval) checks active
instances against per-stage `sla_seconds`. Fires `workflow.sla_breach` event
on first breach, marks `sla_breached: true` in instance metadata (idempotent).
- **Staleness sweep**: New `staleness_timeout_hours` column on `workflows`.
Scanner marks instances as `stale` when `updated_at` exceeds the threshold,
cancels open assignments, fires `workflow.stale` event.
- **New store methods**: `ListActiveInstances()`, `MarkInstanceStale()`.
- **New events**: `workflow.sla_breach`, `workflow.stale`.
- **3 new store tests**: MarkStale, ListActive, StalenessTimeoutHours round-trip.
---
## v0.3.0 — Workflow Schema Redesign ## v0.3.0 — Workflow Schema Redesign
### Changed ### Changed

View File

@@ -152,13 +152,13 @@ finalizes the extension lifecycle model.
| Assignment handlers | ✅ | HTTP API: Claim, Unclaim, Complete, Cancel, ListByTeam, ListMine. Claimer identity verification. | | Assignment handlers | ✅ | HTTP API: Claim, Unclaim, Complete, Cancel, ListByTeam, ListMine. Claimer identity verification. |
| Starlark module expansion | ✅ | `workflow.get_instance()`, `workflow.list_instances()` (read-only). Mutating builtins deferred pending interface extraction. | | Starlark module expansion | ✅ | `workflow.get_instance()`, `workflow.list_instances()` (read-only). Mutating builtins deferred pending interface extraction. |
### v0.3.3 — Public Entry + Background Jobs ### v0.3.3 — Public Entry + Background Jobs (complete)
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Public entry | | StartPublic, ResumePublic, AdvancePublic — token-based anonymous workflow participation. | | Public entry | | `StartPublic`, `ResumePublic`, `AdvancePublic` — token-based anonymous workflow participation. Public routes at `/api/v1/public/workflows/`. Audience-gated: only `public` stages can be advanced anonymously. |
| SLA scanner | | Periodic goroutine checking active instances. Fire events + notifications on breach. | | SLA scanner | | Background goroutine (5-min interval) checking active instances against per-stage `sla_seconds`. Fires `workflow.sla_breach` event, marks breach in instance metadata (idempotent). |
| Staleness sweep | | Mark idle instances as stale after configurable timeout. | | Staleness sweep | | Per-workflow `staleness_timeout_hours` column. Scanner marks idle instances as `stale`, cancels open assignments, fires `workflow.stale` event. |
### v0.3.4 — Team Roles + Multi-party Validation ### v0.3.4 — Team Roles + Multi-party Validation

View File

@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS workflows (
retention JSONB NOT NULL DEFAULT '{"mode": "archive"}', retention JSONB NOT NULL DEFAULT '{"mode": "archive"}',
webhook_url TEXT, webhook_url TEXT,
webhook_secret TEXT, webhook_secret TEXT,
staleness_timeout_hours INTEGER,
created_by UUID NOT NULL REFERENCES users(id), created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

View File

@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS workflows (
retention TEXT NOT NULL DEFAULT '{"mode": "archive"}', retention TEXT NOT NULL DEFAULT '{"mode": "archive"}',
webhook_url TEXT, webhook_url TEXT,
webhook_secret TEXT, webhook_secret TEXT,
staleness_timeout_hours INTEGER,
created_by TEXT NOT NULL REFERENCES users(id), created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))

View File

@@ -57,14 +57,16 @@ var routeTable = map[string]Direction{
// Workspace (v0.21.5) // Workspace (v0.21.5)
// Workflow (v0.27.0, v0.3.2) // Workflow (v0.27.0, v0.3.2, v0.3.3)
"workflow.started": DirToClient, // instance started "workflow.started": DirToClient, // instance started
"workflow.assigned": DirToClient, // new assignment → team members "workflow.assigned": DirToClient, // new assignment → team members
"workflow.claimed": DirToClient, // assignment claimed → team + claimer "workflow.claimed": DirToClient, // assignment claimed → team + claimer
"workflow.advanced": DirToClient, // stage advanced → instance participants "workflow.advanced": DirToClient, // stage advanced → instance participants
"workflow.completed": DirToClient, // workflow finished → instance participants "workflow.completed": DirToClient, // workflow finished → instance participants
"workflow.cancelled": DirToClient, // instance cancelled "workflow.cancelled": DirToClient, // instance cancelled
"workflow.error": DirToClient, // engine/hook error "workflow.error": DirToClient, // engine/hook error
"workflow.sla_breach": DirToClient, // SLA exceeded → team + admins (v0.3.3)
"workflow.stale": DirToClient, // instance marked stale (v0.3.3)
// Plugin hooks — never cross the wire // Plugin hooks — never cross the wire
"plugin.hook.": DirLocal, "plugin.hook.": DirLocal,

View File

@@ -0,0 +1,140 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── Public Workflow Handlers (v0.3.3) ───────
// WorkflowPublicHandler manages unauthenticated workflow entry endpoints.
type WorkflowPublicHandler struct {
engine *workflow.Engine
stores store.Stores
}
// NewWorkflowPublicHandler creates a public workflow handler.
func NewWorkflowPublicHandler(engine *workflow.Engine, stores store.Stores) *WorkflowPublicHandler {
return &WorkflowPublicHandler{engine: engine, stores: stores}
}
// publicInstanceResponse strips internal metadata from an instance for public consumption.
type publicInstanceResponse struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
CurrentStage string `json:"current_stage"`
StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"`
EntryToken *string `json:"entry_token,omitempty"`
CreatedAt any `json:"created_at"`
UpdatedAt any `json:"updated_at"`
}
// StartPublic creates an anonymous workflow instance.
// POST /api/v1/public/workflows/:id/start
func (h *WorkflowPublicHandler) StartPublic(c *gin.Context) {
workflowID := c.Param("id")
var body struct {
Data json.RawMessage `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if len(body.Data) == 0 {
body.Data = json.RawMessage(`{}`)
}
inst, err := h.engine.StartPublic(c.Request.Context(), workflowID, body.Data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, publicInstanceResponse{
ID: inst.ID,
WorkflowID: inst.WorkflowID,
CurrentStage: inst.CurrentStage,
StageData: inst.StageData,
Status: inst.Status,
EntryToken: inst.EntryToken,
CreatedAt: inst.CreatedAt,
UpdatedAt: inst.UpdatedAt,
})
}
// ResumePublic returns an active instance by entry token.
// GET /api/v1/public/workflows/resume/:token
func (h *WorkflowPublicHandler) ResumePublic(c *gin.Context) {
token := c.Param("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
inst, err := h.engine.ResumePublic(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicInstanceResponse{
ID: inst.ID,
WorkflowID: inst.WorkflowID,
CurrentStage: inst.CurrentStage,
StageData: inst.StageData,
Status: inst.Status,
EntryToken: inst.EntryToken,
CreatedAt: inst.CreatedAt,
UpdatedAt: inst.UpdatedAt,
})
}
// AdvancePublic advances a public instance by entry token.
// POST /api/v1/public/workflows/advance/:token
func (h *WorkflowPublicHandler) AdvancePublic(c *gin.Context) {
token := c.Param("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
return
}
var body struct {
Data json.RawMessage `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if len(body.Data) == 0 {
body.Data = json.RawMessage(`{}`)
}
inst, err := h.engine.AdvancePublic(c.Request.Context(), token, body.Data)
if err != nil {
if strings.Contains(err.Error(), "requires authenticated access") {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, publicInstanceResponse{
ID: inst.ID,
WorkflowID: inst.WorkflowID,
CurrentStage: inst.CurrentStage,
StageData: inst.StageData,
Status: inst.Status,
EntryToken: inst.EntryToken,
CreatedAt: inst.CreatedAt,
UpdatedAt: inst.UpdatedAt,
})
}

View File

@@ -587,6 +587,137 @@ func TestWorkflowAssignment_Cancel(t *testing.T) {
} }
} }
// ── v0.3.3 Store Tests ──────────────────────
func TestWorkflowInstance_MarkStale(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Stale-Test", "stale@test.com")
teamID := database.SeedTestTeam(t, "Team Stale", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
if err := s.Workflows.MarkInstanceStale(ctx, inst.ID); err != nil {
t.Fatalf("mark stale: %v", err)
}
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
if got.Status != models.InstanceStatusStale {
t.Errorf("status = %q, want stale", got.Status)
}
// Marking again is a no-op (WHERE status='active' won't match)
if err := s.Workflows.MarkInstanceStale(ctx, inst.ID); err != nil {
t.Fatalf("mark stale again: %v", err)
}
}
func TestWorkflowInstance_ListActive(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Active-Test", "active@test.com")
teamID := database.SeedTestTeam(t, "Team Active", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
// Create 3 instances: 2 active, 1 completed
var activeIDs []string
for i := 0; i < 3; i++ {
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
if i < 2 {
activeIDs = append(activeIDs, inst.ID)
} else {
s.Workflows.CompleteInstance(ctx, inst.ID)
}
}
active, err := s.Workflows.ListActiveInstances(ctx)
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 2 {
t.Fatalf("count = %d, want 2", len(active))
}
for _, inst := range active {
if inst.Status != models.InstanceStatusActive {
t.Errorf("instance %s status = %q, want active", inst.ID, inst.Status)
}
}
}
func TestWorkflow_StalenessTimeoutHours(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Staleness-Test", "staleness@test.com")
teamID := database.SeedTestTeam(t, "Team Staleness", userID)
// Create with staleness_timeout_hours set
hours := 48
wf := &models.Workflow{
TeamID: &teamID,
Name: "Staleness WF",
Slug: "staleness-wf",
EntryMode: "team_only",
IsActive: true,
CreatedBy: userID,
StalenessTimeoutHours: &hours,
}
if err := s.Workflows.Create(ctx, wf); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.Workflows.GetByID(ctx, wf.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.StalenessTimeoutHours == nil || *got.StalenessTimeoutHours != 48 {
t.Errorf("staleness_timeout_hours = %v, want 48", got.StalenessTimeoutHours)
}
// Update via patch
newHours := 24
if err := s.Workflows.Update(ctx, wf.ID, models.WorkflowPatch{StalenessTimeoutHours: &newHours}); err != nil {
t.Fatalf("update: %v", err)
}
got2, _ := s.Workflows.GetByID(ctx, wf.ID)
if got2.StalenessTimeoutHours == nil || *got2.StalenessTimeoutHours != 24 {
t.Errorf("after update staleness_timeout_hours = %v, want 24", got2.StalenessTimeoutHours)
}
// Create without staleness — should be nil
wf2 := &models.Workflow{
TeamID: &teamID,
Name: "No Staleness WF",
Slug: "no-staleness-wf",
EntryMode: "team_only",
IsActive: true,
CreatedBy: userID,
}
s.Workflows.Create(ctx, wf2)
got3, _ := s.Workflows.GetByID(ctx, wf2.ID)
if got3.StalenessTimeoutHours != nil {
t.Errorf("nil staleness = %v, want nil", got3.StalenessTimeoutHours)
}
}
func TestWorkflowAssignment_ListByTeam(t *testing.T) { func TestWorkflowAssignment_ListByTeam(t *testing.T) {
database.RequireTestDB(t) database.RequireTestDB(t)
database.TruncateAll(t) database.TruncateAll(t)

View File

@@ -359,6 +359,24 @@ func main() {
api.POST("/hooks/:package_id/:slug", triggerEngine.HandleWebhook) api.POST("/hooks/:package_id/:slug", triggerEngine.HandleWebhook)
api.GET("/hooks/:package_id/:slug", triggerEngine.HandleWebhook) api.GET("/hooks/:package_id/:slug", triggerEngine.HandleWebhook)
// ── Workflow Engine (shared by public + protected routes) ──
wfEngine := workflow.NewEngine(stores, bus, starlarkRunner)
// ── Public Workflow Entry (v0.3.3) ─────
publicWfH := handlers.NewWorkflowPublicHandler(wfEngine, stores)
publicWf := api.Group("/public/workflows")
publicWf.Use(authLimiter.Limit())
{
publicWf.POST("/:id/start", publicWfH.StartPublic)
publicWf.GET("/resume/:token", publicWfH.ResumePublic)
publicWf.POST("/advance/:token", publicWfH.AdvancePublic)
}
// ── Workflow Scanner (v0.3.3) ──────────
wfScanner := workflow.NewScanner(stores, bus)
wfScanner.Start()
defer wfScanner.Stop()
// ── Protected routes ──────────────────── // ── Protected routes ────────────────────
protected := api.Group("") protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache)) protected.Use(middleware.Auth(cfg, stores.Users, userCache))
@@ -401,7 +419,6 @@ func main() {
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
// Workflow instances + assignments (v0.3.2) // Workflow instances + assignments (v0.3.2)
wfEngine := workflow.NewEngine(stores, bus, starlarkRunner)
wfInstH := handlers.NewWorkflowInstanceHandler(wfEngine, stores) wfInstH := handlers.NewWorkflowInstanceHandler(wfEngine, stores)
protected.POST("/workflows/:id/instances", middleware.RequirePermission(auth.PermWorkflowSubmit, stores), wfInstH.Start) protected.POST("/workflows/:id/instances", middleware.RequirePermission(auth.PermWorkflowSubmit, stores), wfInstH.Start)
protected.GET("/workflows/:id/instances", wfInstH.ListInstances) protected.GET("/workflows/:id/instances", wfInstH.ListInstances)

View File

@@ -23,9 +23,10 @@ type Workflow struct {
Version int `json:"version"` Version int `json:"version"`
OnComplete json.RawMessage `json:"on_complete,omitempty"` OnComplete json.RawMessage `json:"on_complete,omitempty"`
Retention json.RawMessage `json:"retention"` Retention json.RawMessage `json:"retention"`
WebhookURL string `json:"webhook_url,omitempty"` WebhookURL string `json:"webhook_url,omitempty"`
WebhookSecret string `json:"webhook_secret,omitempty"` WebhookSecret string `json:"webhook_secret,omitempty"`
CreatedBy string `json:"created_by"` StalenessTimeoutHours *int `json:"staleness_timeout_hours,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -42,8 +43,9 @@ type WorkflowPatch struct {
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
OnComplete *json.RawMessage `json:"on_complete,omitempty"` OnComplete *json.RawMessage `json:"on_complete,omitempty"`
Retention *json.RawMessage `json:"retention,omitempty"` Retention *json.RawMessage `json:"retention,omitempty"`
WebhookURL *string `json:"webhook_url,omitempty"` WebhookURL *string `json:"webhook_url,omitempty"`
WebhookSecret *string `json:"webhook_secret,omitempty"` WebhookSecret *string `json:"webhook_secret,omitempty"`
StalenessTimeoutHours *int `json:"staleness_timeout_hours,omitempty"`
} }
// ── Workflow Stage ────────────────────────── // ── Workflow Stage ──────────────────────────

View File

@@ -20,12 +20,13 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
branding := jsonOrEmpty(w.Branding) branding := jsonOrEmpty(w.Branding)
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`) retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
return DB.QueryRowContext(ctx, ` return DB.QueryRowContext(ctx, `
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active, on_complete, retention, webhook_url, webhook_secret, created_by) INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) on_complete, retention, webhook_url, webhook_secret, staleness_timeout_hours, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, version, created_at, updated_at`, RETURNING id, version, created_at, updated_at`,
w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
w.IsActive, jsonOrNull(w.OnComplete), retention, w.IsActive, jsonOrNull(w.OnComplete), retention,
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret), w.CreatedBy, nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret), w.StalenessTimeoutHours, w.CreatedBy,
).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt) ).Scan(&w.ID, &w.Version, &w.CreatedAt, &w.UpdatedAt)
} }
@@ -36,11 +37,11 @@ func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflo
err := DB.QueryRowContext(ctx, ` err := DB.QueryRowContext(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE id = $1`, id, FROM workflows WHERE id = $1`, id,
).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding, ).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention, &w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
&webhookURL, &webhookSecret, &webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt) &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -63,13 +64,13 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
if teamID != nil { if teamID != nil {
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id = $1 AND slug = $2` FROM workflows WHERE team_id = $1 AND slug = $2`
args = []interface{}{*teamID, slug} args = []interface{}{*teamID, slug}
} else { } else {
q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, q = `SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id IS NULL AND slug = $1` FROM workflows WHERE team_id IS NULL AND slug = $1`
args = []interface{}{slug} args = []interface{}{slug}
} }
@@ -79,7 +80,7 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
err := DB.QueryRowContext(ctx, q, args...).Scan( err := DB.QueryRowContext(ctx, q, args...).Scan(
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding, &w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention, &w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
&webhookURL, &webhookSecret, &webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt) &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -135,6 +136,9 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
if patch.WebhookSecret != nil { if patch.WebhookSecret != nil {
add("webhook_secret", *patch.WebhookSecret) add("webhook_secret", *patch.WebhookSecret)
} }
if patch.StalenessTimeoutHours != nil {
add("staleness_timeout_hours", *patch.StalenessTimeoutHours)
}
if len(sets) == 0 { if len(sets) == 0 {
return nil return nil
@@ -166,7 +170,7 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
return s.queryWorkflows(ctx, ` return s.queryWorkflows(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id = $1 FROM workflows WHERE team_id = $1
ORDER BY name ASC`, teamID) ORDER BY name ASC`, teamID)
} }
@@ -175,7 +179,7 @@ func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, erro
return s.queryWorkflows(ctx, ` return s.queryWorkflows(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id IS NULL FROM workflows WHERE team_id IS NULL
ORDER BY name ASC`) ORDER BY name ASC`)
} }
@@ -193,7 +197,7 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
var webhookURL, webhookSecret *string var webhookURL, webhookSecret *string
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
&branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete, &branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
&retention, &webhookURL, &webhookSecret, &retention, &webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil { &w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
return nil, err return nil, err
} }
@@ -533,6 +537,40 @@ func (s *WorkflowStore) CancelInstance(ctx context.Context, id string) error {
return err return err
} }
func (s *WorkflowStore) MarkInstanceStale(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, `
UPDATE workflow_instances SET status = 'stale' WHERE id = $1 AND status = 'active'`, id)
return err
}
func (s *WorkflowStore) ListActiveInstances(ctx context.Context) ([]models.WorkflowInstance, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, workflow_version, current_stage, stage_data,
status, started_by, entry_token, metadata,
stage_entered_at, created_at, updated_at
FROM workflow_instances WHERE status = 'active'
ORDER BY stage_entered_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowInstance
for rows.Next() {
var inst models.WorkflowInstance
var stageData, metadata []byte
if err := rows.Scan(&inst.ID, &inst.WorkflowID, &inst.WorkflowVersion,
&inst.CurrentStage, &stageData, &inst.Status, &inst.StartedBy,
&inst.EntryToken, &metadata,
&inst.StageEnteredAt, &inst.CreatedAt, &inst.UpdatedAt); err != nil {
return nil, err
}
inst.StageData = stageData
inst.Metadata = metadata
result = append(result, inst)
}
return result, rows.Err()
}
// ── Assignments (v0.3.1) ──────────────────── // ── Assignments (v0.3.1) ────────────────────
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error { func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error {

View File

@@ -29,11 +29,11 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
_, err := DB.ExecContext(ctx, ` _, err := DB.ExecContext(ctx, `
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode, INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
is_active, version, on_complete, retention, webhook_url, webhook_secret, is_active, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at) staleness_timeout_hours, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode, w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention, boolToInt(w.IsActive), w.Version, jsonOrNull(w.OnComplete), retention,
nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret), nullIfEmpty(w.WebhookURL), nullIfEmpty(w.WebhookSecret), w.StalenessTimeoutHours,
w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339)) w.CreatedBy, w.CreatedAt.Format(time.RFC3339), w.UpdatedAt.Format(time.RFC3339))
return err return err
} }
@@ -42,7 +42,7 @@ func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflo
return s.scanOne(ctx, ` return s.scanOne(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE id = ?`, id) FROM workflows WHERE id = ?`, id)
} }
@@ -51,13 +51,13 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
return s.scanOne(ctx, ` return s.scanOne(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug) FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
} }
return s.scanOne(ctx, ` return s.scanOne(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id IS NULL AND slug = ?`, slug) FROM workflows WHERE team_id IS NULL AND slug = ?`, slug)
} }
@@ -101,6 +101,10 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
sets = append(sets, "webhook_secret = ?") sets = append(sets, "webhook_secret = ?")
args = append(args, *patch.WebhookSecret) args = append(args, *patch.WebhookSecret)
} }
if patch.StalenessTimeoutHours != nil {
sets = append(sets, "staleness_timeout_hours = ?")
args = append(args, *patch.StalenessTimeoutHours)
}
if len(sets) == 0 { if len(sets) == 0 {
return nil return nil
@@ -133,7 +137,7 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
return s.queryWorkflows(ctx, ` return s.queryWorkflows(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id = ? FROM workflows WHERE team_id = ?
ORDER BY name ASC`, teamID) ORDER BY name ASC`, teamID)
} }
@@ -142,7 +146,7 @@ func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, erro
return s.queryWorkflows(ctx, ` return s.queryWorkflows(ctx, `
SELECT id, team_id, name, slug, description, branding, entry_mode, is_active, SELECT id, team_id, name, slug, description, branding, entry_mode, is_active,
version, on_complete, retention, webhook_url, webhook_secret, version, on_complete, retention, webhook_url, webhook_secret,
created_by, created_at, updated_at staleness_timeout_hours, created_by, created_at, updated_at
FROM workflows WHERE team_id IS NULL FROM workflows WHERE team_id IS NULL
ORDER BY name ASC`) ORDER BY name ASC`)
} }
@@ -319,7 +323,7 @@ func (s *WorkflowStore) scanOne(ctx context.Context, q string, args ...interface
err := DB.QueryRowContext(ctx, q, args...).Scan( err := DB.QueryRowContext(ctx, q, args...).Scan(
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding, &w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention, &w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
&webhookURL, &webhookSecret, &webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)) &w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -354,7 +358,7 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
var isActive int var isActive int
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
&branding, &w.EntryMode, &isActive, &w.Version, &onComplete, &branding, &w.EntryMode, &isActive, &w.Version, &onComplete,
&retention, &webhookURL, &webhookSecret, &retention, &webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil { &w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt)); err != nil {
return nil, err return nil, err
} }
@@ -553,6 +557,42 @@ func (s *WorkflowStore) CancelInstance(ctx context.Context, id string) error {
return err return err
} }
func (s *WorkflowStore) MarkInstanceStale(ctx context.Context, id string) error {
now := time.Now().UTC()
_, err := DB.ExecContext(ctx, `
UPDATE workflow_instances SET status = 'stale', updated_at = ?
WHERE id = ? AND status = 'active'`, now.Format(timeFmt), id)
return err
}
func (s *WorkflowStore) ListActiveInstances(ctx context.Context) ([]models.WorkflowInstance, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, workflow_id, workflow_version, current_stage, stage_data,
status, started_by, entry_token, metadata,
stage_entered_at, created_at, updated_at
FROM workflow_instances WHERE status = 'active'
ORDER BY stage_entered_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowInstance
for rows.Next() {
var inst models.WorkflowInstance
var stageData, metadata string
if err := rows.Scan(&inst.ID, &inst.WorkflowID, &inst.WorkflowVersion,
&inst.CurrentStage, &stageData, &inst.Status, &inst.StartedBy,
&inst.EntryToken, &metadata,
st(&inst.StageEnteredAt), st(&inst.CreatedAt), st(&inst.UpdatedAt)); err != nil {
return nil, err
}
inst.StageData = json.RawMessage(stageData)
inst.Metadata = json.RawMessage(metadata)
result = append(result, inst)
}
return result, rows.Err()
}
// ── Assignments (v0.3.1) ──────────────────── // ── Assignments (v0.3.1) ────────────────────
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error { func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error {

View File

@@ -31,7 +31,7 @@ type WorkflowStore interface {
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error) GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error) GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
// Instances (v0.3.1) // Instances (v0.3.1+)
CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error
GetInstance(ctx context.Context, id string) (*models.WorkflowInstance, error) GetInstance(ctx context.Context, id string) (*models.WorkflowInstance, error)
GetInstanceByToken(ctx context.Context, token string) (*models.WorkflowInstance, error) GetInstanceByToken(ctx context.Context, token string) (*models.WorkflowInstance, error)
@@ -40,6 +40,8 @@ type WorkflowStore interface {
AdvanceStage(ctx context.Context, id string, nextStage string, stageData json.RawMessage) error AdvanceStage(ctx context.Context, id string, nextStage string, stageData json.RawMessage) error
CompleteInstance(ctx context.Context, id string) error CompleteInstance(ctx context.Context, id string) error
CancelInstance(ctx context.Context, id string) error CancelInstance(ctx context.Context, id string) error
MarkInstanceStale(ctx context.Context, id string) error
ListActiveInstances(ctx context.Context) ([]models.WorkflowInstance, error)
// Assignments (v0.3.1) // Assignments (v0.3.1)
CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error

View File

@@ -239,6 +239,68 @@ func (e *Engine) Cancel(ctx context.Context, instanceID string, userID string) e
return nil return nil
} }
// ── Public Entry (v0.3.3) ───────────────────
// StartPublic creates an instance for a public_link workflow without authentication.
// The caller is identified as "public:<uuid>". Returns the instance including its entry_token.
func (e *Engine) StartPublic(ctx context.Context, workflowID string, initialData json.RawMessage) (*models.WorkflowInstance, error) {
wf, err := e.stores.Workflows.GetByID(ctx, workflowID)
if err != nil {
return nil, fmt.Errorf("workflow not found: %w", err)
}
if wf.EntryMode != "public_link" {
return nil, fmt.Errorf("workflow %q does not accept public entry", wf.Name)
}
anonymousID := "public:" + uuid.New().String()
return e.Start(ctx, workflowID, initialData, anonymousID)
}
// ResumePublic returns an active instance by its entry token. No authentication required.
func (e *Engine) ResumePublic(ctx context.Context, entryToken string) (*models.WorkflowInstance, error) {
inst, err := e.stores.Workflows.GetInstanceByToken(ctx, entryToken)
if err != nil {
return nil, fmt.Errorf("instance not found: %w", err)
}
if inst.Status != models.InstanceStatusActive {
return nil, fmt.Errorf("instance is %s, not active", inst.Status)
}
return inst, nil
}
// AdvancePublic advances an instance identified by entry token. Only public-audience stages
// can be advanced this way. The original anonymous identity is used as the actor.
func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData json.RawMessage) (*models.WorkflowInstance, error) {
inst, err := e.stores.Workflows.GetInstanceByToken(ctx, entryToken)
if err != nil {
return nil, fmt.Errorf("instance not found: %w", err)
}
if inst.Status != models.InstanceStatusActive {
return nil, fmt.Errorf("instance is %s, not active", inst.Status)
}
// Load version snapshot to check audience
ver, err := e.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil, fmt.Errorf("version not found: %w", err)
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil, fmt.Errorf("corrupt snapshot: %w", err)
}
// Find current stage and validate audience
for _, s := range stages {
if s.Name == inst.CurrentStage {
if s.Audience != models.AudiencePublic {
return nil, fmt.Errorf("stage %q requires authenticated access", s.Name)
}
break
}
}
return e.advanceInternal(ctx, inst.ID, stageData, inst.StartedBy, 0)
}
// emit publishes an event on the bus. // emit publishes an event on the bus.
func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) { func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) {
if e.bus == nil { if e.bus == nil {

246
server/workflow/scanner.go Normal file
View File

@@ -0,0 +1,246 @@
package workflow
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"switchboard-core/events"
"switchboard-core/models"
"switchboard-core/store"
)
// Scanner runs periodic SLA and staleness checks on active workflow instances.
type Scanner struct {
stores store.Stores
bus *events.Bus
stopCh chan struct{}
wg sync.WaitGroup
interval time.Duration
}
// NewScanner creates a workflow scanner with a default 5-minute interval.
func NewScanner(stores store.Stores, bus *events.Bus) *Scanner {
return &Scanner{
stores: stores,
bus: bus,
stopCh: make(chan struct{}),
interval: 5 * time.Minute,
}
}
// Start begins the background scan loop.
func (sc *Scanner) Start() {
sc.wg.Add(1)
go func() {
defer sc.wg.Done()
ticker := time.NewTicker(sc.interval)
defer ticker.Stop()
// Delay first run to avoid startup contention
time.Sleep(2 * time.Minute)
sc.runScan()
for {
select {
case <-ticker.C:
sc.runScan()
case <-sc.stopCh:
return
}
}
}()
log.Printf("[workflow-scanner] started (interval=%s)", sc.interval)
}
// Stop halts the background scan loop and waits for completion.
func (sc *Scanner) Stop() {
close(sc.stopCh)
sc.wg.Wait()
log.Printf("[workflow-scanner] stopped")
}
// runScan performs one SLA + staleness check cycle.
func (sc *Scanner) runScan() {
ctx := context.Background()
instances, err := sc.stores.Workflows.ListActiveInstances(ctx)
if err != nil {
log.Printf("[workflow-scanner] list active instances: %v", err)
return
}
if len(instances) == 0 {
return
}
// Cache version snapshots and workflow definitions to avoid N+1 queries
type versionKey struct {
workflowID string
version int
}
versionCache := map[versionKey][]models.WorkflowStage{}
workflowCache := map[string]*models.Workflow{}
getStages := func(wfID string, ver int) []models.WorkflowStage {
key := versionKey{wfID, ver}
if stages, ok := versionCache[key]; ok {
return stages
}
v, err := sc.stores.Workflows.GetVersion(ctx, wfID, ver)
if err != nil {
return nil
}
var stages []models.WorkflowStage
if json.Unmarshal(v.Snapshot, &stages) != nil {
return nil
}
versionCache[key] = stages
return stages
}
getWorkflow := func(wfID string) *models.Workflow {
if wf, ok := workflowCache[wfID]; ok {
return wf
}
wf, err := sc.stores.Workflows.GetByID(ctx, wfID)
if err != nil {
return nil
}
workflowCache[wfID] = wf
return wf
}
var slaBreaches, staleMarked int
for i := range instances {
inst := &instances[i]
// ── SLA check ──
stages := getStages(inst.WorkflowID, inst.WorkflowVersion)
if stages != nil {
sc.checkSLA(ctx, inst, stages)
}
// ── Staleness check ──
wf := getWorkflow(inst.WorkflowID)
if wf != nil && wf.StalenessTimeoutHours != nil && *wf.StalenessTimeoutHours > 0 {
threshold := time.Duration(*wf.StalenessTimeoutHours) * time.Hour
if time.Since(inst.UpdatedAt) > threshold {
sc.markStale(ctx, inst)
staleMarked++
}
}
}
if slaBreaches > 0 || staleMarked > 0 {
log.Printf("[workflow-scanner] cycle: %d SLA breaches, %d stale", slaBreaches, staleMarked)
}
_ = slaBreaches // used in future if we count from checkSLA
}
// checkSLA checks whether the current stage has an SLA and whether it's been breached.
func (sc *Scanner) checkSLA(ctx context.Context, inst *models.WorkflowInstance, stages []models.WorkflowStage) {
// Find current stage
var currentStage *models.WorkflowStage
for i := range stages {
if stages[i].Name == inst.CurrentStage {
currentStage = &stages[i]
break
}
}
if currentStage == nil || currentStage.SLASeconds == nil {
return
}
slaDuration := time.Duration(*currentStage.SLASeconds) * time.Second
if time.Since(inst.StageEnteredAt) <= slaDuration {
return
}
// Check if already breached (avoid re-firing)
if metadataHasKey(inst.Metadata, "sla_breached") {
return
}
// Mark breached in metadata
inst.Metadata = setMetadataKey(inst.Metadata, "sla_breached", true)
if err := sc.stores.Workflows.UpdateInstance(ctx, inst); err != nil {
log.Printf("[workflow-scanner] update SLA metadata for %s: %v", inst.ID, err)
return
}
sc.emit("workflow.sla_breach", inst.ID, map[string]any{
"instance_id": inst.ID,
"workflow_id": inst.WorkflowID,
"stage": inst.CurrentStage,
"sla_seconds": *currentStage.SLASeconds,
"entered_at": inst.StageEnteredAt.Format(time.RFC3339),
})
log.Printf("[workflow-scanner] SLA breach: instance=%s stage=%s (limit=%ds)",
inst.ID, inst.CurrentStage, *currentStage.SLASeconds)
}
// markStale transitions an instance to stale and cancels open assignments.
func (sc *Scanner) markStale(ctx context.Context, inst *models.WorkflowInstance) {
if err := sc.stores.Workflows.MarkInstanceStale(ctx, inst.ID); err != nil {
log.Printf("[workflow-scanner] mark stale %s: %v", inst.ID, err)
return
}
// Cancel open assignments
assignments, _ := sc.stores.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
for _, a := range assignments {
if a.Status == models.AssignmentStatusUnassigned || a.Status == models.AssignmentStatusClaimed {
sc.stores.Workflows.CancelAssignment(ctx, a.ID)
}
}
sc.emit("workflow.stale", inst.ID, map[string]any{
"instance_id": inst.ID,
"workflow_id": inst.WorkflowID,
"stage": inst.CurrentStage,
})
log.Printf("[workflow-scanner] stale: instance=%s", inst.ID)
}
// emit publishes an event on the bus.
func (sc *Scanner) emit(label, room string, payload map[string]any) {
if sc.bus == nil {
return
}
sc.bus.Publish(events.Event{
Label: label,
Room: room,
Payload: events.MustJSON(payload),
Ts: time.Now().UnixMilli(),
})
}
// ── Metadata helpers ────────────────────────
func metadataHasKey(meta json.RawMessage, key string) bool {
if len(meta) == 0 {
return false
}
var m map[string]json.RawMessage
if json.Unmarshal(meta, &m) != nil {
return false
}
_, ok := m[key]
return ok
}
func setMetadataKey(meta json.RawMessage, key string, val any) json.RawMessage {
var m map[string]any
if len(meta) > 0 {
json.Unmarshal(meta, &m)
}
if m == nil {
m = map[string]any{}
}
m[key] = val
out, _ := json.Marshal(m)
return out
}