Compare commits

..
Author SHA1 Message Date
dev_agent b1a7fc4403 Catch up every period within 24 hours, oldest first (F20-01) (#184)
CI / ci (pull_request) Successful in 8m9s
CI / ci (push) Successful in 8m6s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 18:53:15 -04:00
dev_agent 031b39f9a7 Merge remote-tracking branch 'origin/dev' into HEAD
CI / ci (pull_request) Successful in 8m8s
CI / ci (push) Successful in 8m52s
2026-10-10 17:40:12 -04:00
dev_agent b94ce125dd Rotation check schedule (Feature 20) (#180)
CI / ci (push) Successful in 8m22s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 17:39:55 -04:00
dev_agent 54a55bf5c3 Chat autosend, week rollover and retention schedules (Feature 20) (#178)
CI / ci (push) Successful in 8m46s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 16:43:21 -04:00
orchestrator-agent 4dcfc3c27d Withhold unsafe recipe sources from idea job results (BASE-01) (#170)
CI / ci (push) Failing after 6m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 19:04:44 -04:00
19 changed files with 775 additions and 165 deletions
+3
View File
@@ -204,6 +204,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
}
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
return err
}
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
return err
}
+19 -11
View File
@@ -484,15 +484,13 @@ automatic** (D-021).
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). This release provides the
`MarkTried` service method and the rotation review read and decide APIs
below. The `rotation_check` schedule that is to call `MarkTried` for the
household-local day just past is planned (Feature 20, the scheduler) and
not wired yet: nothing in production calls `MarkTried`, so no Rotation
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
a dinner already checked for that date is left as it is, so it is
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
calls `MarkTried` for the household-local day just past (see Scheduler).
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
emits its `rotation_review`, in one transaction;
a dinner already checked for that date is left as it is and not
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
pending checks whose dinner is still tried, oldest `plan_date` first,
each `{dinner: {id, name, photo}, plan_date}`. `POST
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
@@ -784,7 +782,7 @@ purge are fixed and not configurable.
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
| `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. |
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
| `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`). |
@@ -852,7 +850,13 @@ 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`,
it. `plan.Service.RotationCheckSchedule()` is `rotation_check`,
`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire
date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before
the fire date, which emits one `rotation_review` per Rotation check it
opens; it never makes a dinner a Regular, and a day with no Quest log
dinner planned opens none and logs the count.
`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
@@ -899,6 +903,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
{time}" with the dinner's name, only for a crock-pot dinner whose
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
schedule calls it 15 minutes before `start_by`.
- `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card
asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}?
Keep it in rotation?", once per Rotation check it opens. The
`rotation_check` schedule calls it at `review_time` for yesterday.
- Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook.
+1 -1
View File
@@ -308,7 +308,7 @@ func TestSendThreadsByWeek(t *testing.T) {
ListReady(week, 19, 7),
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
RotationReview("01J9DINNER", "Pho", week),
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
}
for _, ev := range events {
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
+8 -8
View File
@@ -107,17 +107,17 @@ func NewSuggestions(id string, week store.LocalDate, names []string) notify.Even
}
}
// RotationReview is the rotation_review event asking whether the dinner
// dinnerID, named name and tried in the week starting on week, becomes a
// Regular.
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
// RotationReview is the rotation_review event, keyed
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
// in rotation?". It opens Home, where the Rotation check card asks.
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
return notify.Event{
Type: notify.RotationReview,
Key: string(notify.RotationReview) + ":" + dinnerID,
Title: "Keep it in rotation?",
Body: "You tried " + name + ". Make it a Regular?",
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
Title: "How was " + name + "? Keep it in rotation?",
URL: "/",
Week: week,
Week: household.WeekStart(planDate),
}
}
+5 -5
View File
@@ -137,13 +137,13 @@ func TestMessages(t *testing.T) {
},
{
name: "rotation review",
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
key: "rotation_review:01J9DINNER",
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
key: "rotation_review:01J9DINNER:2026-10-14",
url: "/",
week: "2026-10-12",
title: "Keep it in rotation?",
body: "You tried Lemon orzo. Make it a Regular?",
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
title: "How was Lemon orzo? Keep it in rotation?",
body: "",
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
},
} {
ev := tc.ev
+23 -13
View File
@@ -147,11 +147,11 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
return fmt.Errorf("recipe %s: %w", recipeID, err)
}
if !IsManualRecipe(rec.SourceUrl) {
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
src, err := SourceOf(rec.SiteName, rec.SourceUrl)
if err != nil {
return err
}
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
d.Source = &src
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
@@ -182,7 +182,22 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
return nil
}
// pageSiteName is the stored site name of a recipe fetched from sourceURL
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
// site name is siteName: the name only when the page gave it (see
// pageSiteName) and the link only when it is free of secrets (see
// safeSourceURL). It is the one way a fetched recipe's source reaches a
// client. A sourceURL that does not parse is an error, whether or not a
// site name is stored.
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return Source{}, errors.New("source url does not parse")
}
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
}
// pageSiteName is the stored site name of a recipe fetched from link
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
@@ -192,23 +207,18 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its name with them: a credit that does not
// name the site is better than one that leaks a link.
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
func pageSiteName(stored sql.NullString, link *url.URL) *string {
if !stored.Valid {
return nil, nil
}
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return nil, errors.New("source url does not parse")
return nil
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil, nil
return nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil, nil
return nil
}
return &name, nil
return &name
}
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
+9 -6
View File
@@ -99,16 +99,19 @@ func TestPageSiteNameWithholdsHosts(t *testing.T) {
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
} {
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
if err != nil {
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
}
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
}
}
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
// A link that does not parse is an error with or without a site name.
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
}
}
}
-3
View File
@@ -72,8 +72,5 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
return r.GroundingStatus, err
}
// PageSiteName is pageSiteName.
var PageSiteName = pageSiteName
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
+121 -36
View File
@@ -11,6 +11,7 @@ package ideas
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -123,18 +124,18 @@ func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator,
}
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
var err error
if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil {
if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil {
return nil, err
}
if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil {
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
return nil, err
}
return s, nil
}
// register registers the service-only job name, run by run, whose failed
// jobs show their result through failed.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
// register registers the service-only job name, run by run, whose jobs
// show their result through show.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
kind, err := jobs.Register(runner, name, run, Reasons...)
if err != nil {
return nil, err
@@ -142,7 +143,7 @@ func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.H
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
if err := kind.FailedResult(failed); err != nil {
if err := kind.ShownResult(show); err != nil {
return nil, err
}
return kind, nil
@@ -197,11 +198,13 @@ type Idea struct {
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Quick says whether the idea is a Quick dinner (see IsQuick).
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
// SourceURL and SiteName are the grounded recipe's source as
// dinners.SourceOf gives it: each null when it could leak a secret.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// Result is the KindFromIngredients result, published as the job goes:
@@ -251,6 +254,9 @@ type shownResult[R any] interface {
shown() int
// cleared is the result showing no ideas.
cleared() R
// withSafeSources is the result with each idea's source as
// shownSource gives it.
withSafeSources() (R, error)
}
func (r Result) shown() int { return len(r.Ideas) }
@@ -260,11 +266,25 @@ func (r Result) cleared() Result {
return r
}
func (r Result) withSafeSources() (Result, error) {
ideas := make([]Idea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// finish ends a job whose work returned res and err. A job that failed
// has its result published again with no ideas, to tidy the stored
// result. That write is not what keeps a failed job from showing ideas,
// since it may fail and a job interrupted by restart never makes it:
// withoutIdeas, applied whenever a failed job is read, is.
// shownJobResult, applied whenever a failed job is read, is.
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
var zero R
if err == nil {
@@ -312,9 +332,9 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) {
idea := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool, error) {
idea, err := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick, err
}, func() error { return publish(res) })
if err != nil {
return res, err
@@ -333,9 +353,9 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
// order (an idea that grounds sooner than one before it is shown first,
// and the earlier one is put back in its place when it grounds) and
// publish is called. An idea no recipe grounds, or that keep refuses,
// counts in *dropped. Any other grounding failure, or publish failing, is
// returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error {
// counts in *dropped. Any other grounding failure, or keep or publish
// failing, is returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool, error), publish func() error) error {
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
// its place in reqs' order as it grounds.
var order []int
@@ -348,7 +368,10 @@ func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipe
*dropped++
return nil
}
idea, ok := keep(first+i, *gr)
idea, ok, err := keep(first+i, *gr)
if err != nil {
return err
}
if !ok {
*dropped++
return nil
@@ -406,19 +429,32 @@ func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundReque
return failed
}
// withoutIdeas is a failed job's stored result as the job shows it: with no
// ideas, however the job failed, so a failed job never shows ideas kept
// before the failure.
func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) {
// shownJobResult is a job's stored result as the job shows it. A failed
// job's shows no ideas, however the job failed, so a failed job never
// shows ideas kept before the failure. Any other job's shows each idea's
// source as shownSource gives it, so a result stored before sources were
// made safe never shows a link or name that could leak a secret.
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
var res R
if err := json.Unmarshal(stored, &res); err != nil {
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
}
return res.cleared().PublicJSON()
if status == jobs.StatusFailed {
return res.cleared().PublicJSON()
}
safe, err := res.withSafeSources()
if err != nil {
return nil, err
}
return safe.PublicJSON()
}
// ideaOf is idea as grounded by gr.
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
src, err := sourceOf(gr)
if err != nil {
return Idea{}, err
}
return Idea{
Name: idea.Name,
Uses: idea.Uses,
@@ -430,9 +466,38 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
Quick: IsQuick(gr.Method, gr.MinutesTotal),
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: gr.SourceURL,
SiteName: gr.SiteName,
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// sourceOf is gr's recipe source as a client may see it (see
// dinners.SourceOf): a grounded recipe's site name is stored only when
// not empty.
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
if err != nil {
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
}
return src, nil
}
// shownSource is the source of an idea as stored in a job's result, with
// link sourceURL and site name siteName, as a client may see it (see
// dinners.SourceOf). A result stored now already holds it, and one stored
// before sources were made safe holds the grounded recipe's own, with ""
// for no site name, as sourceOf reads it; a null link, which only a result
// stored now holds, is checked as "".
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
var link string
if sourceURL != nil {
link = *sourceURL
}
var site sql.NullString
if siteName != nil {
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
}
return dinners.SourceOf(site, link)
}
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
@@ -478,8 +543,9 @@ type QuickIdea struct {
Difficulty string `json:"difficulty"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
// SourceURL and SiteName are as in Idea.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// QuickResult is the KindQuick result, published as the job goes: its
@@ -504,6 +570,20 @@ func (r QuickResult) cleared() QuickResult {
return r
}
func (r QuickResult) withSafeSources() (QuickResult, error) {
ideas := make([]QuickIdea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
// cooked in a crock pot (normalize_recipe writes a slow cooker as
@@ -564,8 +644,9 @@ func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickR
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) {
return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal)
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool, error) {
idea, err := quickIdeaOf(out.Ideas[i], gr)
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
}, func() error { return publish(res) })
if err != nil {
return res, err
@@ -578,7 +659,11 @@ func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickR
}
// quickIdeaOf is idea as grounded by gr.
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) {
src, err := sourceOf(gr)
if err != nil {
return QuickIdea{}, err
}
return QuickIdea{
Name: idea.Name,
Method: gr.Method,
@@ -586,9 +671,9 @@ func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
Difficulty: gr.Difficulty,
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: gr.SourceURL,
SiteName: gr.SiteName,
}
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// Origins of a dinner made from an idea (the dinners.origin column).
+1 -1
View File
@@ -369,7 +369,7 @@ func TestStartQuickRunsTheJob(t *testing.T) {
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
t.Fatalf("result = %+v", res)
}
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" {
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName == nil || *got.SiteName != "R Kitchen" {
t.Errorf("idea = %+v", got)
}
}
+24 -23
View File
@@ -136,13 +136,13 @@ type runFunc func(ctx context.Context, payload string, publish func(json.RawMess
// handler is a registered kind: run runs a job, check reports whether a
// payload decodes into the kind's payload type, serviceOnly is set when
// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get
// shows of a failed job's stored result (see Kind.FailedResult).
// Runner.Enqueue refuses the kind, and shownResult, when set, is what Get
// shows of a job's stored result (see Kind.ShownResult).
type handler struct {
run runFunc
check func(payload []byte) bool
serviceOnly bool
failedResult func(stored json.RawMessage) (json.RawMessage, error)
run runFunc
check func(payload []byte) bool
serviceOnly bool
shownResult func(status Status, stored json.RawMessage) (json.RawMessage, error)
}
// Runner owns the job handlers and the worker pool.
@@ -309,23 +309,24 @@ func (k *Kind[P]) ServiceOnly() error {
return nil
}
// FailedResult makes Get return, for a failed job of the kind with a
// stored result, show's view of that result instead of the result itself.
// It is applied on every read, so it holds however the job failed: by its
// handler, as interrupted by restart, or after a write meant to tidy the
// result failed. A show error fails Get. A kind that never calls it shows a
// failed job's last result as stored. It must be called before Start.
func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error {
// ShownResult makes Get return, for a job of the kind with a stored
// result, show's view of that result, given the job's status, instead of
// the result itself. It is applied on every read, so it holds for every
// stored result: however a job failed (by its handler, as interrupted by
// restart, or after a write meant to tidy the result failed) and whatever
// version of the kind stored it. A show error fails Get. A kind that never
// calls it shows a job's result as stored. It must be called before Start.
func (k *Kind[P]) ShownResult(show func(status Status, stored json.RawMessage) (json.RawMessage, error)) error {
if show == nil {
return fmt.Errorf("jobs: failed result %q: nil func", k.name)
return fmt.Errorf("jobs: shown result %q: nil func", k.name)
}
k.r.mu.Lock()
defer k.r.mu.Unlock()
if k.r.started {
return fmt.Errorf("jobs: failed result %q: runner already started", k.name)
return fmt.Errorf("jobs: shown result %q: runner already started", k.name)
}
h := k.r.handlers[k.name]
h.failedResult = show
h.shownResult = show
k.r.handlers[k.name] = h
return nil
}
@@ -425,8 +426,8 @@ func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound. A failed job's result is
// shown through its kind's FailedResult, when the kind set one.
// Get returns the job with id, or ErrNotFound. A job's result is shown
// through its kind's ShownResult, when the kind set one.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
@@ -442,17 +443,17 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
if row.Error.Valid {
job.Error = &row.Error.String
}
if job.Status == StatusFailed && job.Result != nil {
if job.Result != nil {
r.mu.Lock()
show := r.handlers[job.Kind].failedResult
show := r.handlers[job.Kind].shownResult
r.mu.Unlock()
if show != nil {
shown, err := show(job.Result)
shown, err := show(job.Status, job.Result)
if err != nil {
return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err)
return Job{}, fmt.Errorf("jobs: show %s %s result: %w", job.Status, job.Kind, err)
}
if !json.Valid(shown) {
return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind)
return Job{}, fmt.Errorf("jobs: show %s %s result: not one JSON value", job.Status, job.Kind)
}
job.Result = shown
}
+18 -10
View File
@@ -773,7 +773,7 @@ func TestServiceOnlyKind(t *testing.T) {
}
}
func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
@@ -788,17 +788,24 @@ func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
}
hidden := register(t, r, "hidden", h, "stopped")
plain := register(t, r, "plain", h, "stopped")
if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) {
return json.RawMessage(`{"done":0}`), nil
if err := hidden.ShownResult(func(status Status, stored json.RawMessage) (json.RawMessage, error) {
if status == StatusFailed {
return json.RawMessage(`{"done":0}`), nil
}
var res countResult
if err := json.Unmarshal(stored, &res); err != nil {
return nil, err
}
return json.Marshal(countResult{Done: res.Done * 10})
}); err != nil {
t.Fatal(err)
}
if err := hidden.FailedResult(nil); err == nil {
t.Error("FailedResult(nil) succeeded, want an error")
if err := hidden.ShownResult(nil); err == nil {
t.Error("ShownResult(nil) succeeded, want an error")
}
start(t, r)
if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
t.Error("FailedResult after Start succeeded, want an error")
if err := hidden.ShownResult(func(_ Status, s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
t.Error("ShownResult after Start succeeded, want an error")
}
for _, tc := range []struct {
@@ -806,9 +813,10 @@ func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
steps int
want string
}{
{hidden, 0, `{"done":0}`}, // failed: shown through FailedResult
{hidden, 3, `{"done":3}`}, // succeeded: as stored
{plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored
{hidden, 0, `{"done":0}`}, // failed: shown through ShownResult
{hidden, 3, `{"done":30}`}, // succeeded: shown through ShownResult
{plain, 0, `{"done":5}`}, // failed, kind without ShownResult: as stored
{plain, 3, `{"done":3}`}, // succeeded, kind without ShownResult: as stored
} {
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
if string(job.Result) != tc.want {
+46 -5
View File
@@ -6,8 +6,11 @@ import (
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"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"
)
@@ -79,12 +82,40 @@ type Decided struct {
UpdatedBy string `json:"updated_by"`
}
// RotationCheckSchedule is the rotation_check schedule: every day at the
// household's review_time it runs MarkTried for the day before, so each
// Quest log dinner planned yesterday is tried and the household is asked
// "Keep it in rotation?". Its period is the fire date, claimed as
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Daily(settings.ReviewTime)
},
PeriodKey: func(p scheduler.Period) string {
return scheduler.RotationCheck + ":" + p.Key
},
Run: func(ctx context.Context, p scheduler.Period) error {
yesterday := addDays(p.Date, -1)
n, err := s.MarkTried(ctx, yesterday)
if err != nil {
return err
}
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
return nil
},
}
}
// MarkTried moves every dinner in the Quest log planned on date, the
// household-local day just past, on a plan of any status, to tried and
// opens a pending Rotation check of it for date. It is idempotent: a
// dinner already checked for date is left as it is, so a later run never
// moves one returned to the Quest log ("We didn't make it") again. It
// returns how many dinners it moved.
// household-local day just past, on a plan of any status, to tried, opens
// a pending Rotation check of it for date and emits its rotation_review
// (chat.RotationReview), all in one transaction. It is idempotent: a
// dinner already checked for date is left as it is and not notified of
// again, so a later run never moves one returned to the Quest log ("We
// didn't make it") again. It returns how many dinners it moved.
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
var n int
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
}
now := store.Now().String()
for _, id := range ids {
d, err := q.GetDinner(ctx, id)
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", id, err)
}
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
if err != nil {
return fmt.Errorf("plan: mark %s tried: %w", id, err)
@@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
}); err != nil {
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
}
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
return fmt.Errorf("plan: %w", err)
}
}
n = len(ids)
return nil
@@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
if err != nil {
return 0, err
}
if n > 0 {
s.notifier.Wake()
}
return n, nil
}
+204
View File
@@ -0,0 +1,204 @@
package scheduler_test
import (
"context"
"testing"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// The tests in this file run plan's rotation_check schedule on the fake
// clock against the real plan service and notification outbox. The plan of
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
// Monday. The household's review time is the seeded 09:00, in Chicago.
// orzoDay is the Monday Lemon Orzo is planned on.
var orzoDay = mustLocalDate("2026-10-12")
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
// place of Beef Chili, and returns its id.
func (p *planEnv) orzoWeek() string {
p.e.t.Helper()
id := p.questLog("Lemon Orzo", 30)
p.planWeek(w42)
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
p.e.t.Fatal(err)
}
return id
}
// rotationReviews returns the keys of the rotation_review notifications
// stored, oldest first. The seeded preference sends rotation_review to push
// only, so there is one row per event.
func (e *env) rotationReviews() []string {
e.t.Helper()
var keys []string
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
if r.Channel != notify.ChannelPush {
e.t.Fatalf("rotation_review row %+v, want push only", r)
}
keys = append(keys, r.Key)
}
return keys
}
// status returns the status of the dinner name.
func (p *planEnv) status(name string) string {
p.e.t.Helper()
var s string
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
p.e.t.Fatal(err)
}
return s
}
// pendingReviews returns the plan dates of the pending Rotation checks of
// the dinner name.
func (p *planEnv) pendingReviews(name string) []string {
p.e.t.Helper()
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
if err != nil {
p.e.t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
p.e.t.Fatal(err)
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
p.e.t.Fatal(err)
}
return out
}
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
// Rotation check for orzoDay and exactly one rotation_review asking about
// it, which opens Home.
func (p *planEnv) wantOrzoTried(id string) {
p.e.t.Helper()
if got := p.status("Lemon Orzo"); got != "tried" {
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
}
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
key := "rotation_review:" + id + ":" + orzoDay.String()
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
if got != want {
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
}
}
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
// after a Quest-log dinner was planned, the schedule, period
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
// emits one rotation_review. It never makes the dinner a Regular.
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 8, 59))
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
if got := p.status("Lemon Orzo"); got != "quest_log" {
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
}
r.runUntil(at(2026, 10, 13, 9, 0))
p.wantOrzoTried(id)
// Days later, still tried: only the household's keep makes a Regular.
r.runUntil(at(2026, 10, 16, 12, 0))
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
})
}
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
// nothing tried, opens no Rotation check and emits nothing.
func TestRotationCheckLeavesRegulars(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.planWeek(w42)
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 9, 0))
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
if got := p.status("Beef Chili"); got != "regular" {
t.Errorf("Beef Chili status = %s, want regular", got)
}
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
// period claimed, and MarkTried run again for the same day finds the
// Rotation check open, so neither opens a second check or notification.
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
id := p.orzoWeek()
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 9, 0))
p.wantOrzoTried(id)
r.stop()
p.stop()
p2 := e.plans(ids)
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
r2.runUntil(at(2026, 10, 13, 23, 59))
p2.wantOrzoTried(id)
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
}
p2.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
// the scheduler runs that day's Rotation check once at boot.
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
}
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
// Rotation check does not run at 09:00 and runs at 10:30.
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
r.changed()
r.runUntil(at(2026, 10, 13, 10, 29))
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
if got := p.status("Lemon Orzo"); got != "quest_log" {
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
}
r.runUntil(at(2026, 10, 13, 10, 30))
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
+59 -41
View File
@@ -15,13 +15,14 @@
//
// # Catch-up
//
// When the scheduler starts, and whenever it wakes late, it looks only at
// each schedule's most recent fire time. If that time passed within
// CatchUpWindow and the period is unclaimed, the period runs once. If it
// passed longer ago, the period is claimed without running and logged as
// missed, so it can never run later, not after a restart and not after a
// settings change moves the fire time. Fire times before the most recent
// one are never looked at.
// When the scheduler starts, and whenever it wakes late, it handles, oldest
// first, every fire time of each schedule that passed within CatchUpWindow,
// and the most recent fire time even when it passed longer ago. Each
// unclaimed period within CatchUpWindow runs once. A most recent one that
// passed longer ago is claimed without running and logged as missed, so it
// can never run later, not after a restart and not after a settings change
// moves the fire time. Other fire times older than CatchUpWindow are never
// looked at.
//
// # Claim failures
//
@@ -71,6 +72,7 @@ import (
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"time"
@@ -316,7 +318,7 @@ func (s *Scheduler) Register(sch Schedule) error {
}
// Start reads the settings and starts the scheduler, which first catches
// up on each schedule's most recent period and then fires schedules as
// up on each schedule's recent periods and then fires schedules as
// their times come. Settings whose zone or rules are invalid are an error
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
// for it.
@@ -496,22 +498,21 @@ func (s *Scheduler) compileChange(ctx context.Context, settings household.Settin
return p
}
// target is one schedule's period, named by the fire time it belongs to
// and the household-local date of its period. A target whose fire time
// could not be looked up has lookup set instead, with the stretch
// (done, until] to look it up in again.
// target is one schedule's periods still to handle, oldest first, each
// named by its fire time and the household-local date of its period. A
// target whose fire times could not be looked up has lookup set instead,
// with the stretch (done, until] to look them up in again.
type target struct {
it item
loc *time.Location
at time.Time
date store.LocalDate
it item
loc *time.Location
fires []fireTime
lookup bool
done, until time.Time
}
// fire handles, for every schedule, its most recent fire time at or before
// until if it is after done; now is the current instant. It returns the
// fire handles, for every schedule, its catch-up fire times after done and
// at or before until; now is the current instant. It returns the
// targets whose lookup or claim failed, to be handled again.
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
var failed []target
@@ -526,21 +527,36 @@ func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Tim
return failed
}
// fireItem handles it's most recent fire time at or before until if it is
// after done. It reports false, with the target to handle again, when the
// fire time could not be looked up or its period's claim failed.
// fireItem handles, oldest first, it's fire times after done and at or
// before until that passed within CatchUpWindow of now, and the most recent
// of them even when it passed longer ago. It reports false, with the target
// to handle again, when the fire times could not be looked up or a period's
// claim failed.
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
f, due, err := fireBefore(ctx, it.rule, loc, until)
fs, err := fireBetween(ctx, it.rule, loc, done, until)
if err != nil {
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
}
if !due || !f.at.After(done) {
return target{}, true
i := 0
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
i++
}
t := target{it: it, loc: loc, at: f.at, date: f.date}
return t, s.handle(ctx, t, now)
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
}
// handleAll handles the periods of t in order, stopping at the first whose
// claim failed. It reports false, with that period and those after it,
// when one did.
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
for i, f := range t.fires {
if !s.handle(ctx, t.it, f, now) {
t.fires = t.fires[i:]
return t, false
}
}
return target{}, true
}
// retry handles targets again at now and returns those whose lookup or
@@ -557,18 +573,18 @@ func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time)
}
continue
}
if !s.handle(ctx, t, now) {
failed = append(failed, t)
if again, ok := s.handleAll(ctx, t, now); !ok {
failed = append(failed, again)
}
}
return failed
}
// handle claims the period of t and runs it, or records it as missed when
// its fire time is more than CatchUpWindow before now. It reports false
// handle claims the period of it's fire time f and runs it, or records it
// as missed when f is more than CatchUpWindow before now. It reports false
// when the claim failed and the period is still unclaimed.
func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool {
it, at, date := t.it, t.at, t.date
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
at, date := f.at, f.date
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
if it.rule.NextWeek {
period.Week = household.WeekStart(addDays(date, 7))
@@ -634,12 +650,12 @@ func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error)
return next, !next.IsZero(), nil
}
// window is how many days either side of a date fireBefore and fireAfter
// window is how many days either side of a date fireBetween and fireAfter
// search: more than a week, so a weekly rule always has a fire time on
// each side.
const window = 8
// atWindow is how many days either side of a date fireBefore and fireAfter
// atWindow is how many days either side of a date fireBetween and fireAfter
// look up a DailyAt rule's fire times. A period firing further off is
// looked up on a later pass, which comes within maxWait.
const atWindow = 2
@@ -650,19 +666,21 @@ type fireTime struct {
date store.LocalDate
}
// fireBefore returns the latest fire time of r in zone loc at or before t.
func fireBefore(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
// fireBetween returns the fire times of r in zone loc after from and at or
// before t, oldest first.
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
fs, err := fireTimes(ctx, r, loc, t)
if err != nil {
return fireTime{}, false, err
return nil, err
}
var best fireTime
var out []fireTime
for _, f := range fs {
if !f.at.After(t) && (best.at.IsZero() || f.at.After(best.at)) {
best = f
if f.at.After(from) && !f.at.After(t) {
out = append(out, f)
}
}
return best, !best.at.IsZero(), nil
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
return out, nil
}
// fireAfter returns the earliest fire time of r in zone loc after t.
+31
View File
@@ -403,6 +403,37 @@ func TestCatchUpOnlyMostRecentPeriod(t *testing.T) {
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
}
func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) {
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08,
// so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart.
e := newEnv(t)
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
var rec recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: rec.run,
}
// Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8
// 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m
// ago, is not looked at.
r := e.start(at(2026, 3, 9, 1, 35), review)
wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"})
wantFired(t, rec.periods(),
time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST
time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT
)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"})
r.stop()
// A restart within the window finds both periods claimed.
var again recorder
review.Run = again.run
r2 := e.start(at(2026, 3, 9, 1, 40), review)
r2.runUntil(at(2026, 3, 9, 23, 0))
wantKeys(t, "after restart", again.keys(), nil)
}
func TestSpringForward(t *testing.T) {
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
e := newEnv(t)
+197
View File
@@ -0,0 +1,197 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
// sourceCase is a recipe page an idea grounds in: its link and the site
// name its page gives (empty for none, so the host is stored instead).
type sourceCase struct {
dish, link, site string
// want is the idea's source_url and site_name; nil is null.
wantURL, wantSite any
}
const (
cleanSourceLink = "https://kitchen.example/recipes/chicken-curry"
queryToken = "query-token-abc123"
queryHost = "query-host-q1.example"
userinfoToken = "user-token-def456"
userinfoHost = "userinfo-host-u2.example"
)
// sourceCases are a clean link, credited by its page's name, and links
// carrying a token in the query and the userinfo whose pages name no site,
// so the stored name is the link's host.
var sourceCases = []sourceCase{
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
{"Bean Chili", "https://" + userinfoToken + "@" + userinfoHost + "/chili", "", nil, nil},
}
// sourceSecrets are what no idea job response may contain.
var sourceSecrets = []string{queryToken, "access_token", queryHost, userinfoToken, userinfoHost}
// sourceFakes makes the search for each case's dish find its page and
// scripts the normalize_recipe reply grounding it takes.
func sourceFakes(t *testing.T, minutes int) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
t.Helper()
model, normalize, search, pages, images := newQuickFakes()
for i, c := range sourceCases {
photo := fmt.Sprintf("https://img.example/%d.jpg", i)
search.Results[c.dish] = []recipes.Candidate{{URL: c.link}}
pages.Bodies[c.link] = recipestest.RecipePage(c.dish, c.site, photo, minutes)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
return model, normalize, search, pages, images
}
// checkIdeaSources checks the finished job id: each idea shows its case's
// safe source, and the response holds nothing of a token-bearing link.
func checkIdeaSources(t *testing.T, h http.Handler, id string) {
t.Helper()
job := waitJob(t, h, id)
if job["status"] != "succeeded" {
t.Fatalf("GET job = %v", job)
}
rec, _ := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
for _, secret := range sourceSecrets {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("job response contains %q: %s", secret, rec.Body)
}
}
list := job["result"].(map[string]any)["ideas"].([]any)
if len(list) != len(sourceCases) {
t.Fatalf("ideas = %v, want %d", list, len(sourceCases))
}
for i, c := range sourceCases {
idea := list[i].(map[string]any)
if idea["name"] != c.dish {
t.Fatalf("idea %d = %v, want %s", i, idea, c.dish)
}
for key, want := range map[string]any{"source_url": c.wantURL, "site_name": c.wantSite} {
got, ok := idea[key]
if !ok || !reflect.DeepEqual(got, want) {
t.Errorf("%s %s = %v (present %t), want %v", c.dish, key, got, ok, want)
}
}
if idea["recipe_id"] == "" || idea["photo_url"] == "" {
t.Errorf("idea %s = %v, want a recipe and photo", c.dish, idea)
}
}
}
// An ideas_from_ingredients job shows a clean link and its page's name,
// and neither a token-bearing link nor a name taken from its host.
func TestIdeasJobShowsOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := sourceFakes(t, 25)
list := make([]string, len(sourceCases))
for i, c := range sourceCases {
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, c.dish, c.dish)
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
checkIdeaSources(t, h, startIdeasJob(t, h))
}
// A quick_ideas job shows sources as an ideas_from_ingredients job does.
func TestQuickJobShowsOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := sourceFakes(t, 25)
ideas := make([][3]any, len(sourceCases))
for i, c := range sourceCases {
ideas[i] = [3]any{c.dish, "stovetop", 20}
}
quickReply(model, ideas...)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
checkIdeaSources(t, h, body["jobId"].(string))
}
// legacyIdea is an idea as the result builders stored it before sources
// were made safe: the grounded recipe's raw link and stored site name,
// which is the link's host when its page named no site.
func legacyIdea(name, link, site string) map[string]any {
return map[string]any{
"name": name, "method": "stovetop", "minutes_total": 25, "difficulty": "easy",
"recipe_id": "01JLEGACY" + strings.ToUpper(strings.ReplaceAll(name, " ", "")), "photo_url": "/api/v1/photos/legacy",
"source_url": link, "site_name": site,
}
}
// A job of either idea kind whose result was stored before sources were
// made safe is served with the sources a job stored now holds: neither a
// token-bearing link nor a name taken from its host.
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
legacy := []any{
legacyIdea("Chicken Curry", cleanSourceLink, "Curry House"),
legacyIdea("Beef Stew", "https://"+queryHost+"/stew?access_token="+queryToken, queryHost),
legacyIdea("Bean Chili", "https://"+userinfoToken+"@"+userinfoHost+"/chili", userinfoHost),
}
for kind, result := range map[string]map[string]any{
ideas.KindFromIngredients: {"stage": "done", "ingredients": []string{"rice"}, "quick_only": false, "ideas": legacy, "dropped": 0},
ideas.KindQuick: {"stage": "done", "kind": "under30", "exclude": []string{}, "ideas": legacy, "dropped": 0},
} {
js, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
id := "legacy-" + kind
if _, err := st.Writer.ExecContext(t.Context(),
`INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at) VALUES (?, ?, 'succeeded', '{}', ?, '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`,
id, kind, string(js)); err != nil {
t.Fatal(err)
}
checkIdeaSources(t, h, id)
}
}
// An idea job whose grounded recipe's stored link does not parse fails,
// even when no site name is stored, rather than show the idea without a
// source.
func TestIdeaJobFailsOnUnparseableSourceWithoutSiteName(t *testing.T) {
t.Parallel()
const badLink = "https://kitchen.example/%zz"
model, normalize, search, pages, images := sourceFakes(t, 25)
idea := fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, sourceCases[0].dish, sourceCases[0].dish)
js := `{"ideas":[` + idea + `]}`
for range 2 {
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, true)
if job := waitJob(t, h, startIdeasJob(t, h)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
// The recipe is now stored under badLink with no site name, and the
// search finds it there.
res, err := st.Writer.ExecContext(t.Context(), `UPDATE recipes SET source_url = ?, site_name = NULL WHERE source_url = ?`, badLink, cleanSourceLink)
if err != nil {
t.Fatal(err)
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
t.Fatalf("updated %d recipes (%v), want 1", n, err)
}
search.Results[sourceCases[0].dish] = []recipes.Candidate{{URL: badLink}}
job := waitJob(t, h, startIdeasJob(t, h))
assertFailedWithoutIdeas(t, job, jobs.CodeInternal)
}
+3 -1
View File
@@ -33,7 +33,9 @@ function idea(name: string, summary: string, minutes: number | null = 30) {
// The ideas of mock 1i.
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
// BOWLS's source is withheld, as the server sends one whose link could
// carry a secret: the card shows all the same.
const BOWLS = { ...idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25), source_url: null, site_name: null }
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
type JobBody = { status: string; result: unknown; error: string | null }
+3 -1
View File
@@ -30,7 +30,9 @@ function idea(name: string, method: string, minutes: number | null, difficulty:
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
// QUESADILLAS's source is withheld, as the server sends one whose link
// could carry a secret: the card shows all the same.
const QUESADILLAS = { ...idea('Chicken quesadillas', 'stovetop', 20, 'easy'), source_url: null, site_name: null }
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')