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
+13
View File
@@ -204,6 +204,19 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err return err
} }
} }
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
return err
}
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
return err
}
purge, err := harness.PurgeSchedule(db, logger)
if err != nil {
return err
}
if err := sched.Register(purge); err != nil {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets()) router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
if err != nil { if err != nil {
+22 -7
View File
@@ -537,8 +537,8 @@ automatic** (D-021).
received. received.
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days), `harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
clearing the `suggestion_batches` and `week_plans` references first. The clearing the `suggestion_batches` and `week_plans` references first. The
harness does not call it: the scheduler's daily `ai_runs` purge daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
(Feature 20) must call `harness.Purge` with `now - RunRetention`. its fire time less `RunRetention`.
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run` - **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
takes a scope key, and concurrent runs of one skill with one scope share a takes a scope key, and concurrent runs of one skill with one scope share a
single in-flight run and its result, error included (single-flight); runs single in-flight run and its result, error included (single-flight); runs
@@ -653,7 +653,8 @@ job failed, the freeze finds the list still untitled and makes its own
`archive_title` attempt before freezing. A job that starts during a freeze `archive_title` attempt before freezing. A job that starts during a freeze
waits for it, then finds the list titled (that title stands) or frozen waits for it, then finds the list titled (that title stands) or frozen
untitled (the job fails `title_failed`), and stores nothing. The untitled (the job fails `title_failed`), and stores nothing. The
`week_rollover` schedule is to call `Freeze` for last week. `week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
`Freeze` for last week, judging the week ended by its fire time.
## Recipe grounding ## Recipe grounding
@@ -781,11 +782,11 @@ purge are fixed and not configurable.
| --- | --- | --- | | --- | --- | --- |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). | | `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). | | `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | | `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). | | `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | | `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. | | `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). | | `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
Runs are recorded in `schedule_runs` and executed through `jobs`. Runs are recorded in `schedule_runs` and executed through `jobs`.
@@ -840,6 +841,20 @@ comes at once, so a crock-pot dinner swapped in for tonight is still
reminded 15 minutes before its `start_by`, not at the next hourly timer reminded 15 minutes before its `start_by`, not at the next hourly timer
(`maxWait`); a lookup that fails is logged and repeated every (`maxWait`); a lookup that fails is logged and repeated every
`ClaimRetry`. `ClaimRetry`.
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
names the coming week. Its run calls `Autosend(period.Week)`, which posts
with `SendToChat(week, false)`, the household's own send, only when a
webhook is stored, `list_ready`'s Chat channel is on
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
nor with a send unconfirmed; otherwise it posts nothing and logs why,
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
fails is the period's error. `shopping.Service.RolloverSchedule()` is
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
the fire date's, and a week with no Supply run freezes nothing and logs
it. `harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
`Daily(03:00)`, deleting rows created more than 30 days before the fire
time.
Fake-clock tests of a schedule live in `internal/scheduler`, where Fake-clock tests of a schedule live in `internal/scheduler`, where
`SetClock` is visible to tests. `SetClock` is visible to tests.
@@ -1140,7 +1155,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
request. 404 when the week has no Supply run, 409 request. 404 when the week has no Supply run, 409
`archived` when it is in the Supply archive, 400 `invalid_request` for a `archived` when it is in the Supply archive, 400 `invalid_request` for a
bad body. `shopping.SendToChat(weekStart, resend)` is what bad body. `shopping.SendToChat(weekStart, resend)` is what
`shopping_chat_autosend` calls (Feature 20). `shopping_chat_autosend` calls, through `Autosend`.
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required - `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
boolean; it sets the flag, it does not flip it) and answers `{id, boolean; it sets the flag, it does not flip it) and answers `{id,
needed, needed_count, total_count}` with the list's counts after the needed, needed_count, total_count}` with the list's counts after the
+2 -5
View File
@@ -34,8 +34,8 @@ const (
StatusProviderError RunStatus = "provider_error" StatusProviderError RunStatus = "provider_error"
) )
// RunRetention is how long ai_runs rows are kept. The daily purge calls // RunRetention is how long ai_runs rows are kept. The daily purge,
// Purge with time.Now().Add(-RunRetention). // PurgeSchedule, calls Purge with its fire time less RunRetention.
const RunRetention = 30 * 24 * time.Hour const RunRetention = 30 * 24 * time.Hour
// runSaveTimeout bounds writing the ai_runs row of a run that has failed, // runSaveTimeout bounds writing the ai_runs row of a run that has failed,
@@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second
// Purge deletes the ai_runs rows created before olderThan and returns how // Purge deletes the ai_runs rows created before olderThan and returns how
// many it deleted. The suggestion_batches and week_plans references to // many it deleted. The suggestion_batches and week_plans references to
// those rows are cleared first, in the same transaction. // those rows are cleared first, in the same transaction.
//
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
// (Feature 20) must call it with time.Now().Add(-RunRetention).
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) { func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
if olderThan.IsZero() { if olderThan.IsZero() {
return 0, errors.New("harness: purge ai_runs: zero cutoff") return 0, errors.New("harness: purge ai_runs: zero cutoff")
+46
View File
@@ -0,0 +1,46 @@
package harness
import (
"context"
"errors"
"log/slog"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
// It is fixed, not a setting.
const PurgeTime = "03:00"
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
// PurgeTime it deletes the ai_runs rows created more than RunRetention
// before the fire time with Purge, its period scheduler.Daily keyed
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
// than RunRetention; one that fails is the period's error and is not
// retried, and the next day's purge deletes what it left.
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
if st == nil {
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
}
if logger == nil {
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
}
return scheduler.Schedule{
Name: scheduler.AIRunsPurge,
Rule: func(household.Settings) scheduler.Rule {
return scheduler.Daily(PurgeTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
cutoff := p.FireAt.Add(-RunRetention)
n, err := Purge(ctx, st, cutoff)
if err != nil {
return err
}
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
return nil
},
}, nil
}
+14
View File
@@ -27,6 +27,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/url" "net/url"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -238,6 +239,19 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun
return nil return nil
} }
// ChannelOn reports whether the preference of event type ev switches
// channel ch on. A type with no preference row is an error.
func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) {
pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev))
if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("notify: no notification preference for %s", ev)
}
if err != nil {
return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err)
}
return slices.Contains(enabledChannels(pref), ch), nil
}
// enabledChannels returns the channels pref switches on. // enabledChannels returns the channels pref switches on.
func enabledChannels(pref db.NotificationPref) []Channel { func enabledChannels(pref db.NotificationPref) []Channel {
var chs []Channel var chs []Channel
@@ -0,0 +1,420 @@
package scheduler_test
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"log/slog"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run shopping's shopping_chat_autosend and
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
// against the real shopping service, Google Chat client (posting to a fake
// webhook), notification preferences and ai_runs purge. The seeded
// autosend is Saturday 08:00 in Chicago.
// w41 is the Monday of 2026-W41.
var w41 = mustLocalDate("2026-10-05")
// shopEnv is a shopping service over a scheduler env's store. Its job
// runner never starts, so Generate's title jobs stay queued and every
// Supply run stays untitled until Freeze titles it, which fails.
type shopEnv struct {
e *env
svc *shopping.Service
chat *chattest.Chat
cs *chat.Service
plans *planEnv
logs *syncBuffer
}
// shopping builds a shopping service over e, with Regulars to plan from.
func (e *env) shopping() *shopEnv {
e.t.Helper()
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
e.t.Fatal(err)
}
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
if err != nil {
e.t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no AI in this test")
}
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
if err != nil {
e.t.Fatal(err)
}
return s
}
// list plans the week starting weekStart and generates its Supply run.
func (s *shopEnv) list(weekStart store.LocalDate) {
s.e.t.Helper()
s.plans.planWeek(weekStart)
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
s.e.t.Fatal(err)
}
}
// webhook stores the fake Google Chat webhook.
func (s *shopEnv) webhook() {
s.e.t.Helper()
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
s.e.t.Fatal(err)
}
}
// get returns the Supply run of the week starting weekStart.
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
s.e.t.Helper()
l, err := s.svc.Get(context.Background(), weekStart)
if err != nil {
s.e.t.Fatal(err)
}
return l
}
// wantLogged fails unless the service logged want and logged no error.
func (s *shopEnv) wantLogged(want string) {
s.e.t.Helper()
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
}
}
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
// schedule posts the coming week's Supply run, W43's, to Google Chat
// through SendToChat, in that week's thread, and records sent_to_chat_at;
// its period is shopping_chat_autosend:2026-W43. It posts once.
func TestChatAutosendSendsComingWeek(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
// Saturday 07:00: the previous Saturday's period is past the
// catch-up window, so it is recorded as missed.
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
r.runUntil(at(2026, 10, 17, 7, 59))
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
}
r.runUntil(at(2026, 10, 17, 8, 0))
posts := s.chat.Posts()
if len(posts) != 1 {
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
}
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
}
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
}
if s.get(w43).SentToChatAt == nil {
t.Error("sent_to_chat_at not recorded")
}
s.wantLogged("Supply run sent to Google Chat")
s.wantLogged("key=list_ready:chat:2026-10-19")
r.runUntil(at(2026, 10, 24, 7, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("a week on: %d posts, want 1", n)
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"})
}
// TestChatAutosendFollowsSettings: chat_autosend_dow and
// chat_autosend_time move the send without a restart, here to Friday
// 18:00, still for the coming week.
func TestChatAutosendFollowsSettings(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
r.changed()
r.runUntil(at(2026, 10, 16, 17, 59))
if n := len(s.chat.Posts()); n != 0 {
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
}
r.runUntil(at(2026, 10, 16, 18, 0))
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
}
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("Saturday: %d posts, want still 1", n)
}
}
// TestChatAutosendSkips: the schedule posts nothing, records no send and
// fails nothing when the coming week's Supply run was already sent, when
// the week has no Supply run, when list_ready is switched off for Google
// Chat, and when no webhook is stored. Each period is still claimed and
// the reason logged.
func TestChatAutosendSkips(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, s *shopEnv)
// posts is how many posts the setup itself made.
posts int
reason string
}{
{
name: "already sent",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
s.webhook()
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
},
posts: 1,
reason: shopping.AutosendAlreadySent,
},
{
name: "no list",
setup: func(t *testing.T, s *shopEnv) {
s.list(w42)
s.webhook()
},
reason: shopping.AutosendNoList,
},
{
name: "chat off for list_ready",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
s.webhook()
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
t.Fatal(err)
}
},
reason: shopping.AutosendChatOff,
},
{
name: "no webhook",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
},
reason: shopping.AutosendNotConfigured,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
s := e.shopping()
c.setup(t, s)
var sentBefore *string
if l, err := s.svc.Get(context.Background(), w43); err == nil {
sentBefore = l.SentToChatAt
}
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != c.posts {
t.Errorf("posts = %d, want %d", n, c.posts)
}
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
}
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"})
})
}
}
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
// period's error, logged as such; the period stays claimed and is not
// retried, and sent_to_chat_at is not recorded.
func TestChatAutosendFailureSurfaces(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
s.chat.SetStatus(500)
var logs syncBuffer
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
if err != nil {
t.Fatal(err)
}
clock := newFakeClock(at(2026, 10, 17, 7, 0))
scheduler.SetClock(sched, clock.Now, clock.After)
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
sched.Wait()
})
if err := sched.Start(ctx); err != nil {
t.Fatal(err)
}
r := &running{t: t, clock: clock, stop: cancel}
r.idle()
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("posts = %d, want the one refused", n)
}
if s.get(w43).SentToChatAt != nil {
t.Error("sent_to_chat_at recorded for a refused send")
}
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"2026-W42", "2026-W43"})
}
func equalPtr(a, b *string) bool {
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
}
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
// freezes exactly the week that has just ended into the Supply archive,
// not the week before it nor the new week; the next Monday freezes the
// next week; a Monday whose previous week has no Supply run freezes
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
// Monday.
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w41)
s.list(w42)
s.list(w43)
status := func() []string {
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
}
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
// Sunday 23:00: last Monday's rollover is past the catch-up window and
// recorded as missed, so W41 is never frozen by it.
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
r.runUntil(at(2026, 10, 19, 0, 4))
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
r.runUntil(at(2026, 10, 19, 0, 5))
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
if s.get(w42).Title != nil {
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
}
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
}
r.runUntil(at(2026, 10, 26, 0, 5))
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
r.runUntil(at(2026, 11, 2, 0, 5))
s.wantLogged("week rollover froze nothing: the week has no Supply run")
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
}
// aiRun stores an ai_runs row created at created and returns its id.
func (e *env) aiRun(created time.Time) string {
e.t.Helper()
id := store.NewID()
ts := store.Timestamp{Time: created.UTC()}.String()
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
CreatedAt: ts, UpdatedAt: ts,
}); err != nil {
e.t.Fatal(err)
}
return id
}
// aiRuns returns the ids of the ai_runs rows, oldest first.
func (e *env) aiRuns() []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
e.t.Fatal(err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return ids
}
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
// ai_runs rows created more than 30 days before, and no younger row; its
// periods are ai_runs_purge:YYYY-MM-DD.
func TestAIRunsPurgeDaily(t *testing.T) {
e := newEnv(t)
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
edge := e.aiRun(at(2026, 9, 12, 2, 59))
young := e.aiRun(at(2026, 9, 12, 3, 1))
recent := e.aiRun(at(2026, 10, 11, 12, 0))
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
if err != nil {
t.Fatal(err)
}
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
r := e.start(at(2026, 10, 12, 2, 0), sch)
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
r.runUntil(at(2026, 10, 12, 2, 59))
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
r.runUntil(at(2026, 10, 12, 3, 0))
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
r.runUntil(at(2026, 10, 13, 3, 0))
wantKeys(t, "next day", e.aiRuns(), []string{recent})
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
}
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
// logger.
func TestPurgeScheduleRejectsNil(t *testing.T) {
e := newEnv(t)
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
t.Error("nil store accepted")
}
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
t.Error("nil logger accepted")
}
}
+17 -4
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -39,11 +40,17 @@ const (
// only when no job is running it. A job that runs after the freeze finds // only when no job is running it. A job that runs after the freeze finds
// the list titled, or frozen untitled, and stores nothing. // the list titled, or frozen untitled, and stores nothing.
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
return s.freeze(ctx, weekStart, s.now())
}
// freeze is Freeze with the week ended by instant now rather than by the
// service's clock.
func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error {
if _, err := household.WeekDays(weekStart); err != nil { if _, err := household.WeekDays(weekStart); err != nil {
return fmt.Errorf("shopping: freeze: %w", err) return fmt.Errorf("shopping: freeze: %w", err)
} }
q := db.New(s.st.Writer) q := db.New(s.st.Writer)
thisWeek, err := s.thisWeek(ctx, q) thisWeek, err := weekAt(ctx, q, now)
if err != nil { if err != nil {
return err return err
} }
@@ -73,9 +80,9 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
} }
} }
// A list frozen meanwhile is left as it is: the freeze is done. // A list frozen meanwhile is left as it is: the freeze is done.
now := store.Now().String() stamp := store.Now().String()
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID, FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
}); err != nil { }); err != nil {
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
} }
@@ -103,6 +110,12 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T
// thisWeek returns the Monday of the household's current week, in its // thisWeek returns the Monday of the household's current week, in its
// time zone, by the service's clock. // time zone, by the service's clock.
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) { func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
return weekAt(ctx, q, s.now())
}
// weekAt returns the Monday of the household's week at instant t, in its
// time zone.
func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) {
settings, err := q.GetHouseholdSettings(ctx) settings, err := q.GetHouseholdSettings(ctx)
if err != nil { if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err) return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
@@ -111,7 +124,7 @@ func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate,
if err != nil { if err != nil {
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err) return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
} }
return household.WeekStart(household.Today(s.now(), zone)), nil return household.WeekStart(household.Today(t, zone)), nil
} }
// Period is one heading of the Supply archive and its posts, newest first. // Period is one heading of the Supply archive and its posts, newest first.
+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
},
}
}