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

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:
2026-03-27 22:28:22 +00:00
parent ab28e4b784
commit 7adb47a50f
14 changed files with 743 additions and 40 deletions

View File

@@ -239,6 +239,68 @@ func (e *Engine) Cancel(ctx context.Context, instanceID string, userID string) e
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.
func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) {
if e.bus == nil {