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
+33
View File
@@ -32,10 +32,12 @@ import (
"github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/reconciler"
"github.com/alexei/tinyforge/internal/scheduler"
"github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
"github.com/alexei/tinyforge/internal/workload/plugin"
// Plugin registrations: each blank-import runs its init() and registers
// itself with internal/workload/plugin. Adding a new Source or Trigger
@@ -46,6 +48,7 @@ import (
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git"
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/manual"
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/registry"
_ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/schedule"
)
func main() {
@@ -178,6 +181,36 @@ func main() {
webhookHandler := webhook.NewHandler(db)
webhookHandler.SetPluginDispatcher(dep)
// Scheduler ticks every 30s and dispatches "schedule"-kind triggers
// through the same FanOutForTrigger path as the inbound webhook. Boot
// runs one sweep immediately so a daily schedule does not idle 24h
// after a restart before catching up.
sched := scheduler.New(db, func(ctx context.Context, trg store.Trigger, evt plugin.InboundEvent) error {
results, err := webhookHandler.FanOutForTrigger(ctx, trg, evt)
if err != nil {
return err
}
// Log per-fire summary so a schedule that quietly fails on N
// of M bindings is visible without parsing per-binding rows.
var deployed, errored int
for _, r := range results {
switch {
case r.Deployed:
deployed++
case r.Reason == "binding disabled", r.Reason == "no match":
// not a failure — silent
default:
errored++
}
}
slog.Info("scheduler dispatch summary",
"trigger", trg.Name, "bindings", len(results),
"deployed", deployed, "errored", errored)
return nil
}, 30*time.Second)
sched.Start(context.Background())
defer sched.Stop()
// Initialize stale container scanner.
staleScanner := stale.New(db, dockerClient, eventBus)
if err := staleScanner.Start("1h"); err != nil {