Plan draft schedule (Feature 20) #175

5 changed files with 475 additions and 8 deletions
+3
View File
@@ -195,6 +195,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil {
return err
}
if err := sched.Register(planSvc.DraftSchedule()); 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 {
+12 -3
View File
@@ -780,7 +780,7 @@ purge are fixed and not configurable.
| Schedule | When | Does |
| --- | --- | --- |
| `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_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. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
@@ -814,6 +814,14 @@ Its run calls `StartScheduledBatch(period.Week)`, which starts the same
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
batch is already pending or running the period starts none and logs it.
`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week
after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls
`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week`
job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored
draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
has a plan, in any status, it enqueues nothing; when a draft of the week
is already queued or running it joins it. Either way it logs it.
Fake-clock tests of a schedule live in `internal/scheduler`, where
`SetClock` is visible to tests.
@@ -825,8 +833,9 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
- Where each event is emitted, with its idempotency key. Each is stored by
`notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all:
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
AI's draft of a week. Only the first draft of a week notifies: a
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job, asked for
by the API or the `plan_draft` schedule, stores the AI's draft of a
week. Only the first draft of a week notifies: a
redraft (Rebuild week) has the same key, and a manual swap never
drafts. Its title is "Next week's plan is ready" (or "This week's",
or "The plan for the week of Oct 26") and its body the drafted
+25 -5
View File
@@ -152,8 +152,8 @@ type Service struct {
// cal with links under publicURL, tells the household through notifier,
// which must be over st, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
// StartDraft and StartScheduledDraft, which keep one draft in flight per
// week, are the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
@@ -342,10 +342,27 @@ type Draft struct {
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
// else it fails with ErrConfirmRequired and enqueues nothing.
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
d, _, err := s.startDraft(ctx, weekStart, confirm, false)
return d, err
}
// StartScheduledDraft is StartDraft, without confirm, for the plan_draft
// schedule: it enqueues the same KindDraftWeek job, or returns the one
// queued or running, only while the week starting weekStart has no plan.
// A week that has a plan, in any status, is left as it is: nothing is
// enqueued and planned is true.
func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) {
return s.startDraft(ctx, weekStart, false, true)
}
// startDraft is StartDraft, and when onlyUnplanned is set it enqueues
// nothing for a week that has a plan and reports planned true.
func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
return Draft{}, false, &InvalidWeekError{Value: weekStart.String()}
}
var d Draft
planned := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -353,6 +370,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case onlyUnplanned:
planned = true
return nil
case cur.Status != StatusDraft && !confirm:
return ErrConfirmRequired
}
@@ -375,12 +395,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
return nil
})
if err != nil {
return Draft{}, err
return Draft{}, false, err
}
if d.Created {
s.draftJob.Wake()
}
return d, nil
return d, planned, nil
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
+42
View File
@@ -0,0 +1,42 @@
package plan
import (
"context"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// DraftSchedule is the plan_draft schedule: at the household's
// plan_draft_dow and plan_draft_time it drafts the week after the fire
// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www
// for that week. The draft is the KindDraftWeek job a manual draft runs
// (StartScheduledDraft), so a stored draft emits plan_ready keyed
// plan_ready:{weekStart} and a draft that fails, as when the AI fails,
// stores no plan and emits nothing. A week that already has a plan is left
// as it is, and so is a draft of it already queued or running; either way
// the period says so.
func (s *Service) DraftSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.PlanDraft,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
d, planned, err := s.StartScheduledDraft(ctx, p.Week)
if err != nil {
return err
}
switch {
case planned:
s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan",
"period", p.Key, "week_start", p.Week.String())
case !d.Created:
s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way",
"period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID)
}
return nil
},
}
}
+393
View File
@@ -0,0 +1,393 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run plan's plan_draft schedule on the fake clock
// against the real plan service, job runner, harness, draft_week and
// notification outbox, with the AI faked.
// planEnv is a plan service over a scheduler env's store, the "process" a
// scheduler runs it in. model answers draft_week.
type planEnv struct {
e *env
svc *plan.Service
model *llm.Fake
ids map[string]string
logs *syncBuffer
stop func()
}
// poolNames are the seven Regulars pool stores, enough for a week.
var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"}
// pool stores poolNames as Regulars and returns their ids by name.
func (e *env) pool() map[string]string {
e.t.Helper()
ids := map[string]string{}
for _, name := range poolNames {
now := store.Now().String()
d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
e.t.Fatal(err)
}
ids[name] = d.ID
}
return ids
}
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
// noCalendar is a Calendar the schedule must never push to.
type noCalendar struct{ t *testing.T }
func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
c.t.Error("plan_draft pushed to Calendar")
return errors.New("no calendar")
}
// plans builds a plan service drafting from the dinners ids over e with a
// started job runner, which stop stops (and the test's end, if not before).
func (e *env) plans(ids map[string]string) *planEnv {
e.t.Helper()
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil },
Store: e.st,
Guard: guard,
}
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
return skills.DraftWeek(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(p.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), publicURL, logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
p.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(p.stop)
return p
}
// reply scripts the next draft_week answer: poolNames on the days of the
// week starting weekStart, Monday to Sunday.
func (p *planEnv) reply(weekStart store.LocalDate) {
p.e.t.Helper()
dates, err := household.WeekDays(weekStart)
if err != nil {
p.e.t.Fatal(err)
}
var days []string
for i, name := range poolNames {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ","))
p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// draftJob is a draft_week jobs row.
type draftJob struct {
Status, Error string
}
// drafts waits until no draft_week job is queued or running and returns
// them all, oldest first.
func (e *env) drafts() []draftJob {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek)
if err != nil {
e.t.Fatal(err)
}
var out []draftJob
busy := false
for rows.Next() {
var j draftJob
if err := rows.Scan(&j.Status, &j.Error); err != nil {
e.t.Fatal(err)
}
busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning)
out = append(out, j)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
rows.Close()
if !busy {
return out
}
if time.Now().After(deadline) {
e.t.Fatalf("draft jobs still under way: %+v", out)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantDrafts fails unless the draft_week jobs are got.
func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want)
}
}
// weekPlans returns the week_start of every plan, oldest first.
func (e *env) weekPlans() []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var w string
if err := rows.Scan(&w); err != nil {
e.t.Fatal(err)
}
out = append(out, w)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// planReady returns the distinct plan_ready keys stored, oldest first.
func (e *env) planReady() []string {
e.t.Helper()
var keys []string
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
if len(keys) == 0 || keys[len(keys)-1] != r.Key {
keys = append(keys, r.Key)
}
}
return keys
}
var (
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
// w43 is the Monday of 2026-W43, the week after Friday Oct 16.
w43 = mustLocalDate("2026-10-19")
w44 = mustLocalDate("2026-10-26")
)
func mustLocalDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule
// drafts the next ISO week through the plan service's draft job, period
// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed
// by its Monday once.
func TestPlanDraftDraftsNextWeek(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
p.reply(w44)
// Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed.
r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 8, 59))
wantDrafts(t, "before Friday 09:00", e.drafts())
wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil)
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
got, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft {
t.Errorf("plan of W43 = %+v, want the drafted week", got)
}
r.runUntil(at(2026, 10, 23, 9, 0))
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
}
// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan,
// here one the household drafted by hand, the period drafts nothing,
// notifies no one and says so.
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
p.reply(w43) // a second draft would succeed too
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
wantDrafts(t, "manual draft", e.drafts(), succeeded)
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
before, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "with a plan", e.drafts(), succeeded)
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
after, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(after) != fmt.Sprint(before) {
t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before)
}
if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was
// drafted, still within the catch-up window, drafts nothing more and sends
// no second plan_ready: the period is already claimed.
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.reply(w43)
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "first process", e.drafts(), succeeded)
r.stop()
p.stop()
// The restarted process has a new job runner, plan service and
// scheduler; it would answer a second draft if one were started.
p2 := e.plans(ids)
p2.reply(w43)
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
r2.runUntil(at(2026, 10, 23, 8, 59))
wantDrafts(t, "after restart", e.drafts(), succeeded)
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job
// fails as ai_failed with the failure recorded in ai_runs, no plan is made
// up and no one is notified; a restart within the catch-up window does not
// draft the claimed period again.
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of draft_week: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) {
t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed)
}
r.stop()
p.stop()
p2 := e.plans(ids)
p2.reply(w43)
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
wantDrafts(t, "after restart", e.drafts(), aiFailed)
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
}
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
// plan_draft_time change takes effect in the running scheduler; the old
// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next
// week.
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")})
r.changed()
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantDrafts(t, "before Wednesday 18:30", e.drafts())
r.runUntil(at(2026, 10, 14, 18, 30))
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
// Friday 09:00, the old fire time, drafts nothing more.
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}