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

@@ -20,12 +20,13 @@ func (s *WorkflowStore) Create(ctx context.Context, w *models.Workflow) error {
branding := jsonOrEmpty(w.Branding)
retention := jsonOrDefault(w.Retention, `{"mode":"archive"}`)
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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
INSERT INTO workflows (team_id, name, slug, description, branding, entry_mode, is_active,
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`,
w.TeamID, w.Name, w.Slug, w.Description, branding, w.EntryMode,
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)
}
@@ -36,11 +37,11 @@ func (s *WorkflowStore) GetByID(ctx context.Context, id string) (*models.Workflo
err := DB.QueryRowContext(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 = $1`, id,
).Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
&webhookURL, &webhookSecret,
&webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
if err != nil {
return nil, err
@@ -63,13 +64,13 @@ func (s *WorkflowStore) GetBySlug(ctx context.Context, teamID *string, slug stri
if teamID != nil {
q = `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 = $1 AND slug = $2`
args = []interface{}{*teamID, slug}
} else {
q = `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 = $1`
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(
&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description, &branding,
&w.EntryMode, &w.IsActive, &w.Version, &onComplete, &retention,
&webhookURL, &webhookSecret,
&webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt)
if err != nil {
return nil, err
@@ -135,6 +136,9 @@ func (s *WorkflowStore) Update(ctx context.Context, id string, patch models.Work
if patch.WebhookSecret != nil {
add("webhook_secret", *patch.WebhookSecret)
}
if patch.StalenessTimeoutHours != nil {
add("staleness_timeout_hours", *patch.StalenessTimeoutHours)
}
if len(sets) == 0 {
return nil
@@ -166,7 +170,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 = $1
ORDER BY name ASC`, teamID)
}
@@ -175,7 +179,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`)
}
@@ -193,7 +197,7 @@ func (s *WorkflowStore) queryWorkflows(ctx context.Context, q string, args ...in
var webhookURL, webhookSecret *string
if err := rows.Scan(&w.ID, &w.TeamID, &w.Name, &w.Slug, &w.Description,
&branding, &w.EntryMode, &w.IsActive, &w.Version, &onComplete,
&retention, &webhookURL, &webhookSecret,
&retention, &webhookURL, &webhookSecret, &w.StalenessTimeoutHours,
&w.CreatedBy, &w.CreatedAt, &w.UpdatedAt); err != nil {
return nil, err
}
@@ -533,6 +537,40 @@ func (s *WorkflowStore) CancelInstance(ctx context.Context, id string) error {
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) ────────────────────
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, `
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 {

View File

@@ -31,7 +31,7 @@ type WorkflowStore interface {
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*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
GetInstance(ctx context.Context, id 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
CompleteInstance(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)
CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error