Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 1f548c9f1d Refuse draft_week jobs on the generic jobs endpoint
CI / ci (push) Successful in 3m28s
CI / ci (pull_request) Successful in 3m10s
A kind can now be made service-only: Runner.Enqueue, and so
POST /api/v1/jobs, refuses it with 400 service_only_kind, and only its
typed Kind enqueues it. draft_week is service-only, so StartDraft and
its one-draft-in-flight-per-week rule is the only way to start a draft;
a confirmed draft_week posted to /api/v1/jobs can no longer run beside
the week's unconfirmed draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:57:53 -04:00
dev_agentandClaude Opus 5.5 45fcd71d27 Keep one draft in flight per week
CI / ci (push) Successful in 3m26s
CI / ci (pull_request) Successful in 3m10s
A confirmed draft asked for while the week's draft job was asked for
without confirm no longer enqueues a second job. With two job workers the
two drafts ran at once, and whichever stored second failed plan_changed,
so the household's confirmed draft could fail. StartDraft now refuses it
with ErrDraftRunning, 409 draft_running, and enqueues nothing; the
household asks again once the running draft finishes. Every other draft
joins the week's queued or running job, so a week has at most one.

The plan tests run the job runner with two workers, as cmd/server does,
and a confirmed draft during a running unconfirmed one is refused while
only the running job drafts.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:46:13 -04:00
dev_agentandClaude Opus 5.5 7e6e49f6b1 Guard plan drafts against swaps and keep confirm on single-flight
CI / ci (push) Successful in 3m20s
CI / ci (pull_request) Successful in 3m6s
Return the draft job id as jobId, as the plan API contract specifies.

Add a week_plans revision (migration 0013) that every swap and stored
draft raises. A draft_week job reads the revision before draft_week runs
and stores its week only while the plan still has it; a plan edited
while the AI ran is left as it is and the job fails plan_changed, so a
draft never erases the household's swap.

A confirmed draft now joins only a confirmed queued or running job, so a
job asked for without confirm can no longer fail it with
confirm_required after the plan was synced.

Test simultaneous drafts, a swap during a draft, a confirmed draft while
an unconfirmed one runs, impossible calendar dates and start_by across
the end of daylight saving time.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:32:51 -04:00
dev_agentandClaude Opus 5.5 75d6ad4e19 Add plan service and weekly plan API
CI / ci (push) Successful in 3m20s
CI / ci (pull_request) Successful in 3m1s
Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.

Serve GET /api/v1/plans/{weekStart}, POST /api/v1/plans/{weekStart}/draft
(202 {job_id}, single-flight per week) and PUT
/api/v1/plans/{weekStart}/days/{date}; a weekStart that is not a Monday
is 400 invalid_week.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:18:09 -04:00
orchestrator-agent 09456c48a2 draft_week skill v1 (#113)
CI / ci (push) Successful in 2m46s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 06:08:13 -04:00
32 changed files with 3386 additions and 33 deletions
+21 -2
View File
@@ -23,6 +23,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
@@ -112,6 +113,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -128,7 +134,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -253,7 +259,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// registerJobKinds registers the framework's job kinds on runner. A feature
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner and
// suggest.NewService suggest.KindSuggestionBatch).
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
// plan.KindDraftWeek).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -286,6 +293,18 @@ func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester
}
}
// weekDrafter runs draft_week with the AI settings current at each run,
// sharing guard.
func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
return func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.Week{}, err
}
return skills.DraftWeek(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+31 -1
View File
@@ -99,7 +99,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
@@ -334,6 +334,36 @@ automatic** (D-021).
the decision's own transaction, so a decision is never recorded without
the replenish it calls for; when they cannot be written, neither is the
decision, and a retry decides again.
- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday
(`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET
/api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status,
ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has
no plan; `days` are the seven days Monday first, each `{date, dinner: {id,
name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means
the dinner was planned from the Quest log; `start_by` is the household's
dinner time on that date minus `minutes_total`, computed on read, for a
crock-pot dinner with known minutes, else null. `POST
/api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202
`{jobId}` for a `draft_week` job, or for the one already queued or
running for the week, so a week has at most one draft in flight. A
confirmed draft while the week's job was asked for without confirm is
409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular
and every Quest log dinner no other plan for the current or a later week
has, with the household context and last week's dinners, and stores the
week: a new plan is `draft`; a redraft keeps each day's
`calendar_event_id`, and a `synced` or `changed` plan becomes `changed`.
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
left as it is and the job fails `confirm_required`. The job reads the
plan's revision before draft_week runs; if the plan was edited (a swap,
or another draft stored) by the time the draft is stored, the plan is
left as it is and the job fails `plan_changed`. A pool under seven
dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, moves a `synced` plan to `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, and any other dinner 409 `dinner_not_plannable`.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
+328
View File
@@ -0,0 +1,328 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
// Aliased: the package tests name a fixture household.
hh "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// NoteLimit is the most characters a drafted week's note may have.
const NoteLimit = 160
// WeekLength is how many dinners a drafted week holds, one a day from
// Monday to Sunday.
const WeekLength = 7
// Candidate kinds: a Regular or a Quest log dinner.
const (
KindRegular = "regular"
KindQuestLog = "quest_log"
)
// WeekRequest is what draft_week reads.
type WeekRequest struct {
// Context is the household context (see harness.BuildContext).
Context harness.HouseholdContext
// WeekStart is the Monday of the week to draft, household-local.
WeekStart store.LocalDate
// Pool holds the dinners the week may be drafted from. It must hold at
// least WeekLength dinners, each once.
Pool []Candidate
// LastWeek holds the IDs of the dinners planned in the week before
// WeekStart. They need not be in Pool.
LastWeek []string
// DinnerTime is the household's dinner time, "HH:MM", from which a
// crock-pot dinner's StartBy is computed.
DinnerTime string
}
// Candidate is one dinner of the pool.
type Candidate struct {
ID string
Name string
// Kind is KindRegular or KindQuestLog.
Kind string
// Method is one of stovetop, oven, crockpot, grill and other, or ""
// when unknown.
Method string
// Minutes from prep to table, or 0 when unknown.
Minutes int
}
// NotEnoughDinnersError is a pool too small to fill a week. DraftWeek
// returns it before any LLM call.
type NotEnoughDinnersError struct {
// Pool is how many dinners the pool holds.
Pool int
}
func (e *NotEnoughDinnersError) Error() string {
return fmt.Sprintf("not enough dinners: the pool holds %d, a week needs %d", e.Pool, WeekLength)
}
// Week is draft_week's output.
type Week struct {
// Days are the week's dinners, Monday to Sunday.
Days []WeekDay `json:"days"`
// Note is a short word on the week, at most NoteLimit characters. It
// may be empty.
Note string `json:"note"`
// Warnings say what the draft did that the rules discourage but allow,
// such as repeating last week's dinners. It is never part of the
// model's reply.
Warnings []string `json:"-"`
}
// WeekDay is one day's dinner.
type WeekDay struct {
// Date is the household-local date, YYYY-MM-DD.
Date string `json:"date"`
// DinnerID is the ID of a dinner of the pool.
DinnerID string `json:"dinner_id"`
// IsNew is true for a Quest log dinner. It is computed from the pool,
// never part of the model's reply.
IsNew bool `json:"-"`
// StartBy is when a crock-pot dinner must start to be ready at the
// household's dinner time; it is zero for any other dinner and for a
// crock-pot dinner whose minutes are unknown. It is computed, never
// part of the model's reply.
StartBy time.Time `json:"-"`
}
// DraftWeek runs draft_week v1 on in and returns a week of seven dinners
// from the pool, one a day from Monday to Sunday, none twice. A pool of
// fewer than WeekLength dinners fails with *NotEnoughDinnersError before
// any LLM call, and any other invalid request fails before it too. scope
// names the week (see harness.Run).
func DraftWeek(ctx context.Context, cfg harness.Config, scope string, in WeekRequest) (Week, error) {
s, err := newDraftWeek(in)
if err != nil {
return Week{}, err
}
return harness.Run(ctx, cfg, s, scope, in)
}
// draftWeek is the skill. It holds its input, the week's dates and the
// pool by ID because validation and post-processing compare the output
// with them.
type draftWeek struct {
in WeekRequest
dates [WeekLength]store.LocalDate
pool map[string]Candidate
zone *time.Location
}
var _ harness.Skill[WeekRequest, Week] = draftWeek{}
// newDraftWeek checks in and returns the skill for it.
func newDraftWeek(in WeekRequest) (draftWeek, error) {
pool := map[string]Candidate{}
for i, c := range in.Pool {
if err := c.check(); err != nil {
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: %w", i+1, err)
}
if _, dup := pool[c.ID]; dup {
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: id %q is in the pool twice", i+1, c.ID)
}
pool[c.ID] = c
}
if len(pool) < WeekLength {
return draftWeek{}, &NotEnoughDinnersError{Pool: len(pool)}
}
dates, err := hh.WeekDays(in.WeekStart)
if err != nil {
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
}
zone, err := hh.LoadZone(in.Context.Timezone)
if err != nil {
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
}
// The dinner time is checked here so a bad one fails before the LLM
// call rather than in PostProcess.
if _, err := hh.LocalTime(zone, in.WeekStart, in.DinnerTime); err != nil {
return draftWeek{}, fmt.Errorf("draft_week: dinner time: %w", err)
}
return draftWeek{in: in, dates: dates, pool: pool, zone: zone}, nil
}
// check reports a candidate the pool cannot hold.
func (c Candidate) check() error {
switch {
case strings.TrimSpace(c.ID) == "" || strings.ContainsAny(c.ID, "\r\n"):
return fmt.Errorf("id %q is empty or has a line break", c.ID)
case strings.TrimSpace(c.Name) == "":
return fmt.Errorf("%s has an empty name", c.ID)
case c.Kind != KindRegular && c.Kind != KindQuestLog:
return fmt.Errorf("%s kind %q is not %s or %s", c.ID, c.Kind, KindRegular, KindQuestLog)
case c.Method != "" && !slices.Contains(methods, c.Method):
return fmt.Errorf("%s method %q is not one of %s", c.ID, c.Method, strings.Join(methods, ", "))
case c.Minutes < 0:
return fmt.Errorf("%s minutes %d is negative", c.ID, c.Minutes)
}
return nil
}
func (draftWeek) Name() string { return "draft_week" }
func (draftWeek) Version() int { return 1 }
func (draftWeek) Timeout() time.Duration { return harness.DefaultTimeout }
// weekPrompt is the template data of draft_week.v1.tmpl.
type weekPrompt struct {
// Days are the week's dates, Monday to Sunday, e.g. "Mon 2026-10-12".
Days []string
}
func (s draftWeek) PromptInput(in WeekRequest) (harness.PromptInput, error) {
var days []string
for _, d := range s.dates {
y, m, day := d.Date()
days = append(days, time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Format("Mon")+" "+d.String())
}
var pool []string
for _, c := range in.Pool {
pool = append(pool, c.line(slices.Contains(in.LastWeek, c.ID)))
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Candidate pool", Lines: pool})
return harness.PromptInput{Data: weekPrompt{Days: days}, Context: sections}, nil
}
// line renders c as `id "ID": Name (Regular, method, N min, planned last
// week)`, leaving out what is unknown.
func (c Candidate) line(lastWeek bool) string {
attrs := []string{"Regular"}
if c.Kind == KindQuestLog {
attrs = []string{"Quest log"}
}
if c.Method != "" {
attrs = append(attrs, c.Method)
}
if c.Minutes > 0 {
attrs = append(attrs, fmt.Sprintf("%d min", c.Minutes))
}
if lastWeek {
attrs = append(attrs, "planned last week")
}
return fmt.Sprintf("id %q: %s (%s)", c.ID, oneLine(c.Name), strings.Join(attrs, ", "))
}
func (draftWeek) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "draft_week", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": {"type": "string"},
"dinner_id": {"type": "string"}
},
"required": ["date", "dinner_id"],
"additionalProperties": false
}
},
"note": {"type": "string"}
},
"required": ["days", "note"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes a day, requiring each of its fields as the schema
// does.
func (d *WeekDay) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "date", "dinner_id"); err != nil {
return fmt.Errorf("day %s", err)
}
type plain WeekDay
return json.Unmarshal(data, (*plain)(d))
}
// UnmarshalJSON decodes the output, requiring each of its fields as the
// schema does.
func (w *Week) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "days", "note"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain Week
return json.Unmarshal(data, (*plain)(w))
}
// Validate finds every broken rule: a week that is not exactly the seven
// dates Monday to Sunday in order, a dinner not in the pool or planned
// twice, or a note over NoteLimit characters.
func (s draftWeek) Validate(out Week) []string {
var problems []string
if n := len(out.Days); n != WeekLength {
problems = append(problems, fmt.Sprintf("days has %d entries; give exactly %d, one for each date from %s to %s",
n, WeekLength, s.dates[0], s.dates[WeekLength-1]))
}
first := map[string]int{} // dinner ID -> first day planning it
for i, d := range out.Days {
if i < WeekLength && d.Date != s.dates[i].String() {
problems = append(problems, fmt.Sprintf("day %d date %q is not %s; give the dates %s to %s in order",
i+1, d.Date, s.dates[i], s.dates[0], s.dates[WeekLength-1]))
}
c, ok := s.pool[d.DinnerID]
if !ok {
problems = append(problems, fmt.Sprintf("day %d dinner_id %q is not in the candidate pool; use only ids from it", i+1, d.DinnerID))
continue
}
if j, dup := first[d.DinnerID]; dup {
problems = append(problems, fmt.Sprintf("day %d dinner_id %q (%s) is also on day %d; plan each dinner at most once",
i+1, d.DinnerID, oneLine(c.Name), j+1))
continue
}
first[d.DinnerID] = i
}
if n := utf8.RuneCountInString(out.Note); n > NoteLimit {
problems = append(problems, fmt.Sprintf("note has %d characters; keep it to at most %d", n, NoteLimit))
}
return problems
}
// PostProcess tidies the note, marks each Quest log dinner new, computes
// each crock-pot dinner's StartBy, and warns when the week repeats more of
// last week's dinners than the pool made necessary.
func (s draftWeek) PostProcess(out Week) (Week, error) {
result := Week{Note: oneLine(out.Note)}
var repeats []string
for i, d := range out.Days {
c := s.pool[d.DinnerID]
day := WeekDay{Date: d.Date, DinnerID: d.DinnerID, IsNew: c.Kind == KindQuestLog}
if c.Method == "crockpot" && c.Minutes > 0 {
dinner, err := hh.LocalTime(s.zone, s.dates[i], s.in.DinnerTime)
if err != nil {
return Week{}, err
}
day.StartBy = dinner.Add(-time.Duration(c.Minutes) * time.Minute)
}
if slices.Contains(s.in.LastWeek, d.DinnerID) {
repeats = append(repeats, oneLine(c.Name))
}
result.Days = append(result.Days, day)
}
fresh := 0
for id := range s.pool {
if !slices.Contains(s.in.LastWeek, id) {
fresh++
}
}
if allowed := max(0, WeekLength-fresh); len(repeats) > allowed {
result.Warnings = append(result.Warnings, fmt.Sprintf("repeats %d of last week's dinners (%s) though the pool needed at most %d",
len(repeats), strings.Join(repeats, ", "), allowed))
}
return result, nil
}
+324
View File
@@ -0,0 +1,324 @@
package skills
import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two
// of them planned last week.
var weekOf = WeekRequest{
Context: harness.HouseholdContext{
Today: date(2026, 10, 8),
Timezone: "America/New_York",
Regulars: []harness.RegularDinner{
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
},
QuestLog: []string{"Pad Thai"},
Passed: []string{},
Plans: []harness.PlannedDinner{
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
{Date: date(2026, 10, 6), Name: "Fish Tacos"},
},
},
WeekStart: date(2026, 10, 12),
Pool: []Candidate{
{ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240},
{ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30},
{ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45},
{ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"},
{ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25},
{ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35},
{ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"},
{ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40},
},
LastWeek: []string{"fajitas", "tacos", "gone"},
DinnerTime: "17:30",
}
// weekDates are the dates of weekOf, Monday to Sunday.
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
// week renders a reply planning ids on dates, in order.
func week(note string, dates []string, ids ...string) string {
var days []string
for i, id := range ids {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id))
}
return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
}
// clean plans no dinner of last week.
var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
// wantWeek is the output for a reply planning ids on weekDates with note.
func wantWeek(note string, ids ...string) Week {
newYork, err := time.LoadLocation("America/New_York")
if err != nil {
panic(err)
}
out := Week{Note: note}
for i, id := range ids {
day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"}
if id == "chili" {
y, m, d := date(2026, 10, 12+i).Date()
day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork)
}
out.Days = append(out.Days, day)
}
return out
}
// draft runs DraftWeek on in with f and checks f was called exactly calls
// times, each at the household temperature with the draft_week schema.
func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) {
t.Helper()
out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", in)
reqs := f.Requests()
if len(reqs) != calls {
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
}
if g, _, _ := f.Remaining(); g != 0 {
t.Fatalf("%d scripted Generate results unused", g)
}
for i, req := range reqs {
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
}
if req.Schema == nil || req.Schema.Name != "draft_week" {
t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema)
}
}
return out, reqs, err
}
func assertWeek(t *testing.T, got, want Week) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("output = %+v\nwant %+v", got, want)
}
}
func TestDraftWeekValid(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...)))
out, reqs, err := draft(t, &f, weekOf, 1)
if err != nil {
t.Fatal(err)
}
assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...))
if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) {
t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "draft_week_valid.system.golden", system)
golden(t, "draft_week_valid.user.golden", user)
for _, part := range []string{
"## Household context",
"### Candidate pool",
`- id "chili": Beef Chili (Regular, crockpot, 240 min)`,
`- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`,
`- id "salmon": Sheet Pan Salmon (Regular, oven)`,
`- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`,
`- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`,
"## Output contract",
`"dinner_id"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") {
t.Error("the output contract is not after the candidate pool")
}
want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18."
if user != want {
t.Errorf("user message = %q, want %q", user, want)
}
}
// repaired checks a first reply is repaired once, quoting it and the
// problems wanted.
func repaired(t *testing.T, first string, want ...string) {
t.Helper()
var f llm.Fake
f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...)))
out, reqs, err := draft(t, &f, weekOf, 2)
if err != nil {
t.Fatal(err)
}
assertWeek(t, out, wantWeek("Fixed.", clean...))
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
for _, w := range want {
if !strings.Contains(repair[2].Content, w) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w)
}
}
}
func TestDraftWeekUnknownIDRepaired(t *testing.T) {
ids := slices.Clone(clean)
ids[2] = "lasagna"
repaired(t, week("A week.", weekDates, ids...),
`day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`)
}
func TestDraftWeekDuplicateRepaired(t *testing.T) {
ids := slices.Clone(clean)
ids[5] = "chili"
repaired(t, week("A week.", weekDates, ids...),
`day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`)
}
func TestDraftWeekWrongDatesTwiceFails(t *testing.T) {
sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...)
var f llm.Fake
f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...)))
out, _, err := draft(t, &f, weekOf, 2)
var bad *harness.BadOutputError
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want a BadOutputError", err)
}
if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Days != nil {
t.Fatalf("output = %+v, want none", out)
}
}
func TestDraftWeekNotEnoughDinners(t *testing.T) {
small := weekOf
small.Pool = slices.Clone(weekOf.Pool[:6])
var f llm.Fake
_, _, err := draft(t, &f, small, 0)
var short *NotEnoughDinnersError
if !errors.As(err, &short) || short.Pool != 6 {
t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err)
}
if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" {
t.Fatalf("err = %q", err)
}
}
func TestDraftWeekLastWeekRepeats(t *testing.T) {
// The pool has six dinners not planned last week, so the week needs one
// of last week's two and may plan it without a warning.
one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"}
for name, tc := range map[string]struct {
ids []string
warn []string
}{
"needed repeat": {one, nil},
"avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...)))
out, _, err := draft(t, &f, weekOf, 1)
if err != nil {
t.Fatal(err)
}
want := wantWeek("A week.", tc.ids...)
want.Warnings = tc.warn
assertWeek(t, out, want)
})
}
}
func TestWeekValidate(t *testing.T) {
s, err := newDraftWeek(weekOf)
if err != nil {
t.Fatal(err)
}
reply := func(note string, ids ...string) Week {
var w Week
if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil {
t.Fatal(err)
}
return w
}
if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 {
t.Fatalf("valid output has problems: %q", p)
}
for name, tc := range map[string]struct {
out Week
want []string
}{
"empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}},
"six days": {reply("", clean[:6]...), []string{
"days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18",
}},
"eight days": {
Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})},
[]string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"},
},
"long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}},
"dates out of order": {
Week{Days: []WeekDay{
{weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}},
{weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}},
{weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}},
{weekDates[6], "fajitas", false, time.Time{}},
}},
[]string{
`day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`,
`day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`,
},
},
} {
t.Run(name, func(t *testing.T) {
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
}
})
}
}
func TestDraftWeekRequestRejected(t *testing.T) {
with := func(change func(*WeekRequest)) WeekRequest {
in := weekOf
in.Pool = slices.Clone(weekOf.Pool)
change(&in)
return in
}
for name, tc := range map[string]struct {
in WeekRequest
want string
}{
"not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }),
"draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"},
"bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }),
`draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`},
"bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }),
`draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`},
"empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }),
`draft_week: pool dinner 3: id " " is empty or has a line break`},
"id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }),
`draft_week: pool dinner 4: id "chili" is in the pool twice`},
"bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }),
`draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
+1
View File
@@ -4,3 +4,4 @@
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
@@ -0,0 +1,21 @@
{{/* draft_week drafts the household's weekly plan: one dinner a day, Monday to Sunday, from a candidate pool. */}}
{{define "instructions" -}}
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
Rules:
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
- Plan each dinner at most once in the week.
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
Give:
- days: the 7 days, each with date and dinner_id.
- note: one sentence of at most 160 characters about the week.
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
{"date": "2026-01-05", "dinner_id": "d1"}
{{- end}}
{{define "user" -}}
Draft the week of {{range $i, $d := .Days}}{{if $i}}, {{end}}{{$d}}{{end}}.
{{- end}}
@@ -0,0 +1,80 @@
## Instructions
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
Rules:
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
- Plan each dinner at most once in the week.
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
Give:
- days: the 7 days, each with date and dinner_id.
- note: one sentence of at most 160 characters about the week.
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
{"date": "2026-01-05", "dinner_id": "d1"}
## Household context
### Today
- Thursday 2026-10-08 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
### Quest log
- Pad Thai
### Passed, oldest first
(none)
### Recent plans
- 2026-10-05 Mon: Chicken Fajitas
- 2026-10-06 Tue: Fish Tacos
### Candidate pool
- id "chili": Beef Chili (Regular, crockpot, 240 min)
- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)
- id "bolognese": Spaghetti Bolognese (Regular, stovetop, 45 min)
- id "salmon": Sheet Pan Salmon (Regular, oven)
- id "tacos": Fish Tacos (Regular, 25 min, planned last week)
- id "padthai": Pad Thai (Quest log, stovetop, 35 min)
- id "roast": Pot Roast (Regular, crockpot)
- id "shawarma": Chicken Shawarma Bowls (Quest log, oven, 40 min)
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "draft_week" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"dinner_id": {
"type": "string"
}
},
"required": [
"date",
"dinner_id"
],
"additionalProperties": false
}
},
"note": {
"type": "string"
}
},
"required": [
"days",
"note"
],
"additionalProperties": false
}
@@ -0,0 +1 @@
Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18.
+1
View File
@@ -316,4 +316,5 @@ type WeekPlan struct {
CalendarSyncedAt sql.NullString
CreatedAt string
UpdatedAt string
Revision int64
}
+355
View File
@@ -0,0 +1,355 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: plans.sql
package db
import (
"context"
"database/sql"
)
const createWeekPlan = `-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?)
`
type CreateWeekPlanParams struct {
ID string
WeekStart string
AiNote sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error {
_, err := q.db.ExecContext(ctx, createWeekPlan,
arg.ID,
arg.WeekStart,
arg.AiNote,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const getActiveDraftJob = `-- name: GetActiveDraftJob :one
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.week_start') = ?2
ORDER BY id
LIMIT 1
`
type GetActiveDraftJobParams struct {
Kind string
WeekStart string
}
type GetActiveDraftJobRow struct {
ID string
Confirmed int64
}
// The job of kind queued or running for the week starting week_start,
// with whether its payload has confirm. StartDraft enqueues a draft only
// when there is none, so there is at most one. Returns sql.ErrNoRows when
// there is none.
func (q *Queries) GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error) {
row := q.db.QueryRowContext(ctx, getActiveDraftJob, arg.Kind, arg.WeekStart)
var i GetActiveDraftJobRow
err := row.Scan(&i.ID, &i.Confirmed)
return i, err
}
const getWeekPlan = `-- name: GetWeekPlan :one
SELECT id, week_start, status, ai_note, ai_run_id, calendar_synced_at, created_at, updated_at, revision FROM week_plans
WHERE week_start = ?
`
// The plan of the week starting week_start. Returns sql.ErrNoRows when
// there is none.
func (q *Queries) GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) {
row := q.db.QueryRowContext(ctx, getWeekPlan, weekStart)
var i WeekPlan
err := row.Scan(
&i.ID,
&i.WeekStart,
&i.Status,
&i.AiNote,
&i.AiRunID,
&i.CalendarSyncedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Revision,
)
return i, err
}
const listDraftPool = `-- name: ListDraftPool :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE status = 'regular'
OR (status = 'quest_log' AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= ?1
AND week_plans.week_start <> ?2
))
ORDER BY normalized_name, id
`
type ListDraftPoolParams struct {
FromWeekStart string
WeekStart string
}
// The dinners a draft of the week starting week_start may plan: every
// Regular, and every Quest log dinner no other plan for the week starting
// from_week_start or later has on any day.
func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listDraftPool, arg.FromWeekStart, arg.WeekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlanDays = `-- name: ListPlanDays :many
SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
ORDER BY plan_days.date
`
type ListPlanDaysRow struct {
Date string
IsNew int64
Dinner Dinner
}
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) {
rows, err := q.db.QueryContext(ctx, listPlanDays, planID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlanDaysRow
for rows.Next() {
var i ListPlanDaysRow
if err := rows.Scan(
&i.Date,
&i.IsNew,
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWeekPlanDinnerIDs = `-- name: ListWeekPlanDinnerIDs :many
SELECT plan_days.dinner_id
FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
ORDER BY plan_days.date
`
// The ids of the dinners planned in the week starting week_start, Monday
// first.
func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) {
rows, err := q.db.QueryContext(ctx, listWeekPlanDinnerIDs, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []sql.NullString
for rows.Next() {
var dinner_id sql.NullString
if err := rows.Scan(&dinner_id); err != nil {
return nil, err
}
items = append(items, dinner_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE id = ?
`
type MarkWeekPlanEditedParams struct {
UpdatedAt string
ID string
}
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error {
_, err := q.db.ExecContext(ctx, markWeekPlanEdited, arg.UpdatedAt, arg.ID)
return err
}
const putPlanDay = `-- name: PutPlanDay :exec
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at
`
type PutPlanDayParams struct {
ID string
PlanID string
Date string
DinnerID sql.NullString
IsNew int64
CreatedAt string
UpdatedAt string
}
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
// when the day exists.
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
_, err := q.db.ExecContext(ctx, putPlanDay,
arg.ID,
arg.PlanID,
arg.Date,
arg.DinnerID,
arg.IsNew,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const redraftWeekPlan = `-- name: RedraftWeekPlan :exec
UPDATE week_plans
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
WHERE id = ?
`
type RedraftWeekPlanParams struct {
Status string
AiNote sql.NullString
UpdatedAt string
ID string
}
// Records a new draft over plan id: its status and note, raising its
// revision.
func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error {
_, err := q.db.ExecContext(ctx, redraftWeekPlan,
arg.Status,
arg.AiNote,
arg.UpdatedAt,
arg.ID,
)
return err
}
const swapPlanDay = `-- name: SwapPlanDay :execrows
UPDATE plan_days
SET dinner_id = ?, is_new = ?, updated_at = ?
WHERE plan_id = ? AND date = ?
`
type SwapPlanDayParams struct {
DinnerID sql.NullString
IsNew int64
UpdatedAt string
PlanID string
Date string
}
// Plans dinner_id on an existing day of plan_id; a date the plan does not
// have is left as it is (0 rows).
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
result, err := q.db.ExecContext(ctx, swapPlanDay,
arg.DinnerID,
arg.IsNew,
arg.UpdatedAt,
arg.PlanID,
arg.Date,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+31
View File
@@ -38,6 +38,7 @@ type Querier interface {
// later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error
// Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows).
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
@@ -67,6 +68,11 @@ type Querier interface {
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The job of kind queued or running for the week starting week_start,
// with whether its payload has confirm. StartDraft enqueues a draft only
// when there is none, so there is at most one. Returns sql.ErrNoRows when
// there is none.
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
@@ -93,6 +99,9 @@ type Querier interface {
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// The id of the job that runs batch batch_id.
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
// The plan of the week starting week_start. Returns sql.ErrNoRows when
// there is none.
GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
@@ -116,6 +125,13 @@ type Querier interface {
// first; renaming its dinner does not change it.
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
// The dinners a draft of the week starting week_start may plan: every
// Regular, and every Quest log dinner no other plan for the week starting
// from_week_start or later has on any day.
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
// The planned dinners dated from from_date up to but not including
// to_date, oldest first. Days with no dinner are left out.
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
@@ -131,18 +147,33 @@ type Querier interface {
// The Quest log: dinners with status quest_log that no plan for the week
// starting from_week_start or later has on any day.
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
// The ids of the dinners planned in the week starting week_start, Monday
// first.
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
// when the day exists.
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
// Records a new draft over plan id: its status and note, raising its
// revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// Plans dinner_id on an existing day of plan_id; a date the plan does not
// have is left as it is (0 rows).
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
// An unknown id returns sql.ErrNoRows.
+31 -7
View File
@@ -88,6 +88,10 @@ var ErrNotFound = errors.New("jobs: job not found")
// registered handler.
var ErrUnknownKind = errors.New("jobs: unknown job kind")
// ErrServiceOnly is returned by Runner.Enqueue for a kind made ServiceOnly,
// whose jobs only its own service enqueues.
var ErrServiceOnly = errors.New("jobs: job kind is enqueued only by its service")
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
// not decode into the kind's payload type.
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
@@ -130,11 +134,13 @@ type Job struct {
// never quotes an error.
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// handler is a registered kind: run runs a job, and check reports whether
// a payload decodes into the kind's payload type.
// handler is a registered kind: run runs a job, check reports whether a
// payload decodes into the kind's payload type, and serviceOnly is set when
// Runner.Enqueue refuses the kind.
type handler struct {
run runFunc
check func(payload []byte) bool
run runFunc
check func(payload []byte) bool
serviceOnly bool
}
// Runner owns the job handlers and the worker pool.
@@ -286,15 +292,30 @@ func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string,
return createJob(ctx, db.New(tx), k.name, data)
}
// ServiceOnly makes Runner.Enqueue refuse the kind with ErrServiceOnly, so
// its jobs are enqueued only through k, by the service that checks what
// must hold before one starts. It must be called before Start.
func (k *Kind[P]) ServiceOnly() error {
k.r.mu.Lock()
defer k.r.mu.Unlock()
if k.r.started {
return fmt.Errorf("jobs: service-only %q: runner already started", k.name)
}
h := k.r.handlers[k.name]
h.serviceOnly = true
k.r.handlers[k.name] = h
return nil
}
// Wake tells an idle worker to look for a job, as Enqueue does itself:
// call it after committing the transaction a job was enqueued in.
func (k *Kind[P]) Wake() { k.r.wakeOne() }
// Enqueue stores a queued job of kind with its JSON payload and returns its
// id, as Kind.Enqueue does for a kind known at compile time. It returns
// ErrUnknownKind for a kind with no registered handler and
// ErrInvalidPayload for a payload that does not decode into the kind's
// payload type; neither stores a job.
// ErrUnknownKind for a kind with no registered handler, ErrServiceOnly for
// a ServiceOnly kind and ErrInvalidPayload for a payload that does not
// decode into the kind's payload type; none stores a job.
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
r.mu.Lock()
h, ok := r.handlers[kind]
@@ -302,6 +323,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
if !ok {
return "", ErrUnknownKind
}
if h.serviceOnly {
return "", ErrServiceOnly
}
if !h.check(payload) {
return "", ErrInvalidPayload
}
+36
View File
@@ -736,3 +736,39 @@ func TestRunnerEnqueueByKindName(t *testing.T) {
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
func TestServiceOnlyKind(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
k := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
if err := k.ServiceOnly(); err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)); !errors.Is(err, ErrServiceOnly) {
t.Errorf("Runner.Enqueue error = %v, want ErrServiceOnly", err)
}
var n int
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("refused enqueue stored %d jobs, want 0", n)
}
id, err := k.Enqueue(ctx, countPayload{Steps: 3})
if err != nil {
t.Fatal(err)
}
start(t, r)
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
t.Errorf("job enqueued through its Kind = %+v, want succeeded", job)
}
if err := k.ServiceOnly(); err == nil {
t.Error("ServiceOnly after Start succeeded, want an error")
}
}
+212
View File
@@ -0,0 +1,212 @@
package plan
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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/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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// DraftJob is the KindDraftWeek payload: the week to draft, and whether
// the household confirmed drafting over a synced or changed plan.
type DraftJob struct {
WeekStart string `json:"week_start"`
Confirm bool `json:"confirm"`
}
// draftResult is the KindDraftWeek result: the week drafted and its plan.
type draftResult struct {
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
}
func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runDraft drafts the week p.WeekStart: draft_week plans it from the
// Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before
// draft_week ran. A pool under seven dinners fails with
// ReasonNotEnoughDinners and draft_week failing with ReasonAIFailed; no
// week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil {
return draftResult{}, fmt.Errorf("plan: draft: %w", err)
}
base, err := s.revision(ctx, weekStart)
if err != nil {
return draftResult{}, err
}
in, err := s.request(ctx, weekStart)
if err != nil {
return draftResult{}, err
}
week, err := s.draft(ctx, household.PeriodKey(weekStart), in)
if err != nil {
if ctx.Err() != nil {
return draftResult{}, ctx.Err()
}
var few *skills.NotEnoughDinnersError
if errors.As(err, &few) {
return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners}
}
// Only the code is logged: the error may quote a model's response.
s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed)
return draftResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
}
planID, err := s.storeDraft(ctx, weekStart, p.Confirm, base, week)
if err != nil {
return draftResult{}, err
}
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil
}
// noPlan is the revision of a week with no plan.
const noPlan = -1
// revision returns the revision of the plan of the week starting
// weekStart, or noPlan when the week has none.
func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int64, error) {
cur, err := db.New(s.st.Reader).GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return noPlan, nil
}
if err != nil {
return 0, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
return cur.Revision, nil
}
// request reads what draft_week is given for the week starting weekStart:
// the household context, the pool, last week's dinners and the dinner
// time.
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
}
q := db.New(s.st.Reader)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err)
}
thisWeek := household.WeekStart(hctx.Today)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make([]skills.Candidate, len(rows))
for i, d := range rows {
kind := skills.KindRegular
if d.Status == dinners.StatusQuestLog {
kind = skills.KindQuestLog
}
pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)}
}
last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String())
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err)
}
lastWeek := []string{}
for _, id := range last {
lastWeek = append(lastWeek, id.String)
}
return skills.WeekRequest{
Context: hctx,
WeekStart: weekStart,
Pool: pool,
LastWeek: lastWeek,
DinnerTime: settings.DinnerTime,
}, nil
}
// storeDraft stores week as the plan of the week starting weekStart in one
// transaction and returns the plan's id. A new plan is draft. A draft
// replaces the days of an existing plan, keeping each day's calendar
// event id; a draft plan stays draft and a synced or changed one becomes
// changed, since Calendar no longer shows it. A plan synced since the
// draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String())
switch {
case errors.Is(err, sql.ErrNoRows) && base != noPlan:
return &jobs.Failure{Code: ReasonPlanChanged}
case errors.Is(err, sql.ErrNoRows):
planID = store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{
ID: planID,
WeekStart: weekStart.String(),
AiNote: note,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: create week %s: %w", weekStart, err)
}
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case cur.Status != StatusDraft && !confirm:
return &jobs.Failure{Code: ReasonConfirmRequired}
case cur.Revision != base:
return &jobs.Failure{Code: ReasonPlanChanged}
default:
planID = cur.ID
status := StatusDraft
if cur.Status != StatusDraft {
status = StatusChanged
}
if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil {
return fmt.Errorf("plan: redraft %s: %w", cur.ID, err)
}
}
for _, d := range week.Days {
isNew := int64(0)
if d.IsNew {
isNew = 1
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{
ID: store.NewID(),
PlanID: planID,
Date: d.Date,
DinnerID: sql.NullString{String: d.DinnerID, Valid: true},
IsNew: isNew,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
}
}
return nil
})
if err != nil {
return "", err
}
return planID, nil
}
// addDays returns d plus n days.
func addDays(d store.LocalDate, n int) store.LocalDate {
y, m, day := d.Date()
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
}
+424
View File
@@ -0,0 +1,424 @@
// Package plan keeps the household's weekly plans: the Draft board the AI
// drafts from the Regulars and the Quest log, and the swaps the household
// makes on it. A draft runs as a job: draft_week plans seven dinners, one
// a day from Monday to Sunday, and the job stores them as the week's plan,
// replacing any draft before it unless the household edited the plan
// while draft_week ran. A plan is draft until Push to Calendar
// syncs it (Feature 16); an edit of a synced plan makes it changed.
package plan
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"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/dinners"
"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/store"
)
// KindDraftWeek is the job that drafts a week's plan.
const KindDraftWeek = "draft_week"
// Plan statuses (the week_plans.status column).
const (
StatusDraft = "draft"
StatusSynced = "synced"
StatusChanged = "changed"
)
// Reasons a draft failed, stored as the KindDraftWeek job's failure code.
// Any other failure, such as the database's, is the runner's
// internal_error.
const (
// ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than
// seven dinners to draft from.
ReasonNotEnoughDinners = "not_enough_dinners"
// ReasonAIFailed: draft_week failed; no week is made up instead.
ReasonAIFailed = "ai_failed"
// ReasonConfirmRequired: the plan was synced after the draft was
// asked for without confirm, so the draft does not replace it.
ReasonConfirmRequired = "confirm_required"
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit.
ReasonPlanChanged = "plan_changed"
)
// Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged}
// ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext).
const ContextBudget = 16000
// plannable are the dinner statuses a day may be swapped to.
var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog}
// ErrNotFound is returned for a week with no plan.
var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found")
// ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
// ErrDraftRunning is a confirmed draft asked for while a draft of the week
// asked for without confirm is queued or running.
var ErrDraftRunning = errors.New("a draft of the week without confirm is already running; ask again when it finishes")
// InvalidWeekError is a week start that is not a Monday date.
type InvalidWeekError struct {
Value string
}
func (e *InvalidWeekError) Error() string {
return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value)
}
// InvalidDayError is a swap of a date outside the plan's week.
type InvalidDayError struct {
Value string
}
func (e *InvalidDayError) Error() string {
return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value)
}
// DinnerNotPlannableError is a swap to a dinner that is neither a Regular
// nor in the Quest log.
type DinnerNotPlannableError struct {
Status string
}
func (e *DinnerNotPlannableError) Error() string {
return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status)
}
// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD.
func ParseWeekStart(s string) (store.LocalDate, error) {
d, err := store.ParseLocalDate(s)
if err != nil {
return store.LocalDate{}, &InvalidWeekError{Value: s}
}
if _, err := household.WeekDays(d); err != nil {
return store.LocalDate{}, &InvalidWeekError{Value: s}
}
return d, nil
}
// Drafter runs draft_week on in; scope names the week. In production it is
// skills.DraftWeek with the household's harness.Config.
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
// Service drafts, reads and edits weekly plans.
type Service struct {
st *store.Store
draft Drafter
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
}
// NewService returns a service over st that drafts with draft 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.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
case draft == nil:
return nil, errors.New("plan: nil drafter")
case runner == nil:
return nil, errors.New("plan: nil job runner")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, logger: logger}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
s.draftJob = kind
return s, nil
}
// Plan is a week's plan as the API shows it.
type Plan struct {
ID string `json:"id"`
WeekStart string `json:"week_start"`
// WeekLabel is e.g. "Week 41 · Oct 5 – 11".
WeekLabel string `json:"week_label"`
Status string `json:"status"`
AINote *string `json:"ai_note"`
CalendarSyncedAt *string `json:"calendar_synced_at"`
Days []Day `json:"days"`
UpdatedAt string `json:"updated_at"`
}
// Day is one day of a plan.
type Day struct {
// Date is household-local, YYYY-MM-DD.
Date string `json:"date"`
Dinner Dinner `json:"dinner"`
// IsNew is true for a dinner planned from the Quest log.
IsNew bool `json:"is_new"`
// StartBy is when a crock-pot dinner must start to be ready at the
// household's dinner time; null for any other dinner and for a
// crock-pot dinner whose minutes are unknown.
StartBy *time.Time `json:"start_by"`
}
// Dinner is a planned dinner as a plan day shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
Minutes *int64 `json:"minutes"`
Method *string `json:"method"`
Origin string `json:"origin"`
}
// Get returns the plan of the week starting weekStart, or ErrNotFound.
// The plan and the household settings come from one read transaction.
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Plan{}, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
return readPlan(ctx, db.New(tx), weekStart)
}
// readPlan reads the plan of the week starting weekStart through q, with
// each day's start_by from the household's dinner time and zone.
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
label, err := household.WeekLabel(weekStart)
if err != nil {
return Plan{}, fmt.Errorf("plan: %w", err)
}
row, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return Plan{}, ErrNotFound
}
if err != nil {
return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return Plan{}, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Plan{}, fmt.Errorf("plan: household settings: %w", err)
}
rows, err := q.ListPlanDays(ctx, row.ID)
if err != nil {
return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
}
dates, err := household.WeekDays(weekStart)
if err != nil {
return Plan{}, fmt.Errorf("plan: %w", err)
}
if len(rows) != len(dates) {
return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
}
p := Plan{
ID: row.ID,
WeekStart: row.WeekStart,
WeekLabel: label,
Status: row.Status,
AINote: nullString(row.AiNote),
CalendarSyncedAt: nullString(row.CalendarSyncedAt),
Days: make([]Day, len(rows)),
UpdatedAt: row.UpdatedAt,
}
for i, r := range rows {
if r.Date != dates[i].String() {
return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
}
startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner)
if err != nil {
return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err)
}
p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy}
}
return p, nil
}
// startBy returns when crock-pot dinner d must start on date to be ready at
// dinnerTime in zone, or nil for any other dinner and for a crock-pot
// dinner whose minutes are unknown.
func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) {
if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 {
return nil, nil
}
at, err := household.LocalTime(zone, date, dinnerTime)
if err != nil {
return nil, err
}
at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute)
return &at, nil
}
// Draft is a draft asked for: the job drafting the week, and whether this
// call enqueued it rather than found one already queued or running.
type Draft struct {
JobID string `json:"jobId"`
Created bool `json:"-"`
}
// StartDraft enqueues the KindDraftWeek job drafting the week starting
// weekStart, unless one is already queued or running for it, which it
// returns instead with Created false, so a week has at most one draft in
// flight. A confirmed draft joins only a confirmed job: while the week's
// job was asked for without confirm, it fails with ErrDraftRunning and
// 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) {
if _, err := household.WeekDays(weekStart); err != nil {
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
}
var d Draft
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case cur.Status != StatusDraft && !confirm:
return ErrConfirmRequired
}
active, err := q.GetActiveDraftJob(ctx, db.GetActiveDraftJobParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
case confirm && active.Confirmed != 1:
return ErrDraftRunning
default:
d = Draft{JobID: active.ID}
return nil
}
id, err := s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm})
if err != nil {
return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err)
}
d = Draft{JobID: id, Created: true}
return nil
})
if err != nil {
return Draft{}, err
}
if d.Created {
s.draftJob.Wake()
}
return d, nil
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), and the dinner must be
// a Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart)
if err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
}
day, err := store.ParseLocalDate(date)
if err != nil || !slices.Contains(dates[:], day) {
return Plan{}, &InvalidDayError{Value: date}
}
var p Plan
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
d, err := q.GetDinner(ctx, dinnerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrDinnerNotFound
}
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
}
if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status}
}
now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true},
IsNew: isNew(d.Status),
UpdatedAt: now,
PlanID: cur.ID,
Date: day.String(),
})
if err != nil {
return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err)
}
if n != 1 {
return fmt.Errorf("plan: %s has no day %s", cur.ID, day)
}
if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil {
return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err)
}
p, err = readPlan(ctx, q, weekStart)
return err
})
if err != nil {
return Plan{}, err
}
return p, nil
}
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
func isNew(status string) int64 {
if status == dinners.StatusQuestLog {
return 1
}
return 0
}
func fromDinner(d db.Dinner) Dinner {
out := Dinner{
ID: d.ID,
Name: d.Name,
Method: nullString(d.Method),
Origin: d.Origin,
}
if d.MinutesTotal.Valid {
out.Minutes = &d.MinutesTotal.Int64
}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.Photo = &u
}
return out
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+872
View File
@@ -0,0 +1,872 @@
package plan_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"slices"
"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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// week is the Monday the tests draft, and weekDates its days.
var (
week = mustDate("2026-10-12")
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
)
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
type env struct {
svc *plan.Service
st *store.Store
runner *jobs.Runner
model *llm.Fake
// ids are the seeded dinners' ids by name.
ids map[string]string
mu sync.Mutex
inputs []skills.WeekRequest
}
// newEnv returns a plan service over a migrated store in zone
// America/New_York with dinner time 17:30, the Regulars below and Quest
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
// with two workers. It drafts through the real harness and draft_week with
// the scripted e.model.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}}
for _, d := range []struct {
name, status, method string
minutes int64
}{
{"Beef Chili", "regular", "crockpot", 240},
{"Pot Roast", "regular", "crockpot", 0},
{"Chicken Fajitas", "regular", "stovetop", 30},
{"Spaghetti Bolognese", "regular", "stovetop", 45},
{"Sheet Pan Salmon", "regular", "oven", 35},
{"Pad Thai", "quest_log", "stovetop", 35},
{"Shawarma Bowls", "quest_log", "oven", 40},
} {
e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes)
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
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 e.model, nil },
Store: st,
Guard: guard,
}
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
e.mu.Lock()
e.inputs = append(e.inputs, in)
e.mu.Unlock()
return skills.DraftWeek(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// Two workers, as cmd/server runs, so drafts of a week could overlap.
e.runner, err = jobs.NewRunner(st, logger, 2)
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// input returns the request of the ith draft_week run.
func (e *env) input(i int) skills.WeekRequest {
e.mu.Lock()
defer e.mu.Unlock()
return e.inputs[i]
}
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// dinner stores a dinner and returns its id; minutes 0 is unknown.
func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string {
t.Helper()
now := store.Now().String()
origin := "regular"
if status != "regular" {
origin = "side_quest"
}
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: status,
Origin: origin,
Method: sql.NullString{String: method, Valid: method != ""},
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// reply scripts one draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) reply(note string, names ...string) {
e.model.QueueGenerate(e.weekReply(note, names...))
}
// weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
var days []string
for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast
// on Tuesday.
var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s still %s", id, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
// draft asks for a draft of week with confirm and waits for its job.
func (e *env) draft(t *testing.T, confirm bool) jobs.Job {
t.Helper()
d, err := e.svc.StartDraft(t.Context(), week, confirm)
if err != nil {
t.Fatal(err)
}
if !d.Created {
t.Fatalf("draft job %s was already queued", d.JobID)
}
return e.waitJob(t, d.JobID)
}
// mustDraft drafts week with confirm, requiring the job to succeed, and
// returns the plan.
func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan {
t.Helper()
job := e.draft(t, confirm)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("draft job %s, error %v", job.Status, *job.Error)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
var res struct {
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
}
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.WeekStart != week.String() || res.PlanID != p.ID {
t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID)
}
return p
}
// setStatus sets the plan of week to status, as Push to Calendar would.
func (e *env) setStatus(t *testing.T, status string) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`,
status, store.Now().String(), week.String()); err != nil {
t.Fatal(err)
}
}
func dayNames(p plan.Plan) []string {
var names []string
for _, d := range p.Days {
names = append(names, d.Dinner.Name)
}
return names
}
func TestDraftCreatesPlan(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("Five Regulars and two from your Quest log.", fullWeek...)
p := e.mustDraft(t, false)
if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" {
t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel)
}
if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." {
t.Errorf("ai_note = %v", p.AINote)
}
if p.CalendarSyncedAt != nil {
t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt)
}
if got := dayNames(p); !slices.Equal(got, fullWeek) {
t.Errorf("days = %v, want %v", got, fullWeek)
}
for i, d := range p.Days {
if d.Date != weekDates[i] {
t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i])
}
wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls"
if d.IsNew != wantNew {
t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew)
}
if d.Dinner.ID != e.ids[d.Dinner.Name] {
t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID)
}
}
chili := p.Days[0]
if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" {
t.Errorf("Beef Chili dinner = %+v", chili.Dinner)
}
// The pool is every Regular and Quest log dinner; last week has no
// plan.
in := e.input(0)
var pool []string
for _, c := range in.Pool {
pool = append(pool, c.Name+"/"+c.Kind)
}
slices.Sort(pool)
want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular",
"Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"}
if !slices.Equal(pool, want) {
t.Errorf("pool = %v, want %v", pool, want)
}
if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 {
t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek)
}
}
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
p := e.mustDraft(t, false)
newYork, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
// Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT.
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) {
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
}
// Pot Roast is a crock-pot dinner with no minutes; the rest are not
// crock-pot dinners.
for _, d := range p.Days[1:] {
if d.StartBy != nil {
t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy)
}
}
if p.AINote != nil {
t.Errorf("ai_note = %q, want null for an empty note", *p.AINote)
}
// start_by follows the household's dinner time.
dinnerTime := "18:45"
if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil {
t.Fatal(err)
}
p, err = e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) {
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
}
if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) {
t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err)
}
}
func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
e := newEnv(t)
// New York leaves daylight saving time at 02:00 on Sunday 2026-11-01.
// Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour
// crock-pot dinner on Sunday, so its start crosses the change.
stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200)
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
dst := mustDate("2026-10-26")
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"}
for i, date := range dates {
dinner := e.ids["Beef Chili"]
if i == 6 {
dinner = stew
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
p, err := e.svc.Get(t.Context(), dst)
if err != nil {
t.Fatal(err)
}
// Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and
// twenty hours before it is 22:30 EDT on Saturday.
for _, tc := range []struct {
day int
want string
}{
{5, `"2026-10-31T13:30:00-04:00"`},
{6, `"2026-10-31T22:30:00-04:00"`},
} {
b, err := json.Marshal(p.Days[tc.day].StartBy)
if err != nil || string(b) != tc.want {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
}
}
// Swapped to Beef Chili, Sunday starts at 13:30 EST.
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", e.ids["Beef Chili"]); err != nil {
t.Fatal(err)
}
if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err)
}
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Beef Chili start_by = %s, %v; want 13:30 EST", b, err)
}
}
func TestDraftGivesLastWeek(t *testing.T) {
e := newEnv(t)
e.start(t)
last := mustDate("2026-10-05")
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06",
DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.reply("", fullWeek...)
e.mustDraft(t, false)
if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) {
t.Errorf("last week = %v, want Chicken Fajitas", got)
}
}
func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai is on a later week's plan, so the Quest log no longer
// offers it; an eighth dinner keeps the pool at seven.
q := db.New(e.st.Writer)
now := store.Now().String()
later := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05",
DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0)
names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
e.reply("", names...)
p := e.mustDraft(t, false)
if got := dayNames(p); !slices.Equal(got, names) {
t.Errorf("days = %v", got)
}
for _, c := range e.input(0).Pool {
if c.Name == "Pad Thai" {
t.Errorf("pool has Pad Thai, planned on a later week")
}
}
// A redraft of the week keeps offering its own Quest log dinners.
e.reply("", names...)
e.mustDraft(t, false)
if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) {
t.Errorf("redraft pool left out Shawarma Bowls, planned on this week")
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t)
e.start(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
job := e.draft(t, false)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners)
}
if n := len(e.model.Requests()); n != 0 {
t.Errorf("model called %d times", n)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound", err)
}
}
func TestDraftFailsWhenAIFails(t *testing.T) {
e := newEnv(t)
e.start(t)
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
job := e.draft(t, false)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound", err)
}
}
func TestStartDraftIsSingleFlight(t *testing.T) {
e := newEnv(t)
first, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
again, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if !first.Created || again.Created || again.JobID != first.JobID {
t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again)
}
other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false)
if err != nil {
t.Fatal(err)
}
if !other.Created || other.JobID == first.JobID {
t.Errorf("another week's draft = %+v, want its own job", other)
}
}
func TestSimultaneousStartDraftsShareOneJob(t *testing.T) {
for _, confirmFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) {
e := newEnv(t)
first, err := e.svc.StartDraft(t.Context(), week, confirmFirst)
if err != nil {
t.Fatal(err)
}
// Twenty simultaneous drafts, half of them confirmed: every
// one joins the queued job, except a confirmed one while the
// job is unconfirmed, which is refused.
const n = 20
drafts := make([]plan.Draft, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1)
})
}
wg.Wait()
for i, d := range drafts {
if i%2 == 1 && !confirmFirst {
if !errors.Is(errs[i], plan.ErrDraftRunning) {
t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i])
}
continue
}
if errs[i] != nil || d.JobID != first.JobID || d.Created {
t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID)
}
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want 1", queued)
}
})
}
}
func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// An unconfirmed redraft is running when the plan is synced; the
// household then confirms a redraft. With a second worker idle, a
// second job would draft alongside the first, so the confirmed draft
// is refused and only the first job runs draft_week.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("unconfirmed", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
e.reply("concurrent", reversed...)
unconfirmed, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
e.setStatus(t, plan.StatusSynced)
if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) {
t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err)
}
close(release)
if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 2 {
t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
}
// Asked again once the unconfirmed job has finished, the confirmed
// draft replaces the plan.
if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) {
t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p))
}
}
func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// The household swaps Wednesday while draft_week runs; the draft must
// not erase the swap.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("second", reversed...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Sheet Pan Salmon"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
want := slices.Clone(fullWeek)
want[2] = "Sheet Pan Salmon"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
}
// A draft after the swap replaces the plan.
e.reply("third", reversed...)
if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) {
t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p))
}
}
func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
first := e.mustDraft(t, false)
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("second", reversed...)
second := e.mustDraft(t, false)
if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" {
t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID)
}
if got := dayNames(second); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
}
func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
for _, status := range []string{plan.StatusSynced, plan.StatusChanged} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
first := e.mustDraft(t, false)
e.setStatus(t, status)
// Push to Calendar records each day's event id.
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) {
t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want only the first", queued)
}
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("second", reversed...)
p := e.mustDraft(t, true)
if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil {
t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID)
}
if got := dayNames(p); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
var events int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
t.Fatal(err)
}
if events != 7 {
t.Errorf("%d days kept their calendar event id, want 7", events)
}
})
}
}
func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// The redraft is asked for while the plan is draft; the plan is synced
// while draft_week runs, so the draft must not replace it.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("second", reversed...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
e.setStatus(t, plan.StatusSynced)
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
}
}
func TestParseWeekStart(t *testing.T) {
if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week {
t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err)
}
// 2026-02-30 and 2027-02-29 would be Mondays were they dates.
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "next-week", ""} {
var invalid *plan.InvalidWeekError
if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) {
t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err)
}
}
}
func TestSwapDay(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300)
// A Quest log dinner is new, and a crock-pot one gets its start_by.
p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"])
if err != nil {
t.Fatal(err)
}
day := p.Days[2]
if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" {
t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day)
}
if p.Status != plan.StatusDraft {
t.Errorf("status = %s, want a swapped draft to stay draft", p.Status)
}
// A Regular in place of a Quest log dinner is not new.
p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"])
if err != nil {
t.Fatal(err)
}
if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil {
t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day)
}
want := slices.Clone(fullWeek)
want[2] = "Fish Tacos"
if got := dayNames(p); !slices.Equal(got, want) {
t.Errorf("days = %v, want %v", got, want)
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(dayNames(got), want) {
t.Errorf("Get days = %v, want %v", dayNames(got), want)
}
}
func TestSwapStatusTransitions(t *testing.T) {
for _, tc := range []struct{ from, want string }{
{plan.StatusDraft, plan.StatusDraft},
{plan.StatusSynced, plan.StatusChanged},
{plan.StatusChanged, plan.StatusChanged},
} {
t.Run(tc.from, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, tc.from)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"])
if err != nil {
t.Fatal(err)
}
if p.Status != tc.want {
t.Errorf("status after swap = %s, want %s", p.Status, tc.want)
}
})
}
}
func TestSwapRefusals(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, plan.StatusSynced)
suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30)
archived := e.dinner(t, "Old Stew", "archived", "", 0)
var notPlannable *plan.DinnerNotPlannableError
var badDay *plan.InvalidDayError
for _, tc := range []struct {
name string
week store.LocalDate
date, id string
wantError func(error) bool
}{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
{"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
{"impossible date", week, "2026-10-32", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
} {
if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) {
t.Errorf("%s: Swap = %v", tc.name, err)
}
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p))
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+5 -1
View File
@@ -30,7 +30,8 @@ type jobCreated struct {
}
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
// that is not service-only and returns 202 with its id, which
// GET /api/v1/jobs/{id} reports on.
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
@@ -43,6 +44,9 @@ func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
case errors.Is(err, jobs.ErrUnknownKind):
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
return
case errors.Is(err, jobs.ErrServiceOnly):
writeError(w, r, logger, http.StatusBadRequest, "service_only_kind", "job kind is started only by its own endpoint")
return
case errors.Is(err, jobs.ErrInvalidPayload):
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
return
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+135
View File
@@ -0,0 +1,135 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
// /plans/{weekStart}/days/{date} body.
const maxPlanBody = 4 << 10
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
// to draft over a plan pushed to Calendar.
type planDraft struct {
Confirm *bool `json:"confirm"`
}
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
type planDaySwap struct {
DinnerID *string `json:"dinner_id"`
}
// getPlan answers GET /plans/{weekStart} with the week's plan.
func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
p, err := svc.Get(r.Context(), week)
if err != nil {
writePlanError(w, r, logger, "read plan", err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// postPlanDraft enqueues the draft of the week and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
// a draft of the week is already queued or running, it answers 202 with
// that job instead, or 409 when this draft is confirmed and that one is
// not.
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
if err != nil {
writePlanError(w, r, logger, "start plan draft", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, d)
}
}
// putPlanDay swaps the dinner of one day of the week's plan and answers
// with the plan as swapped.
func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.DinnerID == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
return
}
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
if err != nil {
writePlanError(w, r, logger, "swap plan day", err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
// when it is not a Monday date.
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
return store.LocalDate{}, false
}
return week, true
}
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, or planning a dinner that is neither a Regular nor
// in the Quest log, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError
var week *plan.InvalidWeekError
var notPlannable *plan.DinnerNotPlannableError
switch {
case errors.As(err, &day):
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
case errors.As(err, &week):
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error())
case errors.Is(err, plan.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
case errors.Is(err, plan.ErrDinnerNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
+324
View File
@@ -0,0 +1,324 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"testing/fstest"
"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/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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const plansPath = "/api/v1/plans/"
// plansEnv is a router whose plan service has an unstarted job runner, so
// drafts stay queued, over a store holding a draft plan of the week of
// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular
// on every other day. questLog is the id of a Quest log dinner on no plan.
type plansEnv struct {
h http.Handler
st *store.Store
questLog string
}
func newPlansRouter(t *testing.T) plansEnv {
t.Helper()
st := newTestStore(t)
ctx := t.Context()
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
now := store.Now().String()
dinner := func(name, status, method string, minutes int64) string {
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular",
Method: sql.NullString{String: method, Valid: method != ""},
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12",
AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} {
id := dinner("Regular "+date, "regular", "stovetop", 30)
if i == 0 {
id = dinner("Beef Chili", "regular", "crockpot", 240)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil {
t.Fatal(err)
}
}
func TestGetPlan(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" ||
body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil {
t.Errorf("plan = %v", body)
}
days, _ := body["days"].([]any)
if len(days) != 7 {
t.Fatalf("days = %v, want 7", body["days"])
}
monday := days[0].(map[string]any)
if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" {
t.Errorf("Monday = %v", monday)
}
d := monday["dinner"].(map[string]any)
for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} {
if _, ok := d[key]; !ok {
t.Errorf("dinner has no %s: %v", key, d)
}
}
if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil {
t.Errorf("Monday dinner = %v", d)
}
if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil {
t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"])
}
rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body)
}
}
func TestPlanWeekStartMustBeMonday(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
for _, tc := range []struct{ method, target, body string }{
{http.MethodGet, plansPath + "2026-10-13", ""},
{http.MethodGet, plansPath + "not-a-date", ""},
{http.MethodGet, plansPath + "2026-02-30", ""},
{http.MethodPost, plansPath + "2027-02-29/draft", "{}"},
{http.MethodPost, plansPath + "2026-10-18/draft", "{}"},
{http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`},
} {
rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" {
t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body)
}
}
}
func TestPostPlanDraft(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
}
jobID, _ := body["jobId"].(string)
if jobID == "" || len(body) != 1 {
t.Fatalf("POST draft = %v, want only a jobId", body)
}
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" {
t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek)
}
// A second draft of the week joins the queued one.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`)
if rec.Code != http.StatusAccepted || body["jobId"] != jobID {
t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID)
}
// A week with no plan needs no confirm.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}")
if rec.Code != http.StatusAccepted || body["jobId"] == jobID {
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
}
// A confirmed draft does not join the unconfirmed one and enqueues
// nothing.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" {
t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 2 {
t.Errorf("%d draft jobs, want one per week", queued)
}
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body)
}
}
// TestDraftWeekNotEnqueuedThroughJobs checks POST /api/v1/jobs refuses a
// draft_week job, so a confirmed draft cannot run beside the week's
// unconfirmed one: POST /plans/{weekStart}/draft is the only way to start
// a draft.
func TestDraftWeekNotEnqueuedThroughJobs(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, _ := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, "/api/v1/jobs",
`{"kind":"`+plan.KindDraftWeek+`","payload":{"week_start":"2026-10-12","confirm":true}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Errorf("POST job %s = %d %v, want 400 service_only_kind", plan.KindDraftWeek, rec.Code, body)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want only the one POST draft enqueued", queued)
}
}
func TestSimultaneousPostPlanDraftsShareOneJob(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
const n = 10
recs := make([]*httptest.ResponseRecorder, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
recs[i] = httptest.NewRecorder()
e.h.ServeHTTP(recs[i], signedIn(http.MethodPost, plansPath+"2026-10-12/draft", strings.NewReader("{}")))
})
}
wg.Wait()
jobIDs := map[any]int{}
for i, rec := range recs {
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || rec.Code != http.StatusAccepted {
t.Fatalf("POST draft %d = %d %s, want 202", i, rec.Code, rec.Body)
}
jobIDs[body["jobId"]]++
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if len(jobIDs) != 1 || jobIDs[nil] != 0 || queued != 1 {
t.Errorf("jobIds = %v, %d draft jobs; want one jobId for all %d drafts", jobIDs, queued, n)
}
}
func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) {
t.Parallel()
for _, status := range []string{"synced", "changed"} {
e := newPlansRouter(t)
e.setStatus(t, status)
for _, b := range []string{"{}", `{"confirm":false}`} {
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b)
if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" {
t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body)
}
}
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusAccepted || body["jobId"] == nil {
t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body)
}
}
}
func TestPutPlanDay(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.setStatus(t, "synced")
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "changed" {
t.Errorf("status = %v, want a swapped synced plan changed", body["status"])
}
wednesday := body["days"].([]any)[2].(map[string]any)
if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" {
t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday)
}
for _, tc := range []struct {
target, body string
status int
code string
}{
{"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code)
}
}
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil {
t.Fatal(err)
}
rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" {
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+11 -2
View File
@@ -16,6 +16,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
@@ -41,12 +42,14 @@ type RouteMounter interface {
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
// /decline; runner serves
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft and PUT
// /api/v1/plans/{weekStart}/days/{date}; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -62,6 +65,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if suggestSvc == nil {
return nil, errors.New("server: nil suggestions service")
}
if planSvc == nil {
return nil, errors.New("server: nil plan service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -116,6 +122,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+38 -8
View File
@@ -23,6 +23,7 @@ import (
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
@@ -49,7 +50,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -185,6 +186,26 @@ func newTestSuggest(t *testing.T) *suggest.Service {
return svc
}
// newTestPlan returns a plan service over a migrated temp database, with
// its own unstarted job runner and a drafter that always fails.
func newTestPlan(t *testing.T) *plan.Service {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, logger)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
@@ -216,7 +237,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -225,7 +246,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -234,7 +255,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -243,7 +264,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -252,7 +273,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -261,16 +282,25 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+4 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision", got)
}
}
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
+6
View File
@@ -0,0 +1,6 @@
-- 0013_week_plan_revision: every edit of a week's plan — a swap or a
-- stored draft — raises its revision. A draft_week job reads the revision
-- before draft_week runs and stores its week only while the plan still
-- has it, so a draft never overwrites an edit made while the AI ran.
ALTER TABLE week_plans ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;
+86
View File
@@ -0,0 +1,86 @@
-- name: GetWeekPlan :one
-- The plan of the week starting week_start. Returns sql.ErrNoRows when
-- there is none.
SELECT * FROM week_plans
WHERE week_start = ?;
-- name: ListPlanDays :many
-- The days of plan plan_id with their dinners, Monday first. A day with
-- no dinner is left out.
SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners)
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
ORDER BY plan_days.date;
-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?);
-- name: RedraftWeekPlan :exec
-- Records a new draft over plan id: its status and note, raising its
-- revision.
UPDATE week_plans
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
WHERE id = ?;
-- name: PutPlanDay :exec
-- Plans dinner_id on date of plan_id, keeping the day's calendar event id
-- when the day exists.
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at;
-- name: SwapPlanDay :execrows
-- Plans dinner_id on an existing day of plan_id; a date the plan does not
-- have is left as it is (0 rows).
UPDATE plan_days
SET dinner_id = ?, is_new = ?, updated_at = ?
WHERE plan_id = ? AND date = ?;
-- name: MarkWeekPlanEdited :exec
-- Records an edit of plan id, raising its revision: a synced plan becomes
-- changed, any other keeps its status.
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE id = ?;
-- name: ListDraftPool :many
-- The dinners a draft of the week starting week_start may plan: every
-- Regular, and every Quest log dinner no other plan for the week starting
-- from_week_start or later has on any day.
SELECT * FROM dinners
WHERE status = 'regular'
OR (status = 'quest_log' AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= sqlc.arg(from_week_start)
AND week_plans.week_start <> sqlc.arg(week_start)
))
ORDER BY normalized_name, id;
-- name: ListWeekPlanDinnerIDs :many
-- The ids of the dinners planned in the week starting week_start, Monday
-- first.
SELECT plan_days.dinner_id
FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
ORDER BY plan_days.date;
-- name: GetActiveDraftJob :one
-- The job of kind queued or running for the week starting week_start,
-- with whether its payload has confirm. StartDraft enqueues a draft only
-- when there is none, so there is at most one. Returns sql.ErrNoRows when
-- there is none.
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
FROM jobs
WHERE kind = sqlc.arg(kind)
AND status IN ('queued', 'running')
AND json_extract(payload, '$.week_start') = sqlc.arg(week_start)
ORDER BY id
LIMIT 1;