diff --git a/cmd/server/main.go b/cmd/server/main.go index 081e0e6..1df7bc5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -204,6 +204,19 @@ func run(cfg config.Config, logger *slog.Logger) error { 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()) if err != nil { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 895bd43..2e87898 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -537,8 +537,8 @@ automatic** (D-021). received. `harness.Purge` deletes rows older than `harness.RunRetention` (30 days), clearing the `suggestion_batches` and `week_plans` references first. The - harness does not call it: the scheduler's daily `ai_runs` purge - (Feature 20) must call `harness.Purge` with `now - RunRetention`. + daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with + its fire time less `RunRetention`. - **Concurrency guards** (`harness.Guard`, one per process): `harness.Run` 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 @@ -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 waits for it, then finds the list titled (that title stands) or frozen 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 @@ -781,11 +782,11 @@ purge are fixed and not configurable. | --- | --- | --- | | `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`). | -| `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`). | | `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. | -| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). | +| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). | +| `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`. @@ -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 (`maxWait`); a lookup that fails is logged and repeated every `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 `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 `archived` when it is in the Supply archive, 400 `invalid_request` for a 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 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 diff --git a/internal/ai/harness/runs.go b/internal/ai/harness/runs.go index 5bf2e86..270aba4 100644 --- a/internal/ai/harness/runs.go +++ b/internal/ai/harness/runs.go @@ -34,8 +34,8 @@ const ( StatusProviderError RunStatus = "provider_error" ) -// RunRetention is how long ai_runs rows are kept. The daily purge calls -// Purge with time.Now().Add(-RunRetention). +// RunRetention is how long ai_runs rows are kept. The daily purge, +// PurgeSchedule, calls Purge with its fire time less RunRetention. const RunRetention = 30 * 24 * time.Hour // 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 // many it deleted. The suggestion_batches and week_plans references to // 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) { if olderThan.IsZero() { return 0, errors.New("harness: purge ai_runs: zero cutoff") diff --git a/internal/ai/harness/schedule.go b/internal/ai/harness/schedule.go new file mode 100644 index 0000000..9bd5c80 --- /dev/null +++ b/internal/ai/harness/schedule.go @@ -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 +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 4254e5b..018706c 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -27,6 +27,7 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strings" "sync" "time" @@ -238,6 +239,19 @@ func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels fun 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. func enabledChannels(pref db.NotificationPref) []Channel { var chs []Channel diff --git a/internal/scheduler/weekly_automation_test.go b/internal/scheduler/weekly_automation_test.go new file mode 100644 index 0000000..b21073c --- /dev/null +++ b/internal/scheduler/weekly_automation_test.go @@ -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") + } +} diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go index c87ad63..a24d2dc 100644 --- a/internal/shopping/archive.go +++ b/internal/shopping/archive.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "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 // the list titled, or frozen untitled, and stores nothing. 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 { return fmt.Errorf("shopping: freeze: %w", err) } q := db.New(s.st.Writer) - thisWeek, err := s.thisWeek(ctx, q) + thisWeek, err := weekAt(ctx, q, now) if err != nil { 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. - now := store.Now().String() + stamp := store.Now().String() 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 { 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 // time zone, by the service's clock. 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) if err != nil { 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 { 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. diff --git a/internal/shopping/schedule.go b/internal/shopping/schedule.go new file mode 100644 index 0000000..e531621 --- /dev/null +++ b/internal/shopping/schedule.go @@ -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 + }, + } +}