Chat autosend, week rollover and ai_runs purge schedules (Feature 20)
CI / ci (push) Successful in 7m55s
CI / ci (pull_request) Successful in 7m30s

shopping_chat_autosend fires at chat_autosend_dow/time for the coming
week and posts its Supply run through SendToChat when a webhook is
stored, list_ready's Chat channel is on and the list exists unsent;
otherwise it logs why (key list_ready:chat:{weekStart}). week_rollover
freezes the previous week at Monday 00:05 through Freeze, judging the
week ended by the fire time. ai_runs_purge calls harness.Purge daily at
03:00 with the fire time less RunRetention. Fake-clock tests cover each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 15:38:42 -04:00
co-authored by Claude Opus 5.5
parent 02d537b303
commit 7f48a67ad4
8 changed files with 663 additions and 16 deletions
+129
View File
@@ -0,0 +1,129 @@
package shopping
import (
"context"
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// RolloverTime is when, every Monday in the household zone, week_rollover
// freezes the week that has just ended. It is fixed, not a setting.
const RolloverTime = "00:05"
// Reasons Autosend posts nothing.
const (
AutosendNotConfigured = "no Google Chat webhook is configured"
AutosendChatOff = "list_ready is switched off for Google Chat"
AutosendNoList = "the week has no Supply run"
AutosendAlreadySent = "the Supply run was already sent to Google Chat"
AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed"
)
// AutosendKey identifies the automatic send of the Supply run of the week
// starting weekStart, list_ready:chat:YYYY-MM-DD, in the schedule's logs.
// The send itself is recorded in the list's sent_to_chat_at.
func AutosendKey(weekStart store.LocalDate) string {
return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String()
}
// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the
// household's chat_autosend_dow and chat_autosend_time it sends the Supply
// run of the coming week, the week after the fire date's, to Google Chat
// with Autosend. Its period scheduler.WeeklyForNextWeek keys
// shopping_chat_autosend:YYYY-Www for that week. A send that fails is the
// period's error and is not retried; the household can still send it from
// the Supply run.
func (s *Service) ChatAutosendSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.ShoppingChatAutosend,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
skipped, err := s.Autosend(ctx, p.Week)
if err != nil {
return err
}
log := s.logger.With("period", p.Key, "key", AutosendKey(p.Week), "week_start", p.Week.String())
if skipped != "" {
log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped)
return nil
}
log.InfoContext(ctx, "Supply run sent to Google Chat")
return nil
},
}
}
// Autosend sends the Supply run of the week starting weekStart to Google
// Chat with SendToChat, as the household's send does, when a webhook is
// stored, list_ready's Google Chat channel is switched on, and the week has
// a Supply run not yet sent and with no send unconfirmed. Otherwise it
// posts nothing and returns which of those reasons stopped it; it never
// resends. A send that fails is SendToChat's error.
func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) {
settings, err := s.chat.Get(ctx)
if err != nil {
return "", fmt.Errorf("shopping: autosend: %w", err)
}
if settings.URLMasked == nil {
return AutosendNotConfigured, nil
}
on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat)
if err != nil {
return "", fmt.Errorf("shopping: autosend: %w", err)
}
if !on {
return AutosendChatOff, nil
}
l, err := s.Get(ctx, weekStart)
if errors.Is(err, ErrListNotFound) {
return AutosendNoList, nil
}
if err != nil {
return "", err
}
switch {
case l.SentToChatAt != nil:
return AutosendAlreadySent, nil
case l.ChatSendUnconfirmedAt != nil:
return AutosendUnconfirmed, nil
}
if _, err := s.SendToChat(ctx, weekStart, false); err != nil {
return "", err
}
return "", nil
}
// RolloverSchedule is the week_rollover schedule: every Monday at
// RolloverTime it freezes the Supply run of the week before the fire
// date's into the Supply archive with Freeze, the week ending by the fire
// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the
// fire date's week. A week with no Supply run has nothing to freeze, and
// the period says so; a freeze that fails is the period's error and is not
// retried.
func (s *Service) RolloverSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.WeekRollover,
Rule: func(household.Settings) scheduler.Rule {
return scheduler.Weekly(time.Monday, RolloverTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
y, m, d := p.Week.Date()
last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
err := s.freeze(ctx, last, p.FireAt)
if errors.Is(err, ErrListNotFound) {
s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run",
"period", p.Key, "week_start", last.String())
return nil
}
return err
},
}
}