feat(11-02): run plugin schedules as River periodic jobs through bonfire.Call

- pact.HasSchedule with ScheduledCommand and Daily/DailyAt/Every cadences (no River import)
- bonfire.Call, Catalog and ErrUnknownCommand for in-process command runs
- conga Daily/Every wall-clock schedules in app.timezone, periodic jobs on every worker,
  scheduled queue (MaxAttempts 1, unique by args within the cadence period)
- scheduled worker runs only entries matching the compiled table; unregistered
  commands are skipped with a Warn log
- generated app main publishes bonfire.NewCatalog(commands); hello main regenerated
This commit is contained in:
Jakub Zych
2026-09-29 19:47:51 +02:00
parent 77b8ff177a
commit d9f939a1ea
15 changed files with 890 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"io/fs"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/go-gormigrate/gormigrate/v2"
@@ -98,6 +99,77 @@ type HasJobs interface {
Jobs() []Job
}
type cadenceKind uint8
const (
cadenceNone cadenceKind = iota
cadenceDaily
cadenceEvery
)
// Cadence is how often a scheduled command runs. Build one with Daily,
// DailyAt or Every; the zero value is no cadence and is rejected by the
// scheduler.
type Cadence struct {
kind cadenceKind
hour int
minute int
every time.Duration
}
// Daily runs once a day at 00:00 in the app timezone (Laravel ->daily()).
func Daily() Cadence { return DailyAt(0, 0) }
// DailyAt runs once a day at hour:minute in the app timezone.
func DailyAt(hour, minute int) Cadence {
return Cadence{kind: cadenceDaily, hour: hour, minute: minute}
}
// Every runs at every multiple of d since local midnight in the app
// timezone. The scheduler requires d to be at least one second and to divide
// 24h evenly.
func Every(d time.Duration) Cadence {
return Cadence{kind: cadenceEvery, every: d}
}
// IsZero reports whether c is the zero Cadence.
func (c Cadence) IsZero() bool { return c.kind == cadenceNone }
// Interval is the period of c: 24h for a daily cadence, d for Every(d) and
// zero for the zero Cadence.
func (c Cadence) Interval() time.Duration {
switch c.kind {
case cadenceDaily:
return 24 * time.Hour
case cadenceEvery:
return c.every
}
return 0
}
// At returns the wall-clock time of a daily cadence; ok is false for Every
// and for the zero Cadence.
func (c Cadence) At() (hour, minute int, ok bool) {
if c.kind != cadenceDaily {
return 0, 0, false
}
return c.hour, c.minute, true
}
// ScheduledCommand is one recurring run of a registered console command.
// Command is the command name (namespace:verb) and Args its arguments.
type ScheduledCommand struct {
Command string
Args []string
Cadence Cadence
}
// HasSchedule is implemented by plugins that run console commands on a
// schedule. Only these compiled entries are ever executed by the scheduler.
type HasSchedule interface {
Schedule() []ScheduledCommand
}
// AdminController is the compile-time admin controller contract. Phase 9
// grows the schema pipeline; ID, model name and YAML config directory are
// enough for generated stubs to compile.
@@ -369,10 +441,10 @@ type OptionalMessage interface {
// packages exist:
//
// HasListeners
// HasSchedule
//
// The kernel type-asserts HasConfig (party, before Register), HasCommands
// (generated app main, after Boot), HasMigrations (lagoon migrate), and
// (generated app main, after Boot), HasMigrations (lagoon migrate),
// HasJobs and HasSchedule (conga workers), and
// HasMiddleware/HasMiddlewareFactories/HasHouseMiddleware/HasRoutes
// (surf assemble). surf.BucketProvider is type-asserted in Assemble/
// BuildRouter (not a pact interface: pact cannot import surf without a cycle).