This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/trigger_sync.go
Jeffrey Smith bd703b9e0d
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 24s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-go-pg (pull_request) Successful in 2m30s
CI/CD / test-sqlite (pull_request) Successful in 2m37s
CI/CD / build-and-deploy (pull_request) Successful in 1m30s
Feat event bus subscriptions + trigger system (v0.2.2)
Three trigger primitives replacing the old monolithic scheduler:

- Event triggers: extensions subscribe to bus patterns via manifest,
  async handler invocation through sandbox.CallEntryPoint
- Webhook triggers: inbound HTTP at /api/v1/hooks/:pkg/:slug with
  HMAC-SHA256 verification and synchronous Starlark response
- Scheduled tasks: user-created cron scripts with restricted sandbox
  (no raw HTTP, no DB table creation), runs as creator identity

New tables: triggers, scheduled_tasks, trigger_logs (postgres + sqlite).
New permission: triggers.register. Full admin + user CRUD APIs.
SyncManifestTriggers hooked into seed and install flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:29:43 +00:00

193 lines
4.8 KiB
Go

package handlers
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/triggers"
)
// manifestTrigger is the manifest.json trigger declaration shape.
type manifestTrigger struct {
Type string `json:"type"` // "event" or "webhook"
Pattern string `json:"pattern"` // event pattern (for type=event)
Slug string `json:"slug"` // webhook slug (for type=webhook)
EntryPoint string `json:"entry_point"` // Starlark function name
}
// SyncManifestTriggers parses the "triggers" array from a package manifest
// and syncs them with the trigger store. Removes triggers no longer in the
// manifest (declarative sync). For webhooks, generates an HMAC secret on
// first create.
//
// Called from SeedBuiltinPackages, admin install, and package install.
func SyncManifestTriggers(ctx context.Context, stores store.Stores, engine *triggers.Engine, pkgID string, manifest map[string]any) {
if stores.Triggers == nil {
return
}
// Parse triggers from manifest
triggersRaw, ok := manifest["triggers"]
if !ok {
return
}
data, err := json.Marshal(triggersRaw)
if err != nil {
return
}
var declared []manifestTrigger
if err := json.Unmarshal(data, &declared); err != nil {
log.Printf(" ⚠️ trigger-sync[%s]: invalid triggers in manifest: %v", pkgID, err)
return
}
if len(declared) == 0 {
return
}
// Get existing triggers for this package
existing, err := stores.Triggers.ListByPackage(ctx, pkgID)
if err != nil {
log.Printf(" ⚠️ trigger-sync[%s]: failed to list existing: %v", pkgID, err)
return
}
// Build lookup maps
existingByKey := make(map[string]*models.Trigger, len(existing))
for i := range existing {
key := triggerKey(&existing[i])
existingByKey[key] = &existing[i]
}
declaredKeys := make(map[string]bool, len(declared))
created, updated := 0, 0
for _, dt := range declared {
if dt.Type != models.TriggerTypeEvent && dt.Type != models.TriggerTypeWebhook {
log.Printf(" ⚠️ trigger-sync[%s]: unknown trigger type %q, skipping", pkgID, dt.Type)
continue
}
if dt.EntryPoint == "" {
continue
}
key := dt.Type + ":" + triggerIdentifier(dt)
declaredKeys[key] = true
if ex, ok := existingByKey[key]; ok {
// Update if entry_point changed
if ex.EntryPoint != dt.EntryPoint {
ex.EntryPoint = dt.EntryPoint
if err := stores.Triggers.Update(ctx, ex); err != nil {
log.Printf(" ⚠️ trigger-sync[%s]: update failed: %v", pkgID, err)
} else {
updated++
}
}
continue
}
// Create new trigger
t := &models.Trigger{
PackageID: pkgID,
Type: dt.Type,
Enabled: true,
EntryPoint: dt.EntryPoint,
Config: json.RawMessage("{}"),
}
switch dt.Type {
case models.TriggerTypeEvent:
t.EventPattern = dt.Pattern
case models.TriggerTypeWebhook:
t.Slug = dt.Slug
t.Secret = generateSecret()
}
if err := stores.Triggers.Create(ctx, t); err != nil {
log.Printf(" ⚠️ trigger-sync[%s]: create failed: %v", pkgID, err)
continue
}
created++
// Register with engine
if engine != nil {
// Re-fetch to get the generated ID
switch dt.Type {
case models.TriggerTypeWebhook:
if wh, _ := stores.Triggers.GetWebhook(ctx, pkgID, dt.Slug); wh != nil {
engine.RegisterTrigger(wh)
}
case models.TriggerTypeEvent:
// Need to fetch by package to find the new one
triggers, _ := stores.Triggers.ListByPackage(ctx, pkgID)
for i := range triggers {
if triggers[i].EventPattern == dt.Pattern {
engine.RegisterTrigger(&triggers[i])
break
}
}
}
}
}
// Remove triggers no longer in manifest
removed := 0
for key, ex := range existingByKey {
if !declaredKeys[key] {
if engine != nil {
engine.UnregisterTrigger(ex.ID)
}
if err := stores.Triggers.Delete(ctx, ex.ID); err != nil {
log.Printf(" ⚠️ trigger-sync[%s]: delete failed: %v", pkgID, err)
} else {
removed++
}
}
}
if created+updated+removed > 0 {
log.Printf(" 🔔 trigger-sync[%s]: %d created, %d updated, %d removed",
pkgID, created, updated, removed)
}
}
// triggerKey returns a unique key for a trigger within a package.
func triggerKey(t *models.Trigger) string {
return t.Type + ":" + triggerIdentifierFromModel(t)
}
func triggerIdentifier(dt manifestTrigger) string {
switch dt.Type {
case models.TriggerTypeEvent:
return dt.Pattern
case models.TriggerTypeWebhook:
return dt.Slug
}
return ""
}
func triggerIdentifierFromModel(t *models.Trigger) string {
switch t.Type {
case models.TriggerTypeEvent:
return t.EventPattern
case models.TriggerTypeWebhook:
return t.Slug
}
return ""
}
// generateSecret creates a random 32-byte hex-encoded secret for webhook HMAC.
func generateSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}