26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, coupling_justified, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | coupling_justified | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-jobs-realtime-and-search-infrastructure | 02 | execute | 2 |
|
|
true |
|
|
|
|
Phase Goal
ROADMAP Phase 11 goal (verbatim, not in user-story form): River jobs run on the correct dual-driver split, Centrifugo publishing and channel authorization match the existing server, and Typesense sync stays a re-gated pre-filter — all brought up before the API phases that depend on them.
This plan's slice: a plugin declares a daily or interval command and it runs on schedule inside summer serve, in a dedicated summer schedule:run process, or from system cron with --once (CLI-04, ROADMAP SC-2).
Purpose: Laravel's registerSchedule has no Go counterpart yet; Phase 14's prune-notifications command and later maintenance jobs need it. Decisions implemented: D-18; user decision 5 (unknown command warns and skips); RESEARCH Pattern 6 and its anti-pattern on PeriodicInterval(24h).
Output: pact.HasSchedule and cadences, bonfire Catalog/Call, conga Daily/Every schedules and scheduler wiring, schedule:run [--once], generated main catalog publish, summer schedule:run delegate, fonoteka schedule.go.
Repos: summercms.go (framework) and fonoteka.go (schedule.go, regenerated main.go). This plan does not edit fonoteka's plugin.go (the schedule lives in its own file so plan 11-03 can edit plugin.go in the same wave). Planning docs and code in separate commits. Never add co-author tags.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
- pact:
HasSchedule(Schedule() []ScheduledCommand),ScheduledCommand{Command string; Args []string; Cadence Cadence},Cadence(opaque struct withIsZero(),Interval() time.Duration,At() (hour, minute int, ok bool)), constructorsDaily(),DailyAt(hour, minute int),Every(d time.Duration). - bonfire:
Catalog,NewCatalog(commands []Command) *Catalog,(*Catalog).Has(name string) bool,(*Catalog).Call(ctx, name string, args []string, out io.Writer) error,Call(ctx, commands []Command, name string, args []string, out io.Writer) error,ErrUnknownCommand. - conga:
Daily{Hour, Minute int; Loc *time.Location}andEvery{Interval time.Duration; Loc *time.Location}(bothNext(time.Time) time.Time),ScheduledCommandArgs{Entry, Command string; Args []string}with Kindsummer.scheduled_command, queue constantQueueScheduled = "scheduled", theschedule:run [--once]command. - CLI: app command
schedule:run(flagonce, bare);summer schedule:rundelegate forwarding--once. - Config key:
app.timezone(read, default UTC). - Files:
modules/bonfire/call.go,modules/conga/schedule.go,modules/conga/scheduler.go,../fonoteka.go/plugins/golem15/fonoteka/schedule.go.
(2) bonfire, new modules/bonfire/call.go: var ErrUnknownCommand = errors.New("bonfire: unknown command"); func Call(ctx context.Context, commands []Command, name string, args []string, out io.Writer) error finds the command by exact name (else fmt.Errorf("%w: %q", ErrUnknownCommand, name)), builds a root with NewRootIO(name, []Command{cmd}, strings.NewReader(""), out, out) (empty stdin so prompts take their defaults), sets args append([]string{name}, args...) and runs ExecuteContext(ctx); type Catalog struct holding a copy of the commands, NewCatalog(commands []Command) *Catalog, (*Catalog).Has(name) bool, (*Catalog).Call(ctx, name, args, out) error. A smoke TestCall in call_test.go (a command receiving its args and writing to out; unknown name wraps ErrUnknownCommand). README updated.
(3) conga schedules, new modules/conga/schedule.go (RESEARCH Pattern 6): type Daily struct { Hour, Minute int; Loc *time.Location } whose Next returns the first Hour:Minute:00 in Loc strictly after now (adding a day when not after, using time.Date so DST gaps resolve the way Go normalizes them); type Every struct { Interval time.Duration; Loc *time.Location } whose Next returns local midnight plus the smallest multiple of Interval strictly after now (rolling into the next day at midnight). A helper scheduleFor(c pact.Cadence, loc *time.Location) (river.PeriodicSchedule, time.Duration, error) returns the schedule and its period, rejecting a zero cadence, a non-positive interval and an interval that does not evenly divide 24h. Location comes from app.timezone (default "UTC", time.LoadLocation; an invalid name is a start error).
(4) conga scheduler, new modules/conga/scheduler.go: const QueueScheduled = "scheduled"; type ScheduledCommandArgs struct { Entry string; Command string; Args []string } with Kind() "summer.scheduled_command". periodicJobs(app, plugins) ([]*river.PeriodicJob, map[string]pact.ScheduledCommand, error) walks plugins in activation order and each pact.HasSchedule entry in declaration order; entry id <plugin id>#<index>:<command>; an empty Command or invalid cadence is an error naming plugin id and index; each periodic job's constructor returns the args plus &river.InsertOpts{Queue: QueueScheduled, MaxAttempts: 1, UniqueOpts: river.UniqueOpts{ByPeriod: period}} with &river.PeriodicJobOpts{ID: entryID}. The built-in job (registered once through conga.Job on QueueScheduled, MaxAttempts 1) looks the Entry up in the compiled table and runs only when Command and Args match it exactly (T-11-09); it resolves *bonfire.Catalog from the app (missing catalog: warn and skip), and on errors.Is(err, bonfire.ErrUnknownCommand) logs Warn schedule: command not registered; skipping with the command attribute and returns nil (user decision 5); other command errors are logged with duration and returned (no retry because MaxAttempts is 1). Command output goes to an io.Writer that logs each line at Info with the command attribute.
(5) Wiring: in modules/conga/worker.go StartWorker registers the built-in job, adds QueueScheduled to the known queues (MaxWorkers from queue.queues.scheduled, default 1) and sets river.Config.PeriodicJobs from periodicJobs for every worker regardless of the queue filter (only the elected leader enqueues). In internal/build/build.go the generated main, after collecting plugin commands and before bonfire.NewRoot, emits if err := app.Publish(bonfire.NewCatalog(commands)); err != nil { return err }; add TestGenerateMainPublishesCommandCatalog to build_test.go; regenerate examples/hello/main.go and ../fonoteka.go/main.go with the framework CLI.
(6) Smoke test modules/conga/schedule_test.go: TestScheduleNext (Daily and Every adjacency: exactly-on-boundary returns the next occurrence; DST day in Europe/Warsaw keeps wall-clock hh:mm) and TestScheduleRunsCommand (Postgres harness: an acme test plugin declaring acme:tick with pact.Every(time.Second), a catalog holding an acme:tick command that signals a channel, StartWorker with all queues, assert the command runs within 10s; a second entry acme:missing produces the skip warning in a captured slog handler). Document the scheduler in modules/conga/README.md (Usage, API reference, CLI commands).
go vet ./... && go test ./modules/pact ./modules/bonfire ./internal/build -count=1 && go test ./modules/bonfire -run '^TestCall$' -count=1 -v && go test ./modules/conga -run '^(TestScheduleNext|TestScheduleRunsCommand)$' -count=1 -v && go test ./internal/build -run '^TestGenerateMainPublishesCommandCatalog$' -count=1 -v
<fails_when>Any command exits non-zero; a verbose run lacks "--- PASS" for TestCall, TestScheduleNext, TestScheduleRunsCommand or TestGenerateMainPublishesCommandCatalog, or prints "no tests to run" or "--- SKIP".</fails_when>
<acceptance_criteria>
- go doc ./modules/pact HasSchedule, go doc ./modules/pact ScheduledCommand, go doc ./modules/pact Every, go doc ./modules/bonfire Call and go doc ./modules/conga Daily exit 0.
- grep -c 'riverqueue' modules/pact/capabilities.go prints 0.
- grep -c 'ByPeriod' modules/conga/scheduler.go prints at least 1 and grep -c 'PeriodicJobs' modules/conga/worker.go prints at least 1.
- grep -c 'bonfire.NewCatalog(commands)' ../fonoteka.go/main.go examples/hello/main.go prints 1 for each file.
- grep -c 'HasSchedule' modules/pact/README.md prints at least 1 and grep -c 'Catalog' modules/bonfire/README.md prints at least 1.
</acceptance_criteria>
A plugin declares Every(1s) and its command runs through a River periodic job and bonfire.Call inside a worker, unknown commands are skipped with a warning, and every generated main publishes the command catalog.
(2) summer delegate: in cmd/summer/runtime.go add delegateScheduleRunCommand() declaring the bare once flag and forwarding --once when set; register it in cmd/summer/main.go toolCommands and extend the expected list in main_test.go.
(3) fonoteka.go (D-18, user decision 5): new plugins/golem15/fonoteka/schedule.go with var _ pact.HasSchedule = (*Plugin)(nil) and func (p *Plugin) Schedule() []pact.ScheduledCommand { return []pact.ScheduledCommand{{Command: "fonoteka:prune-notifications", Cadence: pact.Daily()}} } and a comment that the command itself ships in Phase 14 and is skipped with a warning until then. Do not edit plugin.go.
(4) Tests for the behavior list in modules/conga/schedule_test.go (TestScheduleRunOnce, TestScheduledEntryMismatchSkipped, TestScheduleUniqueByPeriod); the ByPeriod test inserts the same constructed args twice through the periodic constructor within one period and counts river_job rows. Update modules/conga/README.md CLI commands with schedule:run and --once.
go vet ./... && go test ./modules/conga -run '^(TestScheduleRunOnce|TestScheduledEntryMismatchSkipped|TestScheduleUniqueByPeriod)$' -count=1 -v && go test ./cmd/summer -count=1 && go test ./... && (cd ../fonoteka.go && go vet ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... && go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/...)
<fails_when>Any command exits non-zero; the verbose run lacks "--- PASS" for TestScheduleRunOnce, TestScheduledEntryMismatchSkipped or TestScheduleUniqueByPeriod, or prints "no tests to run" or "--- SKIP"; any package reports FAIL.</fails_when>
<acceptance_criteria>
- grep -c '"schedule:run"' modules/conga/commands.go prints at least 1 and grep -c 'schedule:run' cmd/summer/main_test.go prints at least 1.
- grep -c 'No scheduled commands are ready to run.' modules/conga/commands.go prints 1.
- grep -c 'fonoteka:prune-notifications' ../fonoteka.go/plugins/golem15/fonoteka/schedule.go prints 1 and grep -c 'pact.Daily()' ../fonoteka.go/plugins/golem15/fonoteka/schedule.go prints 1.
- (cd ../fonoteka.go && SUMMER_GOLEM15__USER__JWT__SECRET=test-only-cli-secret go run . schedule:run --help) output contains "--once" (the app refuses to boot without a user JWT secret, so a test-only value is passed).
</acceptance_criteria>
schedule:run runs as a scheduler-only process or once per cron minute, fonoteka declares its daily prune entry (skipped with a warning until Phase 14), and both repositories pass their full suites.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| river_job rows (database) → scheduled-command worker | Job args stored in Postgres name a command to execute in-process |
System cron → schedule:run --once |
An external trigger runs due commands |
| Go module proxy → go.mod/go.sum | No new module in this plan |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-11-09 | Tampering | scheduled-command worker | high | mitigate | Command names come only from compiled pact.HasSchedule entries; the worker runs a job only when its Entry exists in the compiled table and Command/Args match exactly, so a forged river_job row cannot run an arbitrary command (Tasks 1-2, TestScheduledEntryMismatchSkipped). |
| T-11-17 | Denial of Service | periodic enqueue on leader failover | low | mitigate | UniqueOpts.ByPeriod dedupes an entry within its period; wall-clock Daily/Every schedules avoid the restart drift of PeriodicInterval (Task 1-2). |
| T-11-18 | Repudiation | scheduled runs | low | mitigate | Each run, skip and failure is logged with the command name and duration; skips of unregistered commands are Warn level (Task 1). |
| T-11-SC | Tampering | Go module installs | high | mitigate | No dependency added; River stays pinned at v0.47.0 from plan 11-01; cron parsing is not added because Daily/Every cover the cadences. |
| </threat_model> |
<success_criteria>
- pact.HasSchedule exists with Daily/DailyAt/Every cadences and no River import.
- Every worker carries the periodic jobs; scheduled runs go through bonfire.Call with ByPeriod dedupe and MaxAttempts 1.
schedule:runruns in the foreground or--once; unknown commands warn and skip.- fonoteka declares
fonoteka:prune-notificationsdaily in schedule.go. - pact, bonfire and conga READMEs updated in the same commits. </success_criteria>