feat(triggers): add schedule trigger kind + internal scheduler
Build / build (push) Successful in 10m42s

Fourth trigger kind alongside registry/git/manual. Recurring time-interval
fires driven by a new internal/scheduler tick loop (default 30s, clamped
to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as
inbound HTTP webhooks, so per-binding concurrency, outcome accounting,
and config-merge semantics are identical.

Schema: triggers.last_fired_at TEXT column (additive ALTER for existing
DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking
Match cannot wedge a tight loop; failed deploys wait one full interval
before retry — correct trade-off for a periodic refresh trigger.

Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the
schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly,
custom interval input matched to Go time.ParseDuration syntax, optional
pinned reference). /triggers/[id] surfaces "last fired" on schedule rows.
EN+RU i18n in parity.

Review fixes from go-reviewer / security-reviewer / typescript-reviewer:
- Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double-
  cancel panic on shutdown re-entry).
- shouldFire rejects sub-MinInterval as defense-in-depth against
  hand-inserted rows that bypassed Validate.
- fire() asserts trigger Kind=="schedule" before dispatching.
- Aligned isValidInterval regex across all three frontend sites; reject
  the unsupported "d" unit (Go time.ParseDuration doesn't accept it).
- formatLastFired falls back to lastFiredNever on malformed timestamps
  rather than leaking raw bytes into the UI.
- main.go scheduler closure logs per-fire deployed/errored counts.
This commit is contained in:
2026-05-16 11:24:05 +03:00
parent e3c7b13d58
commit 39e1e36510
19 changed files with 1247 additions and 49 deletions
+29 -8
View File
@@ -10,14 +10,14 @@ import (
const triggerColumns = `id, kind, name, config,
webhook_secret, webhook_signing_secret, webhook_require_signature,
created_at, updated_at`
last_fired_at, created_at, updated_at`
func scanTrigger(s rowScanner) (Trigger, error) {
var t Trigger
var requireSig int
if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt); err != nil {
&t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt); err != nil {
return Trigger{}, err
}
t.WebhookRequireSignature = requireSig != 0
@@ -38,10 +38,10 @@ func (s *Store) CreateTrigger(t Trigger) (Trigger, error) {
t.UpdatedAt = t.CreatedAt
_, err := s.db.Exec(
`INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt,
t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
)
if err != nil {
return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
@@ -139,7 +139,7 @@ func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingC
const base = `
SELECT t.id, t.kind, t.name, t.config,
t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature,
t.created_at, t.updated_at,
t.last_fired_at, t.created_at, t.updated_at,
COALESCE(b.cnt, 0)
FROM triggers t
LEFT JOIN (
@@ -166,7 +166,7 @@ func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingC
var count int
if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config,
&t.WebhookSecret, &t.WebhookSigningSecret, &requireSig,
&t.CreatedAt, &t.UpdatedAt, &count); err != nil {
&t.LastFiredAt, &t.CreatedAt, &t.UpdatedAt, &count); err != nil {
return nil, fmt.Errorf("scan trigger+count: %w", err)
}
t.WebhookRequireSignature = requireSig != 0
@@ -236,10 +236,10 @@ func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding)
t.UpdatedAt = t.CreatedAt
if _, err := tx.Exec(
`INSERT INTO triggers (`+triggerColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Kind, t.Name, t.Config,
t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature),
t.CreatedAt, t.UpdatedAt,
t.LastFiredAt, t.CreatedAt, t.UpdatedAt,
); err != nil {
return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err))
}
@@ -301,3 +301,24 @@ func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) {
}
return secret, nil
}
// SetTriggerLastFired records the wall-clock the scheduler last
// dispatched this trigger. Callers pass time.Now().UTC().Format(time.RFC3339)
// so the value is stable across timezones. Updating last_fired_at does
// not bump updated_at — last_fired_at is operational state, while
// updated_at tracks user-visible config edits.
func (s *Store) SetTriggerLastFired(id, ts string) error {
result, err := s.db.Exec(
`UPDATE triggers SET last_fired_at = ? WHERE id = ?`,
ts, id,
)
if err != nil {
return fmt.Errorf("update trigger last_fired_at: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("trigger %s: %w", id, ErrNotFound)
}
return nil
}