Files
MealQuest/internal/plan/plan.go
T
dev_agentandClaude Opus 5.5 45fcd71d27
CI / ci (push) Successful in 3m26s
CI / ci (pull_request) Successful in 3m10s
Keep one draft in flight per week
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

421 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
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
}
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
}