Feat v0.3.3 public entry + background jobs
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-sqlite (pull_request) Successful in 2m39s
CI/CD / test-go-pg (pull_request) Failing after 2m54s
CI/CD / build-and-deploy (pull_request) Has been skipped
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-sqlite (pull_request) Successful in 2m39s
CI/CD / test-go-pg (pull_request) Failing after 2m54s
CI/CD / build-and-deploy (pull_request) Has been skipped
Public workflow entry: unauthenticated routes at /api/v1/public/workflows/ for anonymous workflow participation. StartPublic creates instances with public:<uuid> identity, ResumePublic/AdvancePublic use entry tokens. Audience-gated: only public stages can be advanced anonymously. SLA scanner: background goroutine (5-min interval) checks active instances against per-stage sla_seconds. Fires workflow.sla_breach event on first breach, marks sla_breached in instance metadata (idempotent). Staleness sweep: new staleness_timeout_hours column on workflows. Scanner marks idle instances as stale, cancels open assignments, fires workflow.stale event. New store methods: ListActiveInstances, MarkInstanceStale. New events: workflow.sla_breach, workflow.stale. 3 new store tests (17 total), all passing on SQLite. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,11 +29,11 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO workflows (id, team_id, name, slug, description, branding, entry_mode,
|
||||
is_active, version, on_complete, retention, webhook_url, webhook_secret,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
staleness_timeout_hours, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID, w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
|
||||
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))
|
||||
return err
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflo
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, 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
|
||||
FROM workflows WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
@@ -51,13 +51,13 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, 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
|
||||
FROM workflows WHERE team_id = ? AND slug = ?`, *teamID, slug)
|
||||
}
|
||||
return s.scanOne(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, 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
|
||||
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 = ?")
|
||||
args = append(args, *patch.WebhookSecret)
|
||||
}
|
||||
if patch.StalenessTimeoutHours != nil {
|
||||
sets = append(sets, "staleness_timeout_hours = ?")
|
||||
args = append(args, *patch.StalenessTimeoutHours)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
return nil
|
||||
@@ -133,7 +137,7 @@ func (s *WorkflowStore) ListForTeam(ctx context.Context, teamID string) ([]model
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, 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
|
||||
FROM workflows WHERE team_id = ?
|
||||
ORDER BY name ASC`, teamID)
|
||||
}
|
||||
@@ -142,7 +146,7 @@ func (s *WorkflowStore) ListGlobal(ctx context.Context) ([]models.Workflow, erro
|
||||
return s.queryWorkflows(ctx, `
|
||||
SELECT id, team_id, name, slug, description, branding, entry_mode, 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
|
||||
FROM workflows WHERE team_id IS NULL
|
||||
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(
|
||||
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
|
||||
&w.EntryMode, &isActive, &w.Version, &onComplete, &retention,
|
||||
&webhookURL, &webhookSecret,
|
||||
&webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
|
||||
&w.CreatedBy, st(&w.CreatedAt), st(&w.UpdatedAt))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -354,7 +358,7 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
|
||||
var isActive int
|
||||
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
|
||||
&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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -553,6 +557,42 @@ func (s *WorkflowStore) CancelInstance(ctx context.Context, id string) error {
|
||||
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) ────────────────────
|
||||
|
||||
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error {
|
||||
|
||||
Reference in New Issue
Block a user