Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 8d8a5480b9 Keep candidates found at a rename's revision when clearing offers
CI / ci (push) Successful in 2m42s
CI / ci (pull_request) Successful in 2m26s
A candidate search that read a dinner after its rename committed could
store fresh offers that the rename's own clear then dropped, so a valid
candidate for the renamed dinner was refused with a 400.

clearOffers now takes the revision below which offers are stale: a rename
passes its committed revision, so it drops only offers and in-flight
searches from earlier revisions, while grounding by hand and archiving
pass math.MaxInt64 and clear everything as before. A search finishing
late at an earlier revision no longer replaces offers already found at a
later one.

Fixes MQ-103-02.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:05:30 -04:00
dev_agentandClaude Opus 5.5 e07628638e Bind candidate offers to the dinner's grounding revision
CI / ci (pull_request) Successful in 2m32s
CI / ci (push) Successful in 2m40s
MQ-103-01: offers were bound only to the normalized name and cleared
only when it changed, so "Chicken curry" -> "Quick Chicken curry" kept
the old candidates live, and a search finishing after renames A -> B -> A
committed (before either cleared offers) stored candidates that matched
A again.

Every actual name edit now raises grounding_revision and clears the
dinner's offers. Each offer set records the revision its search read,
and a candidate grounds the dinner only while the dinner is still at
that revision; GroundDinner already requires the observed revision, so
a rename between the check and the write is a conflict. A pending
dinner renamed keeps getting a job at its new revision; recipe_url is
still cleared only when the normalized name changes.

Tests: a same-normalized-name rename refuses the old candidate and
attaches no recipe; an in-flight search finishing between the A -> B -> A
commits and their offer clearing stores candidates that are refused.
Both fail against 3c0a7fd.

Maestro task: 4fd737b2-fdcb-48ad-858a-ecdd95595e0c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:53:31 -04:00
dev_agentandClaude Opus 5.5 3c0a7fd398 Fix offline onboarding lockout and stale candidates after rename
CI / ci (push) Successful in 2m40s
CI / ci (pull_request) Successful in 2m28s
MQ-98-04: the household settings read that OnboardingGate makes before
every page now runs with networkMode 'always', like /me. After the
browser emits an offline event it fails into the saved pages instead of
pausing on the loading screen. A test emits a real offline event before
/me fails.

MQ-98-05: renaming a dinner now clears its candidate offers and makes a
running candidate search stale, and an offer is bound to the normalized
name it was searched for, so a candidate offered before a rename cannot
ground the renamed dinner. Regression tests cover a pending dinner
renamed after candidates were offered, and a rename during a running
search.

Maestro task: 4fd737b2-fdcb-48ad-858a-ecdd95595e0c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:36:31 -04:00
320 changed files with 1765 additions and 68952 deletions
+5 -9
View File
@@ -78,18 +78,17 @@ behaviour, exercise it (hit the endpoint, render the page).
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point. Wiring only — no business logic. |
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
| `internal/config` | `MEALQUEST_*` environment config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, routes, `/health`. |
| `internal/frontend` | `go:embed` of the built PWA with SPA fallback. |
| `internal/*` | `auth`, `forwarded`, `secureurl`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `ai/aisettings`, `suggest`, `plan`, `shopping`, `ideas`, `home`, `gcal`, `notify`, `push`, `chat`, `vapid`, `jobs`, `scheduler` — one line each in [ARCHITECTURE.md](docs/ARCHITECTURE.md#repository-layout). |
| `migrations/` | SQL migrations, embedded and applied at boot by `internal/store`; sqlc queries in `migrations/queries/`. |
| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. |
| `migrations/` | SQL migrations (planned). |
| `web/` | React PWA source. |
| `deploy/` | Deployment files: compose stack, systemd units, [deploy runbook](deploy/RUNBOOK.md) and the automated deploy (`auto-deploy.sh` with `systemd/mealquest-auto-deploy.*`). |
| `deploy/` | Deployment files (planned). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
| `Makefile` | `dev`, `test`, `build`, `docker`; `sqlc` and `sqlc-check` regenerate and check `internal/db`. |
| `Makefile` | `dev`, `test`, `build`, `docker`. |
| `.gitea/workflows/ci.yml` | CI quality floor. |
Design references:
@@ -105,10 +104,7 @@ Design references:
- **Task branches:** `task/<slug>_<task-uuid>`, branched from the feature branch.
- Task pull requests **squash-merge into the feature branch**.
- The feature pull request merges into `dev`.
- **Only `overseer` merges `dev` → `main`** (a release). On the production
host, `mealquest-auto-deploy.timer` then deploys the new `main` once its
CI passes ([deploy/RUNBOOK.md](deploy/RUNBOOK.md) "Automated deploys");
nothing deploys `dev` or a feature branch.
- **Only `overseer` merges `dev` → `main`** (a release).
- Never push directly to `dev` or `main`.
- Authors never review, approve, merge or close their own work.
- Never store credentials in the repository; environment or ignored files only.
+56 -37
View File
@@ -7,36 +7,65 @@ builds a weekly plan, pushes it to Google Calendar, and generates a shopping
list grouped by dinner, kept as a browsable weekly history. It ships as a
single Go service with the React PWA embedded in the binary.
Production runs at <https://mealquest.cubecraftlabs.com> for one household,
signed in through Authentik.
> Status: this repository is the Feature 01 scaffold (Go service with
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Stack
## Quick start
- **Backend:** Go 1.25 (`CGO_ENABLED=0`, one static binary), chi router,
SQLite through `modernc.org/sqlite` with sqlc queries and embedded
migrations, Authentik OIDC, `log/slog`. AI calls go to an
OpenAI-compatible, Anthropic or Ollama endpoint over plain HTTP; recipes
come from SearXNG or Brave search and the recipe page's schema.org data.
Google Calendar, Google Chat and Web Push are the integrations.
- **Frontend:** React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
`vite-plugin-pwa`, CSS Modules over the design tokens; tests with Vitest,
Testing Library and jsdom.
- **Deployment:** a Docker Compose stack (the service, Litestream
replication to the NAS, a SearXNG sidecar) behind Traefik, with systemd
units for the firewall, nightly media backup and the automated deploy of
`main`.
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
`engines` range of jsdom in the committed lockfile, the strictest of all the
web dependencies.
The approved dependency list is in [AGENTS.md](AGENTS.md); nothing else is
added without the owner's approval.
```sh
(cd web && npm ci)
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
chmod 700 ~/.mealquest-dev
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
chmod 600 ~/.mealquest-dev/secret_key
export MEALQUEST_HTTP_ADDR=:8080
export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
export MEALQUEST_OIDC_CLIENT_ID=mealquest
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
# export MEALQUEST_SESSION_IDLE=720h
# export MEALQUEST_SESSION_MAX=4320h
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
## Running it locally
The developer setup (prerequisites, Authentik and Google clients, the
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
runbook information and lives in CTXD: project `mealquest`, file
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
the environment, and the service refuses to start, naming every missing or
invalid variable.
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
with on first boot: until those settings exist, boot fails unless it is a
valid IANA zone. Later boots ignore it, set or not (change the zone in
household settings instead).
## Common commands
@@ -46,20 +75,10 @@ invalid variable.
| `make test` | `gofmt` check, `go vet`, `go test -race`, web typecheck and tests. |
| `make build` | Build the PWA, embed it, build the static binary `bin/mealquest`. |
| `make docker` | Build the container image from the root `Dockerfile`. |
| `make sqlc-check` | Regenerate `internal/db` with sqlc and fail if it drifted. |
## Deploying and operating
[deploy/RUNBOOK.md](deploy/RUNBOOK.md) is the deploy runbook: build, ship and
restart, verifying the deployed version, rollback and the automated deploy.
Database restore, the boot configuration and other host procedures are in
CTXD: project `mealquest`, file `RUNBOOKS.MD`. Only `overseer` releases, by
merging `dev` into `main`.
## Where to read next
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
floor every change must pass, repository layout, conventions.
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
-18
View File
@@ -19,7 +19,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -45,10 +44,6 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
googleSecretPath := filepath.Join(dir, "google_client_secret")
if err := os.WriteFile(googleSecretPath, []byte("google-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
t.Setenv(config.EnvPublicURL, "http://localhost")
t.Setenv(config.EnvDataDir, t.TempDir())
@@ -58,19 +53,6 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
t.Setenv(config.EnvOIDCClientID, "mealquest")
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
t.Setenv(config.EnvDefaultTZ, "")
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
+8 -185
View File
@@ -17,26 +17,16 @@ import (
"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/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -112,73 +102,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
notifier, err := notify.NewService(db, logger)
if err != nil {
return err
}
pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject)
if err != nil {
return err
}
if err := notifier.Register(notify.ChannelPush, pushSender); err != nil {
return err
}
chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport)
if err != nil {
return err
}
if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil {
return err
}
// The scheduler is made before the services whose changes wake it.
sched, err := scheduler.New(db, settings, logger)
if err != nil {
return err
}
dinnerSvc, err := dinners.NewService(db, grounder, runner, sched.Wake, logger)
if err != nil {
return err
}
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, notifier, logger)
if err != nil {
return err
}
googleSvc, err := gcal.NewService(db, box, gcal.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
Endpoint: gcal.GoogleEndpoint,
RevokeURL: gcal.GoogleRevokeURL,
CalendarAPI: gcal.GoogleCalendarAPI,
})
if err != nil {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
if err != nil {
return err
}
shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, notifier, chatSvc, logger)
if err != nil {
return err
}
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey, notifier)
if err != nil {
return err
}
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
if err != nil {
return err
}
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
if err != nil {
return err
}
@@ -199,53 +123,20 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil {
return err
}
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
return err
}
for _, sch := range planSvc.ReminderSchedules() {
if err := sched.Register(sch); err != nil {
return err
}
}
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
return err
}
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
return err
}
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
return err
}
purge, err := harness.PurgeSchedule(db, logger)
if err != nil {
return err
}
if err := sched.Register(purge); err != nil {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
// Start fails the jobs a previous process left running before any
// worker runs. The job workers, the notification worker and the
// scheduler stop, and are waited for, before the database closes.
workersCtx, stopWorkers := context.WithCancel(ctx)
// worker runs. The workers stop, and are waited for, before the
// database closes.
jobsCtx, stopJobs := context.WithCancel(ctx)
defer func() {
stopWorkers()
sched.Wait()
stopJobs()
runner.Wait()
notifier.Wait()
}()
if err := runner.Start(workersCtx); err != nil {
return err
}
if err := notifier.Start(workersCtx); err != nil {
if err := runner.Start(jobsCtx); err != nil {
return err
}
// With those jobs failed, a dinner still pending with no grounding job
@@ -254,11 +145,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
return err
}
// The scheduler starts last: its catch-up may enqueue jobs and
// notifications, so their workers must already run.
if err := sched.Start(workersCtx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
@@ -361,10 +247,7 @@ 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, plan.NewService
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
// starts (dinners.NewService registers dinners.KindGroundDinner).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -385,66 +268,6 @@ func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer
}
}
// sideQuests runs suggest_side_quests with the AI settings current at each
// run, sharing guard.
func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester {
return func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.SideQuests{}, err
}
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
}
// 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)
}
}
// archiveTitler runs archive_title with the AI settings current at each
// run, sharing guard.
func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler {
return func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.ArchiveTitle{}, err
}
return skills.TitleArchive(ctx, cfg, scope, in)
}
}
// ideaFinder runs ideas_from_ingredients with the AI settings current at
// each run, sharing guard.
func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.Ideas{}, err
}
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
}
// quickIdeaFinder runs quick_ideas with the AI settings current at each
// run, sharing guard.
func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator {
return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.QuickIdeaList{}, err
}
return skills.QuickIdeas(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) {
-39
View File
@@ -1,39 +0,0 @@
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
// line goes into deploy/.env as is; the private key line goes, alone, into
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
// unpadded base64url P-256 keys, the encoding config.Load validates.
//
// go run ./cmd/vapidgen
package main
import (
"fmt"
"io"
"os"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
func main() {
if len(os.Args) > 1 {
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
os.Exit(2)
}
if err := run(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// run writes a new key pair to w in four lines: a comment and the public
// key's environment line, then a comment and the private key alone.
func run(w io.Writer) error {
pair, err := vapid.Generate()
if err != nil {
return err
}
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
return err
}
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// TestRunPrintsAMatchingPair checks the output shape and that the printed
// private key line is a key config.Load accepts for the printed public key.
func TestRunPrintsAMatchingPair(t *testing.T) {
t.Parallel()
var out strings.Builder
if err := run(&out); err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
}
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
if !ok {
t.Fatalf("public key line = %q, want %s=<key>", lines[1], config.EnvVAPIDPublicKey)
}
pub, err := vapid.ParsePublicKey(public)
if err != nil {
t.Fatalf("printed public key: %v", err)
}
priv, err := vapid.ParsePrivateKey(lines[3])
if err != nil {
t.Fatalf("printed private key: %v", err)
}
if err := vapid.CheckPair(pub, priv); err != nil {
t.Error(err)
}
var second strings.Builder
if err := run(&second); err != nil {
t.Fatal(err)
}
if second.String() == out.String() {
t.Error("two runs printed the same key pair")
}
}
+30 -38
View File
@@ -1,16 +1,19 @@
# MealQuest production environment: the template for deploy/.env (never
# committed). The service fails fast: a missing or invalid required variable
# stops it at boot with the variable named. Creating deploy/.env: CTXD
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
# file".
# MealQuest production environment. Copy to deploy/.env (never committed)
# and replace the placeholders. The service fails fast: a missing or invalid
# required variable stops it at boot with the variable named.
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user 10001:10001 with mode
# 0600: secret_key, oidc_client_secret, google_client_secret and
# vapid_private_key. SearXNG's
# searxng.env is the exception: compose reads it itself, so it is owned by
# the host account that runs compose, mode 0600. Creating them: CTXD project
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
# one value per file, owned by the container user and readable only by it:
#
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
#
# SearXNG's secret key is the exception: compose reads it itself, so it
# stays owned by you (mode 0600). Create it after the chown above:
#
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
#
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
# *_FILE variables at them.
@@ -38,7 +41,8 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
# IANA zone the household settings are created with on first boot. Required
# until those settings exist; ignored afterwards (change the zone in
# household settings instead).
# household settings instead). Confirm the household's zone before the first
# deploy.
MEALQUEST_DEFAULT_TZ=America/New_York
# --- Authentik OIDC ----------------------------------------------------------
@@ -50,27 +54,6 @@ MEALQUEST_OIDC_CLIENT_ID=mealquest
# Comma-separated Authentik groups whose members may sign in.
MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# --- Google Calendar (Push to Calendar) ------------------------------------
# Client ID of the Google OAuth web client (CTXD project mealquest,
# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
# not start without it and the client secret file. Its authorized redirect
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# --- Web Push (VAPID) ------------------------------------------------------
# Required: the service will not start without the public key, the subject
# and the private key file, or with a private key that does not belong to
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
# Push)". Replacing the pair invalidates every browser's subscription.
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# The push services' contact for this sender: mailto:<address> or https://.
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
# Optional session lifetimes. A session ends after IDLE unused and always MAX
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
MEALQUEST_SESSION_IDLE=720h
@@ -84,12 +67,21 @@ MEALQUEST_SESSION_MAX=4320h
# Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM).
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
# The Google OAuth web client's secret.
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# The VAPID private key printed by `go run ./cmd/vapidgen`.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
# Added to deploy/compose.yaml and this file by the features that use them.
#
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
#
# Google Calendar OAuth web client (Feature 16).
# MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
+950 -325
View File
File diff suppressed because it is too large Load Diff
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env bash
# MealQuest automated deploy: deploy/RUNBOOK.md, run unattended.
#
# mealquest-auto-deploy deploy origin/main if it is new and CI passed
# mealquest-auto-deploy --ref <ref> deploy <ref> now, bypassing the CI and no-op gates
#
# Installed as /usr/local/sbin/mealquest-auto-deploy and run as overseer by
# mealquest-auto-deploy.service (deploy/systemd); install, semantics and the
# manual override: deploy/RUNBOOK.md "Automated deploys". Run as root (sudo)
# it re-executes itself as overseer.
#
# Without --ref: exit 0 with no output when origin/main is the last deployed
# SHA in the deploy record or its CI is pending; exit 1, deploying nothing,
# when its CI failed or reports no status, or when the last attempt to deploy
# that SHA failed. Every deploy runs the runbook's blocks in order and stops
# at the first failure; nothing before the restart touches production. A
# verified deploy appends one line to the deploy record:
# <UTC date> previous=<SHA> deployed=<SHA> restore_point=<UTC date>
# a failed one appends:
# <UTC date> FAILED target=<SHA> status=<exit status>
set -euo pipefail
CHECKOUT=/home/overseer/mealquest
RECORD=/home/overseer/mealquest-auto-deploy.log
LOCK=/home/overseer/mealquest-auto-deploy.lock
STATUS_API=https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits
PUBLIC=https://mealquest.cubecraftlabs.com
D=/var/lib/docker/volumes/mealquest-data/_data
COMPOSE=(docker compose -f deploy/compose.yaml)
fail() { echo "$*" >&2; exit 1; }
if [ "$(id -u)" -eq 0 ]; then
exec sudo -u overseer -H -- "$0" "$@"
fi
[ "$(id -un)" = overseer ] || fail "run as overseer (or root), not $(id -un)"
ref=
case "$#" in
0) ;;
2) [ "$1" = --ref ] && [ -n "$2" ] || fail "usage: $0 [--ref <ref>]"; ref=$2 ;;
*) fail "usage: $0 [--ref <ref>]" ;;
esac
exec 9>> "$LOCK"
flock -n 9 || fail "another mealquest-auto-deploy run holds $LOCK"
cd "$CHECKOUT"
git fetch --quiet --tags origin || fail "git fetch origin failed"
if [ -n "$ref" ]; then
target=$(git rev-parse --verify --quiet "$ref^{commit}") || fail "$ref is not a commit in $CHECKOUT"
else
# The full remote-tracking ref: a bare origin/main resolves a tag or local
# branch of that name first, which could deploy a commit not on main.
target=$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}") \
|| fail "refs/remotes/origin/main is not a commit in $CHECKOUT"
[ -f "$RECORD" ] || fail "no deploy record at $RECORD: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
last=$(grep -oE 'deployed=[0-9a-f]{40}' "$RECORD" | tail -n 1 | cut -d = -f 2) || true
[ -n "$last" ] || fail "$RECORD records no deploy: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
[ "$target" != "$last" ] || exit 0
if tail -n 1 "$RECORD" | grep -q " FAILED target=$target status="; then
fail "auto-deploy of $target already failed (see $RECORD and the journal): fix it, then deploy with --ref"
fi
state=$(curl -fsS --max-time 30 "$STATUS_API/$target/status" \
| python3 -c 'import json, sys; d = json.load(sys.stdin); print(d["state"] if d["total_count"] else "absent")') \
|| fail "could not read the CI status of $target from $STATUS_API"
case "$state" in
success) ;;
pending) exit 0 ;;
*) fail "CI for origin/main $target is $state, not success: not deploying" ;;
esac
fi
echo "auto-deploy: deploying $target${ref:+ (--ref $ref)}"
restarted=
W=
on_exit() {
local st=$?
[ -z "$W" ] || rm -rf "$W" || st=1
if [ "$st" -ne 0 ]; then
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAILED target=$target status=$st" >> "$RECORD"
if [ -n "$restarted" ]; then
echo "auto-deploy FAILED (status $st) after the restart: production may be down or unverified; read journalctl -u mealquest, then fix or roll back (deploy/RUNBOOK.md)" >&2
else
echo "auto-deploy FAILED (status $st): production untouched" >&2
fi
fi
exit "$st"
}
trap on_exit EXIT
# deploy/RUNBOOK.md "Deploy" > "1. Build": record the rollback SHA from the
# RUNNING release, check out the target, reject a dirty checkout, build.
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-) || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
case "$running" in
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
esac
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|| fail "running version $running is not a commit in this repository"
echo "rollback SHA: $rollback (running version $running)"
git checkout --quiet --detach "$target"
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
$dirty"
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
echo "built: mealquest:$v"
# deploy/RUNBOOK.md "Prerequisites": deploy/.env sets every variable of the
# target's .env.example, and every secret is mode 0600.
missing=$(comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u))
[ -z "$missing" ] || fail "deploy/.env is missing: $missing"
loose=$(sudo find deploy/secrets -type f ! -perm 600)
[ -z "$loose" ] || fail "secrets not mode 0600: $loose"
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 2: a verified
# pre-deploy restore point. The scripts are CTXD "Count check". They and the
# check files live in a private mktemp directory (mode 0700), not at shared
# /tmp paths, and python runs with -I: the snapshot runs as root, and a
# shared directory on its import path would let any local user run code as
# root.
W=$(mktemp -d)
cat > "$W/mqcount.py" <<'EOF'
import sqlite3, sys, hashlib
c = sqlite3.connect(sys.argv[1])
tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")]
for t in ("dinners", "week_plans", "schema_migrations"):
print(t, c.execute(f"select count(*) from {t}").fetchone()[0])
h = hashlib.sha256()
for t in tabs:
for row in c.execute(f"select * from \"{t}\" order by rowid"):
h.update(repr(row).encode())
print("tables", len(tabs), "content-sha256", h.hexdigest()[:16])
print("integrity", c.execute("pragma integrity_check").fetchone()[0])
EOF
cat > "$W/mqsnap.py" <<'EOF'
import os, sqlite3, sys
src, dst = sys.argv[1], sys.argv[2]
if os.path.exists(dst):
sys.exit(f"{dst} already exists")
s = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
d = sqlite3.connect(dst)
s.backup(d)
s.close()
result = d.execute("pragma integrity_check").fetchall()
d.close()
if result != [("ok",)]:
sys.exit(f"snapshot integrity_check failed: {result}")
print("snapshot", dst, "integrity ok")
EOF
point=$(
set -eu
point=
trap 'st=$?
rm -rf "$W" || st=1
sudo sh -c "rm -f $D/predeploy-check.db*" || st=1
if [ "$st" -eq 0 ] && [ -n "$point" ]; then echo "pre-deploy restore point: $point" >&2; echo "$point"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi
exit "$st"' EXIT
exec 3>&1 1>&2 # block output to the journal; only the restore point to $point
mkdir "$W/mqcheck"
# a. Replication running, no log errors (grep: 0 match, 1 none, 2+ error), newest LTX listed
id=$("${COMPOSE[@]}" ps --status running --quiet litestream) || fail "docker compose ps failed"
[ -n "$id" ] || fail "litestream is not running"
"${COMPOSE[@]}" logs --since 24h litestream > "$W/mqcheck/litestream.log" || fail "docker compose logs failed"
gs=0
grep -i error "$W/mqcheck/litestream.log" > "$W/mqcheck/litestream.errors" || gs=$?
case "$gs" in
0) cat "$W/mqcheck/litestream.errors"; fail "litestream logged errors in the last 24h";;
1) ;;
*) fail "the litestream log scan failed (status $gs)";;
esac
"${COMPOSE[@]}" exec -T litestream \
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
# b. Restore replica to a scratch file (live files untouched), move off the volume, snapshot live
"${COMPOSE[@]}" run --rm --no-deps litestream \
restore -config /etc/litestream.yml -o /data/predeploy-check.db /data/mealquest.db
sudo sh -c "mv $D/predeploy-check.db* $W/mqcheck/"
sudo python3 -I "$W/mqsnap.py" "$D/mealquest.db" "$W/mqcheck/live.db"
sudo chown -R "$(id -un)" "$W/mqcheck"
sudo ls -ln "$D"
# mealquest.db-wal and -shm owned by 10001, never 0 (CTXD "Count check")
root=$(sudo find "$D" -maxdepth 1 \( -name mealquest.db-wal -o -name mealquest.db-shm \) -uid 0)
[ -z "$root" ] || fail "owned by root, not 10001 (CTXD RUNBOOKS.MD \"Count check\"): $root"
# c. Restored replica: integrity ok and identical counts/content-sha256 to the live snapshot
python3 -I "$W/mqcount.py" "$W/mqcheck/predeploy-check.db" > "$W/mqcheck/restored.txt"
python3 -I "$W/mqcount.py" "$W/mqcheck/live.db" > "$W/mqcheck/live.txt"
cat "$W/mqcheck/restored.txt"
grep -qx 'integrity ok' "$W/mqcheck/restored.txt" || fail "the restored replica failed integrity_check"
diff "$W/mqcheck/restored.txt" "$W/mqcheck/live.txt" || fail "the restored replica differs from the live snapshot"
# d. Restore point: after every check, before the restart
point=$(date -u +%Y-%m-%dT%H:%M:%SZ)
exec 1>&3
)
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 3:
# boot-required configuration, secrets read the way the service reads them.
[ -f deploy/.env ] || fail "missing deploy/.env"
cid=$(grep -m 1 -E '^MEALQUEST_GOOGLE_CLIENT_ID=' deploy/.env | cut -d = -f 2- | tr -d '"' | tr -d "'") \
|| fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
[ -n "$cid" ] || fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
[ -e deploy/secrets/google_client_secret ] || fail "missing deploy/secrets/google_client_secret"
[ -s deploy/secrets/google_client_secret ] || fail "deploy/secrets/google_client_secret is empty"
id=$("${COMPOSE[@]}" ps --status running --quiet mealquest) || fail "docker compose ps failed"
[ -n "$id" ] || fail "the mealquest container is not running: start it (the check reads secrets the way the service does)"
docker exec "$id" sh -c 'test -s /run/secrets/google_client_secret && test -r /run/secrets/google_client_secret' \
|| fail "google_client_secret is not readable by the service user inside the container"
echo "boot-required configuration present"
# deploy/RUNBOOK.md "2. Ship and restart".
"${COMPOSE[@]}" pull litestream searxng
docker tag "mealquest:$v" mealquest-mealquest
restarted=yes
sudo systemctl restart mealquest
for s in mealquest litestream searxng; do
id=$("${COMPOSE[@]}" ps --status running --quiet "$s")
[ -n "$id" ] || fail "$s is not running"
done
id=$("${COMPOSE[@]}" ps --quiet mealquest)
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
echo "restarted on: mealquest:$v"
# deploy/RUNBOOK.md "3. Verify the deployed version and a real screen".
for base in "http://$bind:8090" "$PUBLIC"; do
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "$base/health did not return a version"
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
for path in / /plan; do
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
case "$type" in
text/javascript*) ;;
*) fail "$base$js is $type, not JavaScript" ;;
esac
done
echo "$base: version $got, PWA shell and $js served"
done
deployed=$(git rev-parse HEAD)
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) previous=$rollback deployed=$deployed restore_point=$point" >> "$RECORD"
echo "deployed and verified: $v ($deployed), previous $rollback, restore point $point"
+5 -4
View File
@@ -1,7 +1,8 @@
# Development override layered on deploy/compose.yaml: publishes SearXNG
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
# development".
# Development override: publishes SearXNG on the dev machine's loopback so
# it can be queried directly, e.g.
#
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
#
# Loopback only, never a LAN address. Never used in production: the
# systemd unit starts deploy/compose.yaml alone.
+12 -18
View File
@@ -1,16 +1,18 @@
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
# "Routine deploy and rollback".
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
# then start through systemd:
#
# docker compose -f deploy/compose.yaml build
# sudo systemctl restart mealquest
#
# The services use restart: "no" on purpose: Docker must not start them
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
# owns start and stop and requires mealquest-firewall.service first.
#
# Configuration comes from deploy/.env (template: deploy/.env.example) and
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
# file secrets with their host owner and mode, so each file in
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
# 0600 (except searxng.env, read by compose itself; see the searxng service).
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
# with their host owner and mode, so each file in deploy/secrets/ must be
# owned by the container user 10001:10001 with mode 0600 (except
# searxng.env, read by compose itself; see the searxng service).
name: mealquest
services:
@@ -30,14 +32,10 @@ services:
MEALQUEST_DATA_DIR: /data
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
MEALQUEST_SEARXNG_URL: http://searxng:8080
secrets:
- secret_key
- oidc_client_secret
- google_client_secret
- vapid_private_key
volumes:
- mealquest-data:/data
ports:
@@ -65,8 +63,8 @@ services:
# the app's own user (owner of the volume) with a read-only root
# filesystem, no privilege escalation and no Linux capabilities.
#
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
# the database".
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
# stack, move the database with its -wal and -shm aside, restore, check).
litestream:
image: litestream/litestream:0.5.17
user: "10001:10001"
@@ -130,10 +128,6 @@ secrets:
file: ./secrets/secret_key
oidc_client_secret:
file: ./secrets/oidc_client_secret
google_client_secret:
file: ./secrets/google_client_secret
vapid_private_key:
file: ./secrets/vapid_private_key
volumes:
mealquest-data:
@@ -1,23 +0,0 @@
# Deploys origin/main to production when it is new and its CI passed
# (/usr/local/sbin/mealquest-auto-deploy, from deploy/auto-deploy.sh).
# Triggered by mealquest-auto-deploy.timer; output goes to the journal.
# Installing it and the manual override: deploy/RUNBOOK.md "Automated deploys".
#
# ExecCondition= skips the run while mealquest.service is not active, so a
# stack stopped on purpose (a database or media restore) is never redeployed
# under the operator. It is deliberately not Requires=/Requisite= on
# mealquest.service: those would start the stack, or stop this run when the
# deploy itself restarts mealquest.service. Runs as overseer, who owns the
# checkout and uses sudo for the runbook's privileged steps.
[Unit]
Description=MealQuest automated deploy of main
Wants=network-online.target
After=network-online.target docker.service mealquest.service
[Service]
Type=oneshot
User=overseer
Group=overseer
WorkingDirectory=/home/overseer/mealquest
ExecCondition=/usr/bin/systemctl is-active --quiet mealquest.service
ExecStart=/usr/local/sbin/mealquest-auto-deploy
@@ -1,13 +0,0 @@
# Checks for a new main at every quarter hour (plus up to 5 minutes' random
# delay). Persistent= runs a check at once when the host comes back if a
# scheduled one was missed while it was down.
[Unit]
Description=Periodic MealQuest automated deploy
[Timer]
OnCalendar=*-*-* *:0/15:00
RandomizedDelaySec=5min
Persistent=true
[Install]
WantedBy=timers.target
+2 -3
View File
@@ -5,9 +5,8 @@
# are one-way: a failure here fails this unit visibly and never fails
# docker.service.
#
# mealquest.service requires this unit and starts the stack only after it
# has succeeded. Installing and operating both: CTXD project mealquest,
# RUNBOOKS.MD "Host firewall" and "Stack unit".
# Start MealQuest only after this unit has succeeded:
# systemctl start mealquest-firewall && docker compose up -d
[Unit]
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
After=docker.service
@@ -1,7 +1,8 @@
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
# Triggered by mealquest-media-backup.timer; output goes to the journal.
# Installing it and running it by hand: CTXD project mealquest,
# RUNBOOKS.MD "Media — nightly rsync".
# Triggered by mealquest-media-backup.timer; run by hand with
# systemctl start mealquest-media-backup
# and read the result with
# journalctl -u mealquest-media-backup
#
# The script runs as the app's user (10001, host user "mealquest") with no
# capabilities. systemd, as root, only bind-mounts the data volume's root
+2 -3
View File
@@ -15,9 +15,8 @@
# swapping the path afterwards cannot redirect it. Symlinks inside the
# directory are copied as links (-a), never followed.
#
# The media directory is created at deploy time (CTXD project mealquest,
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
# is never treated as "nothing to back up".
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
# is missing the job fails; it is never treated as "nothing to back up".
#
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
# a slash, so --delete removes files from that directory and nothing
+36 -871
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -3,7 +3,6 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
@@ -15,12 +14,10 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
-69
View File
@@ -1,5 +1,3 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -8,9 +6,6 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -26,82 +21,18 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+5 -5
View File
@@ -15,8 +15,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// PassedLimit is how many of the most recently passed names (Side quests
// passed and dinners a Rotation check dropped) the household context holds.
// PassedLimit is how many of the most recently passed Side quests the
// household context holds.
const PassedLimit = 60
// PlanWeeks is how many ISO weeks of plans, ending with the current week,
@@ -34,8 +34,8 @@ type HouseholdContext struct {
Regulars []RegularDinner
// QuestLog holds the Quest log dinner names, in name order.
QuestLog []string
// Passed holds the most recently passed names, oldest first: Side
// quests passed and dinners a Rotation check dropped ("Not for us").
// Passed holds the names of the most recently passed Side quests,
// oldest first.
Passed []string
// Plans are the planned dinners of the last PlanWeeks weeks, oldest
// first.
@@ -137,7 +137,7 @@ func readContext(ctx context.Context, st *store.Store, now time.Time) (h Househo
h.QuestLog = append(h.QuestLog, d.Name)
}
passed, err := q.ListRecentPassedNames(ctx, PassedLimit)
passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit)
if err != nil {
return h, fmt.Errorf("read passed Side quests: %w", err)
}
+3 -3
View File
@@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) {
batch := s.id("batch")
s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt)
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, name, reason, cited_regular_ids, status,
decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt)
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status,
decided_at, created_at, updated_at) VALUES (?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt)
}
// plan inserts the week plan starting weekStart with one dinner per date;
+5 -2
View File
@@ -34,8 +34,8 @@ const (
StatusProviderError RunStatus = "provider_error"
)
// RunRetention is how long ai_runs rows are kept. The daily purge,
// PurgeSchedule, calls Purge with its fire time less RunRetention.
// RunRetention is how long ai_runs rows are kept. The daily purge calls
// Purge with time.Now().Add(-RunRetention).
const RunRetention = 30 * 24 * time.Hour
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
@@ -46,6 +46,9 @@ var runSaveTimeout = 5 * time.Second
// Purge deletes the ai_runs rows created before olderThan and returns how
// many it deleted. The suggestion_batches and week_plans references to
// those rows are cleared first, in the same transaction.
//
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
// (Feature 20) must call it with time.Now().Add(-RunRetention).
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
if olderThan.IsZero() {
return 0, errors.New("harness: purge ai_runs: zero cutoff")
-46
View File
@@ -1,46 +0,0 @@
package harness
import (
"context"
"errors"
"log/slog"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
// It is fixed, not a setting.
const PurgeTime = "03:00"
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
// PurgeTime it deletes the ai_runs rows created more than RunRetention
// before the fire time with Purge, its period scheduler.Daily keyed
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
// than RunRetention; one that fails is the period's error and is not
// retried, and the next day's purge deletes what it left.
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
if st == nil {
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
}
if logger == nil {
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
}
return scheduler.Schedule{
Name: scheduler.AIRunsPurge,
Rule: func(household.Settings) scheduler.Rule {
return scheduler.Daily(PurgeTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
cutoff := p.FireAt.Add(-RunRetention)
n, err := Purge(ctx, st, cutoff)
if err != nil {
return err
}
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
return nil
},
}, nil
}
-148
View File
@@ -1,148 +0,0 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// TitleLimit is the most characters a Supply run's title may have.
const TitleLimit = 48
// ArchiveTitleRequest is what archive_title reads.
type ArchiveTitleRequest struct {
// Dinners are the names of the week's planned dinners in day order,
// Monday first. It must hold at least one, none blank.
Dinners []string
}
// ArchiveTitle is archive_title's output.
type ArchiveTitle struct {
// Title is the Supply run's post title in the Supply archive: 1 to
// TitleLimit characters on one line, with no emoji and no surrounding
// quotation marks.
Title string `json:"title"`
}
// TitleArchive runs archive_title v1 on in and returns the title of the
// week's Supply run, written from its dinners. A request with no dinners,
// or a blank one, fails before any LLM call. scope names the week and its
// dinners (see harness.Run).
func TitleArchive(ctx context.Context, cfg harness.Config, scope string, in ArchiveTitleRequest) (ArchiveTitle, error) {
return harness.Run(ctx, cfg, archiveTitle{}, scope, in)
}
// archiveTitle is the skill.
type archiveTitle struct{}
var _ harness.Skill[ArchiveTitleRequest, ArchiveTitle] = archiveTitle{}
func (archiveTitle) Name() string { return "archive_title" }
func (archiveTitle) Version() int { return 1 }
func (archiveTitle) Timeout() time.Duration { return harness.DefaultTimeout }
// archiveTitlePrompt is the template data of archive_title.v1.tmpl.
type archiveTitlePrompt struct {
// Dinners are the week's dinner names in day order, each on one line.
Dinners []string
}
func (archiveTitle) PromptInput(in ArchiveTitleRequest) (harness.PromptInput, error) {
if len(in.Dinners) == 0 {
return harness.PromptInput{}, errors.New("the week has no dinners to title it from")
}
var p archiveTitlePrompt
for i, name := range in.Dinners {
name = oneLine(name)
if name == "" {
return harness.PromptInput{}, fmt.Errorf("dinner %d has a blank name", i+1)
}
p.Dinners = append(p.Dinners, name)
}
return harness.PromptInput{Data: p}, nil
}
func (archiveTitle) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "archive_title", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes the output, requiring its field as the schema
// does.
func (t *ArchiveTitle) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "title"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain ArchiveTitle
return json.Unmarshal(data, (*plain)(t))
}
// quotes are the quotation marks a title may not start or end with.
const quotes = `"'` + "`" + "“”‘’„‚«»‹›「」『』"
// Validate finds every broken rule of the title as PostProcess will store
// it, on one line: empty, over TitleLimit characters, holding an emoji, or
// starting or ending with a quotation mark.
func (archiveTitle) Validate(out ArchiveTitle) []string {
var problems []string
title := oneLine(out.Title)
switch n := utf8.RuneCountInString(title); {
case n == 0:
problems = append(problems, fmt.Sprintf("title is empty; give a title of 1 to %d characters", TitleLimit))
case n > TitleLimit:
problems = append(problems, fmt.Sprintf("title %q has %d characters; shorten it to at most %d", title, n, TitleLimit))
}
for _, r := range title {
if isEmoji(r) {
problems = append(problems, fmt.Sprintf("title %q has an emoji (%U); use words only", title, r))
break
}
}
if first, _ := utf8.DecodeRuneInString(title); title != "" && strings.ContainsRune(quotes, first) {
problems = append(problems, fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", title))
} else if last, _ := utf8.DecodeLastRuneInString(title); title != "" && strings.ContainsRune(quotes, last) {
problems = append(problems, fmt.Sprintf("title %q ends with a quotation mark; give the title without quotation marks around it", title))
}
return problems
}
// isEmoji reports whether r is an emoji or a part of one: a pictograph,
// a symbol from the emoji blocks, a regional indicator, a keycap, a skin
// tone, an emoji variation selector or the zero-width joiner.
func isEmoji(r rune) bool {
switch {
case r >= 0x1F000 && r <= 0x1FAFF: // tiles, cards, enclosed, pictographs, emoticons, transport, supplemental symbols
return true
case r >= 0x2600 && r <= 0x27BF: // miscellaneous symbols and dingbats
return true
case r >= 0x2B00 && r <= 0x2BFF: // arrows and stars such as ⭐ and ⬛
return true
case r >= 0x2300 && r <= 0x23FF: // technical symbols such as ⌚ and ⏰
return true
case r >= 0xE0020 && r <= 0xE007F: // tag sequences of flags
return true
case r == 0x200D, r == 0x20E3, r == 0xFE0F: // joiner, keycap, emoji presentation
return true
}
return false
}
// PostProcess puts the title on one line, collapsing its white space.
func (archiveTitle) PostProcess(out ArchiveTitle) (ArchiveTitle, error) {
return ArchiveTitle{Title: oneLine(out.Title)}, nil
}
-170
View File
@@ -1,170 +0,0 @@
package skills
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// titleWeek is a week of seven dinners, Monday first.
var titleWeek = ArchiveTitleRequest{Dinners: []string{
"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Fish Tacos",
}}
func titleReply(title string) llm.GenerateResult {
return reply(fmt.Sprintf(`{"title":%q}`, title))
}
// title runs TitleArchive on in with f and checks f was called exactly
// calls times, each at the household temperature with the archive_title
// schema.
func title(t *testing.T, f *llm.Fake, in ArchiveTitleRequest, calls int) (ArchiveTitle, []llm.Request, error) {
t.Helper()
out, err := TitleArchive(context.Background(), testConfig(t, f), "2026-10-12", 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 != "archive_title" {
t.Fatalf("request %d schema = %v, want archive_title", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestArchiveTitleValid(t *testing.T) {
var f llm.Fake
f.QueueGenerate(titleReply(" Slow Simmers\nand Taco Night "))
out, reqs, err := title(t, &f, titleWeek, 1)
if err != nil {
t.Fatal(err)
}
if out.Title != "Slow Simmers and Taco Night" {
t.Fatalf("title = %q", out.Title)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "archive_title_valid.system.golden", system)
golden(t, "archive_title_valid.user.golden", user)
want := "Title the week whose dinners are, in day order from Monday:\n" +
"- Beef Chili\n- Pot Roast\n- Chicken Fajitas\n- Spaghetti Bolognese\n- Sheet Pan Salmon\n- Pad Thai\n- Fish Tacos"
if user != want {
t.Errorf("user message = %q, want %q", user, want)
}
if !strings.Contains(system, "## Output contract") || !strings.Contains(system, `"title"`) {
t.Error("system prompt lacks the output contract")
}
}
// titleRepaired checks a first reply is repaired once, quoting it and the
// problem wanted.
func titleRepaired(t *testing.T, first, want string) {
t.Helper()
var f llm.Fake
f.QueueGenerate(titleReply(first), titleReply("Chili, Roast and Taco Night"))
out, reqs, err := title(t, &f, titleWeek, 2)
if err != nil {
t.Fatal(err)
}
if out.Title != "Chili, Roast and Taco Night" {
t.Fatalf("title = %q", out.Title)
}
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != fmt.Sprintf(`{"title":%q}`, first) {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
if !strings.Contains(repair[2].Content, want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
}
}
func TestArchiveTitleTooLongRepaired(t *testing.T) {
long := "A Week of Slow Simmers, Sizzling Fajitas and Taco Night" // 55 characters
titleRepaired(t, long, fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", long))
}
func TestArchiveTitleEmojiRepaired(t *testing.T) {
titleRepaired(t, "Taco Night 🌮", `title "Taco Night 🌮" has an emoji (U+1F32E); use words only`)
}
func TestArchiveTitleQuotedRepaired(t *testing.T) {
titleRepaired(t, `"Taco Night"`, `title "\"Taco Night\"" starts with a quotation mark; give the title without quotation marks around it`)
}
func TestArchiveTitleEmojiTwiceFails(t *testing.T) {
var f llm.Fake
f.QueueGenerate(titleReply("Chili Week 🔥"), titleReply("Chili Week ❤️"))
out, _, err := title(t, &f, titleWeek, 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.Equal(bad.Repair, []string{`title "Chili Week ❤️" has an emoji (U+2764); use words only`}) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Title != "" {
t.Fatalf("title = %q, want none", out.Title)
}
}
func TestArchiveTitleRules(t *testing.T) {
s := archiveTitle{}
for _, tc := range []struct {
title string
want []string
}{
{"Taco Night", nil},
{strings.Repeat("a", TitleLimit), nil},
{"Crème Brûlée & Co.", nil},
{"Fajitas — Then Tacos", nil},
{"Mom's Pot Roast Week", nil},
{" Spaced out ", nil},
{"", []string{"title is empty; give a title of 1 to 48 characters"}},
{" \n ", []string{"title is empty; give a title of 1 to 48 characters"}},
{strings.Repeat("é", TitleLimit+1), []string{fmt.Sprintf("title %q has 49 characters; shorten it to at most 48", strings.Repeat("é", TitleLimit+1))}},
{"Hot ☕ Week", []string{`title "Hot ☕ Week" has an emoji (U+2615); use words only`}},
{"Star ⭐ Week", []string{`title "Star ⭐ Week" has an emoji (U+2B50); use words only`}},
{"Go 🇮🇹", []string{`title "Go 🇮🇹" has an emoji (U+1F1EE); use words only`}},
{"‘Taco Night’", []string{`title "‘Taco Night’" starts with a quotation mark; give the title without quotation marks around it`}},
{"Taco Night”", []string{`title "Taco Night”" ends with a quotation mark; give the title without quotation marks around it`}},
{"«Pasta» 🍝 " + strings.Repeat("x", 45), []string{
fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
fmt.Sprintf("title %q has an emoji (U+1F35D); use words only", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
}},
} {
if got := s.Validate(ArchiveTitle{Title: tc.title}); !slices.Equal(got, tc.want) {
t.Errorf("Validate(%q) =\n%q\nwant\n%q", tc.title, got, tc.want)
}
}
}
func TestArchiveTitleBadRequestFailsBeforeCall(t *testing.T) {
for name, tc := range map[string]struct {
in ArchiveTitleRequest
want string
}{
"no dinners": {ArchiveTitleRequest{}, "the week has no dinners to title it from"},
"blank dinner": {ArchiveTitleRequest{Dinners: []string{"Pad Thai", " \n"}}, "dinner 2 has a blank name"},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
_, _, err := title(t, &f, tc.in, 0)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want one containing %q", err, tc.want)
}
})
}
}
-328
View File
@@ -1,328 +0,0 @@
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
@@ -1,324 +0,0 @@
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,356 +0,0 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
const (
// IdeasRequested is how many ideas ideas_from_ingredients asks the
// model for; validation and grounding drop some.
IdeasRequested = 8
// IdeasKept is the most ideas the household is shown.
IdeasKept = 5
// MaxIngredients is the most ingredients the household may list.
MaxIngredients = 20
// IngredientLimit is the most characters one listed ingredient may
// have.
IngredientLimit = 80
)
// IdeasRequest is what ideas_from_ingredients reads.
type IdeasRequest struct {
// Ingredients are the ingredients the household has on hand, in its
// order, as CleanIngredients returns them.
Ingredients []string
// QuickOnly asks the model for Quick dinners only. It is a hint: the
// caller enforces it after grounding, from the grounded recipe.
QuickOnly bool
}
// Idea is one dinner idea. Method, EstMinutes and Difficulty are the
// model's estimates until the idea is grounded in a recipe.
type Idea struct {
Name string `json:"name"`
// Uses are the household's ingredients the idea uses, as the household
// wrote them and in its order. PostProcess drops every entry that is
// not one of them.
Uses []string `json:"uses"`
// Missing are the ingredients the idea needs that the household does
// not have, in the model's order.
Missing []string `json:"missing"`
EstMinutes int `json:"est_minutes"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
// Summary is the line the household reads under the idea, computed
// by IdeaSummary from Uses and Missing. It is never part of the
// model's reply.
Summary string `json:"-"`
}
// Ideas is ideas_from_ingredients' output.
type Ideas struct {
// Ideas are the ideas that passed every rule, in the model's order.
Ideas []Idea `json:"ideas"`
// Dropped are the ideas the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedIdea `json:"-"`
}
// DroppedIdea is an idea dropped by PostProcess.
type DroppedIdea struct {
Name string
// Why says which rule it broke.
Why string
}
// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns
// ideas whose uses hold only the household's own ingredients: an entry
// naming anything else is removed, and an idea left using none of them is
// dropped, as is one repeating an earlier idea. Each kept idea carries its
// summary line, computed from the corrected uses and missing lists. scope
// names the request (see harness.Run; IdeasScope). An ingredient list
// CleanIngredients refuses fails before any LLM call.
func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) {
ings, err := CleanIngredients(in.Ingredients)
if err != nil {
return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err)
}
in.Ingredients = ings
return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in)
}
// IdeasScope is the harness scope of a request: its normalized
// ingredients, in order, and whether it is for Quick dinners only, so two
// requests share a run only when the model would be asked the same.
func IdeasScope(in IdeasRequest) string {
parts := make([]string, len(in.Ingredients))
for i, ing := range in.Ingredients {
parts[i] = harness.Normalize(ing)
}
return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|")
}
// CleanIngredients returns the household's ingredient list with each entry
// on one line, or why it cannot be used: it is empty or longer than
// MaxIngredients, or an entry is blank, longer than IngredientLimit
// characters, has nothing left once normalized (harness.Normalize), or
// normalizes the same as an earlier one.
func CleanIngredients(list []string) ([]string, error) {
if len(list) == 0 {
return nil, errors.New("list at least one ingredient")
}
if len(list) > MaxIngredients {
return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients)
}
out := make([]string, len(list))
seen := map[string]string{}
for i, ing := range list {
ing = oneLine(ing)
switch {
case ing == "":
return nil, fmt.Errorf("ingredient %d is blank", i+1)
case utf8.RuneCountInString(ing) > IngredientLimit:
return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit)
}
n := harness.Normalize(ing)
if n == "" {
return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing)
}
if first, ok := seen[n]; ok {
return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first)
}
seen[n] = ing
out[i] = ing
}
return out, nil
}
// IdeaSummary is the line shown under an idea: how many of the household's
// total ingredients it uses, then what is missing, in the order given.
// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of
// 5 · missing: 1 lime, cumin" when some are. With nothing missing the
// second part is "nothing missing", so an idea using all 5 and needing
// nothing else prints "Uses all 5 · nothing missing".
func IdeaSummary(uses, missing []string, total int) string {
used := fmt.Sprintf("Uses %d of %d", len(uses), total)
if len(uses) == total {
used = fmt.Sprintf("Uses all %d", total)
}
if len(missing) == 0 {
return used + " · nothing missing"
}
return used + " · missing: " + strings.Join(missing, ", ")
}
// ideasFromIngredients is the skill. It holds its input because
// post-processing compares the output with it.
type ideasFromIngredients struct {
in IdeasRequest
}
var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{}
func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" }
func (ideasFromIngredients) Version() int { return 1 }
func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout }
// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl.
type ideasPrompt struct {
// Count is how many ideas to ask for.
Count int
QuickOnly bool
}
// PromptInput puts the ingredients, already cleaned by
// IdeasFromIngredients, in the household context block.
func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) {
return harness.PromptInput{
Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly},
Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}},
}, nil
}
func (ideasFromIngredients) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"uses": {"type": "array", "items": {"type": "string"}},
"missing": {"type": "array", "items": {"type": "string"}},
"est_minutes": {"type": "integer"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"search_query": {"type": "string"}
},
"required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"],
"additionalProperties": false
}
}
},
"required": ["ideas"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes an idea, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (i *Idea) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil {
return fmt.Errorf("idea %s", err)
}
type plain Idea
return json.Unmarshal(data, (*plain)(i))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *Ideas) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "ideas"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain Ideas
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: no ideas, too
// many, or one that is malformed. A well-formed idea that breaks a rule is
// dropped by PostProcess instead.
func (ideasFromIngredients) Validate(out Ideas) []string {
var problems []string
switch n := len(out.Ideas); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested))
case n > IdeasRequested:
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested))
}
for i, idea := range out.Ideas {
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
}
if !slices.Contains(methods, idea.Method) {
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
}
if idea.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
}
if !slices.Contains(difficulties, idea.Difficulty) {
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess corrects every idea against the household's ingredients and
// moves each that breaks a rule to Dropped. Uses keeps only entries whose
// normalized form (harness.Normalize) is that of a listed ingredient,
// written as the household wrote it, once each and in the household's
// order; anything else the model claimed is removed, never trusted.
// Missing loses blank entries, repeats and anything the household listed.
// An idea is dropped when it repeats an earlier idea or uses none of the
// household's ingredients. The summary line is computed from the
// corrected lists.
func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) {
listed := map[string]int{}
for i, ing := range s.in.Ingredients {
listed[harness.Normalize(ing)] = i
}
result := Ideas{Ideas: []Idea{}}
for _, idea := range out.Ideas {
idea = Idea{
Name: oneLine(idea.Name),
Uses: s.uses(idea.Uses, listed),
Missing: missing(idea.Missing, listed),
EstMinutes: idea.EstMinutes,
Method: idea.Method,
Difficulty: idea.Difficulty,
SearchQuery: oneLine(idea.SearchQuery),
}
if why := dropIdea(idea, result.Ideas); why != "" {
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
continue
}
idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients))
result.Ideas = append(result.Ideas, idea)
}
return result, nil
}
// uses returns the household's ingredients that claimed names, as the
// household wrote them and in its order. A claimed entry that names none
// of them is left out.
func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string {
used := make([]bool, len(s.in.Ingredients))
for _, c := range claimed {
if i, ok := listed[harness.Normalize(c)]; ok {
used[i] = true
}
}
out := []string{}
for i, ing := range s.in.Ingredients {
if used[i] {
out = append(out, ing)
}
}
return out
}
// missing returns claimed on one line each, in order, without blank
// entries, repeats or any the household listed.
func missing(claimed []string, listed map[string]int) []string {
out := []string{}
seen := map[string]bool{}
for _, c := range claimed {
c = oneLine(c)
n := harness.Normalize(c)
if n == "" || seen[n] {
continue
}
if _, ok := listed[n]; ok {
continue
}
seen[n] = true
out = append(out, c)
}
return out
}
// dropIdea returns why idea must be dropped, or "" when it may be kept.
func dropIdea(idea Idea, kept []Idea) string {
for _, k := range kept {
if harness.Similar(idea.Name, k.Name) {
return fmt.Sprintf("repeats the idea %q", k.Name)
}
}
if len(idea.Uses) == 0 {
return "uses none of the household's ingredients"
}
return ""
}
@@ -1,269 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// pantry is the household's ingredient list in its order.
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
// idea renders one idea of an ideas_from_ingredients reply.
func idea(name, method string, minutes int, uses, missing []string) string {
u, _ := json.Marshal(append([]string{}, uses...))
m, _ := json.Marshal(append([]string{}, missing...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
}
// ideasReply renders a reply holding the ideas given.
func ideasReply(ideas ...string) string {
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
}
// ideas runs IdeasFromIngredients on in with f and checks f was called
// exactly calls times, each at the household temperature.
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
t.Helper()
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), 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 != "ideas_from_ingredients" {
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestIdeasFromIngredientsPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
_, reqs, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "ideas_from_ingredients_any.system.golden", system)
golden(t, "ideas_from_ingredients_any.user.golden", user)
for _, part := range []string{
"## Household context",
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
"## Output contract",
`"uses"`,
`"missing"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Contains(system, "Quick dinners only") {
t.Error("an Any time request asks for Quick dinners only")
}
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
t.Errorf("user message = %q", user)
}
}
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
in := pantry
in.QuickOnly = true
_, reqs, err := ideas(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
}
}
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// Every ingredient, named in another order and spelling: uses is
// in the household's order, as the household wrote it.
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
// Some of them.
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
// Every ingredient, nothing missing.
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
// Some, nothing missing.
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Dropped) != 0 {
t.Fatalf("dropped %+v", out.Dropped)
}
got := map[string][2]any{}
for _, i := range out.Ideas {
got[i.Name] = [2]any{i.Uses, i.Summary}
}
want := map[string][2]any{
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
}
}
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// The model claims parmesan and lime, which the household never
// listed, and repeats rice.
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
// Uses only ingredients the household never listed.
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
// Lists an ingredient on hand as missing.
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
want := Ideas{
Ideas: []Idea{
{
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
Summary: "Uses 2 of 5 · missing: 1 cup broth",
},
{
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
Summary: "Uses 1 of 5 · missing: tortillas",
},
},
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("output = %+v\nwant %+v", out, want)
}
}
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
t.Fatalf("ideas = %+v", out.Ideas)
}
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
}
}
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
out, reqs, err := ideas(t, &f, pantry, 2)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 {
t.Fatalf("ideas = %+v", out.Ideas)
}
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
if !strings.Contains(repair, part) {
t.Errorf("repair turn lacks %q:\n%s", part, repair)
}
}
}
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
var f llm.Fake
var nine []string
for i := range 9 {
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
}
f.QueueGenerate(reply(ideasReply(nine...)))
f.QueueGenerate(reply(ideasReply(nine...)))
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
t.Fatalf("err = %v, want a bad output naming the limit", err)
}
}
func TestCleanIngredients(t *testing.T) {
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
if err != nil {
t.Fatal(err)
}
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CleanIngredients = %q, want %q", got, want)
}
tooMany := make([]string, MaxIngredients+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("thing %d", i)
}
for _, tc := range []struct {
name string
in []string
want string
}{
{"empty", nil, "list at least one ingredient"},
{"too many", tooMany, "list at most 20"},
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
{"filler only", []string{"the"}, "names no ingredient"},
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
var f llm.Fake
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
t.Fatal("a repeated ingredient was accepted")
}
}
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
t.Errorf("scopes of the same list differ: %q, %q", a, b)
}
for _, other := range []IdeasRequest{
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
{Ingredients: []string{"black beans", "Rice"}},
{Ingredients: []string{"Rice"}},
} {
if IdeasScope(other) == a {
t.Errorf("%+v shares the scope %q", other, a)
}
}
}
@@ -1,22 +0,0 @@
{{/* archive_title titles a week's Supply run for the Supply archive, from the week's dinners. */}}
{{define "instructions" -}}
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
Rules:
- At most 48 characters, on one line.
- Words only: no emoji.
- No quotation marks around the title.
- Do not name a date or week number; the archive shows those.
Give:
- title: the title.
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
{"title": "Slow Simmers and Taco Night"}
{{- end}}
{{define "user" -}}
Title the week whose dinners are, in day order from Monday:
{{- range .Dinners}}
- {{.}}
{{- end}}
{{- end}}
-5
View File
@@ -3,8 +3,3 @@
# version, rename its template to match and add a line for the new version.
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
@@ -1,21 +0,0 @@
{{/* 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}}
@@ -1,25 +0,0 @@
{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}}
{{define "instructions" -}}
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
{{- if .QuickOnly}}
The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.
{{- end}}
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
{{- end}}
{{define "user" -}}
Suggest {{.Count}} dinner ideas from the ingredients on hand.
{{- end}}
@@ -1,34 +0,0 @@
{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}}
{{define "instructions" -}}
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
{{- if .Crockpot}}
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
{{- else}}
Every idea must take 30 minutes or less from starting prep to the table.
{{- end}}
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea (an example only, not about this household):
{{- if .Crockpot}}
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
{{- else}}
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
{{- end}}
{{- end}}
{{define "user" -}}
{{- if .Crockpot -}}
Suggest {{.Count}} crock pot dinner ideas.
{{- else -}}
Suggest {{.Count}} dinner ideas ready in 30 minutes or less.
{{- end}}
{{- end}}
@@ -1,22 +0,0 @@
{{/* suggest_side_quests drafts Side quests: new dinners for the household to try, each based on its Regulars. */}}
{{define "instructions" -}}
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
Give for each Side quest:
- name: the dinner's usual name, as a recipe site would title it.
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- difficulty: "easy", "medium" or "hard" for a home cook.
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
- search_query: a web search query that finds a recipe for this dinner.
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
{{- end}}
{{define "user" -}}
Suggest {{.Count}} Side quests.
{{- end}}
-297
View File
@@ -1,297 +0,0 @@
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"
)
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
// dinners screen.
const (
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
QuickCrockpot = "crockpot"
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
QuickUnder30 = "under30"
)
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
const (
// QuickIdeasRequested is how many ideas quick_ideas asks the model
// for: more than a page holds, since validation, the exclude list,
// grounding and the kind's check drop some.
QuickIdeasRequested = 12
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
QuickIdeasKept = 6
// MaxExclude is the most names already shown a request may exclude.
MaxExclude = 60
// ExcludeLimit is the most characters one excluded name may have.
ExcludeLimit = 120
)
// QuickIdeasRequest is what quick_ideas reads.
type QuickIdeasRequest struct {
// Context is the household context (see harness.BuildContext): the
// ideas are picked from the household's tastes.
Context harness.HouseholdContext
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
// the caller enforces it after grounding, from the grounded recipe.
Kind string
// Exclude are the names already shown to the household, as
// CleanExclude returns them. No idea repeating one is returned.
Exclude []string
}
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
// are the model's estimates until the idea is grounded in a recipe.
type QuickIdea struct {
Name string `json:"name"`
EstMinutes int `json:"est_minutes"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
}
// QuickIdeaList is quick_ideas' output.
type QuickIdeaList struct {
// Ideas are the ideas that passed every rule, in the model's order.
Ideas []QuickIdea `json:"ideas"`
// Dropped are the ideas the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedIdea `json:"-"`
}
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
// excluded name nor each other (harness.Similar, on the normalized
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
// decided here: the model's estimates never decide it, the grounded
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
// fails before any LLM call.
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
if err := CheckQuickKind(in.Kind); err != nil {
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
}
exclude, err := CleanExclude(in.Exclude)
if err != nil {
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
}
in.Exclude = exclude
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
}
// QuickIdeasScope is the harness scope of a request: its kind and its
// normalized excluded names, in order, so two requests share a run only
// when they ask for the same page.
func QuickIdeasScope(in QuickIdeasRequest) string {
parts := make([]string, len(in.Exclude))
for i, name := range in.Exclude {
parts[i] = harness.Normalize(name)
}
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
}
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
// is no default kind.
func CheckQuickKind(kind string) error {
if !slices.Contains(QuickKinds, kind) {
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
}
return nil
}
// CleanExclude returns the excluded names with each on one line, never
// nil, or why they cannot be used: there are more than MaxExclude, or a
// name is blank, longer than ExcludeLimit characters, has nothing left
// once normalized (harness.Normalize), or normalizes the same as an
// earlier one. An empty list, the first page's, is valid.
func CleanExclude(list []string) ([]string, error) {
if len(list) > MaxExclude {
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
}
out := make([]string, len(list))
seen := map[string]string{}
for i, name := range list {
name = oneLine(name)
switch {
case name == "":
return nil, fmt.Errorf("excluded name %d is blank", i+1)
case utf8.RuneCountInString(name) > ExcludeLimit:
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
}
n := harness.Normalize(name)
if n == "" {
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
}
if first, ok := seen[n]; ok {
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
}
seen[n] = name
out[i] = name
}
return out, nil
}
// quickIdeas is the skill. It holds its input because post-processing
// compares the output with it.
type quickIdeas struct {
in QuickIdeasRequest
}
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
func (quickIdeas) Name() string { return "quick_ideas" }
func (quickIdeas) Version() int { return 1 }
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
type quickIdeasPrompt struct {
// Count is how many ideas to ask for.
Count int
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
Crockpot bool
}
// PromptInput puts the household context, then the names already shown,
// in the household context block.
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
if err := CheckQuickKind(in.Kind); err != nil {
return harness.PromptInput{}, err
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
return harness.PromptInput{
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
Context: sections,
}, nil
}
func (quickIdeas) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"est_minutes": {"type": "integer"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"search_query": {"type": "string"}
},
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
"additionalProperties": false
}
}
},
"required": ["ideas"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes an idea, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
return fmt.Errorf("idea %s", err)
}
type plain QuickIdea
return json.Unmarshal(data, (*plain)(i))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "ideas"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain QuickIdeaList
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: no ideas, too
// many, or one that is malformed. A well-formed idea that breaks a rule is
// dropped by PostProcess instead.
func (quickIdeas) Validate(out QuickIdeaList) []string {
var problems []string
switch n := len(out.Ideas); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
case n > QuickIdeasRequested:
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
}
for i, idea := range out.Ideas {
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
}
if !slices.Contains(methods, idea.Method) {
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
}
if idea.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
}
if !slices.Contains(difficulties, idea.Difficulty) {
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess puts every idea's text on one line and moves each that
// repeats an excluded name or an earlier idea (harness.Similar, which
// compares normalized names) to Dropped. The model's method and minutes
// are kept as estimates and never decide the kind.
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
result := QuickIdeaList{Ideas: []QuickIdea{}}
for _, idea := range out.Ideas {
idea = QuickIdea{
Name: oneLine(idea.Name),
EstMinutes: idea.EstMinutes,
Method: idea.Method,
Difficulty: idea.Difficulty,
SearchQuery: oneLine(idea.SearchQuery),
}
if why := s.drop(idea, result.Ideas); why != "" {
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
continue
}
result.Ideas = append(result.Ideas, idea)
}
return result, nil
}
// drop returns why idea must be dropped, or "" when it may be kept.
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
for _, name := range s.in.Exclude {
if harness.Similar(idea.Name, name) {
return fmt.Sprintf("repeats %q, already shown", name)
}
}
for _, k := range kept {
if harness.Similar(idea.Name, k.Name) {
return fmt.Sprintf("repeats the idea %q", k.Name)
}
}
return ""
}
-235
View File
@@ -1,235 +0,0 @@
package skills
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// quickTastes is the household context of the quick_ideas tests.
var quickTastes = harness.HouseholdContext{
Today: date(2026, 10, 8),
Timezone: "America/New_York",
Regulars: []harness.RegularDinner{
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true},
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30},
},
QuestLog: []string{"Pad Thai"},
}
// quickIdea renders one idea of a quick_ideas reply.
func quickIdea(name, method string, minutes int) string {
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, minutes, method, strings.ToLower(name)+" recipe")
}
// quick runs QuickIdeas on in with f and checks f was called exactly
// calls times, each at the household temperature.
func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) {
t.Helper()
out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(in), 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 != "quick_ideas" {
t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestQuickIdeasPrompt(t *testing.T) {
for _, tc := range []struct {
kind, golden, user, rule string
}{
{QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.",
`Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`},
{QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.",
"Every idea must take 30 minutes or less from starting prep to the table."},
} {
t.Run(tc.kind, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25))))
in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
_, reqs, err := quick(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, tc.golden+".system.golden", system)
golden(t, tc.golden+".user.golden", user)
for _, part := range []string{
"## Household context",
"### Regulars\n- Beef Chili",
"### Quest log\n- Pad Thai",
"### Already shown\n- Beef Stew\n- Shrimp Tacos",
tc.rule,
"## Output contract",
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if user != tc.user {
t.Errorf("user message = %q, want %q", user, tc.user)
}
})
}
}
func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// The excluded names, spelled another way: the normalized name
// decides.
quickIdea("Easy Beef Stew!", "crockpot", 300),
quickIdea("shrimp-tacos", "stovetop", 20),
quickIdea("Pulled Pork", "crockpot", 480),
// A repeat of an idea already kept.
quickIdea("The Pulled Pork", "crockpot", 420),
quickIdea("Chicken Fried Rice", "stovetop", 25),
)))
in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
out, _, err := quick(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
var kept []string
for _, i := range out.Ideas {
kept = append(kept, i.Name)
}
// Chicken Fried Rice is not a crock pot dinner, but the grounded
// recipe, not the model's estimate, decides that later.
if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) {
t.Fatalf("ideas = %q, want %q", kept, want)
}
want := []DroppedIdea{
{Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`},
{Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`},
{Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`},
}
if !reflect.DeepEqual(out.Dropped, want) {
t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want)
}
}
func TestQuickIdeasRepairsMalformedReply(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0))))
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480))))
out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 2)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 {
t.Fatalf("ideas = %+v", out.Ideas)
}
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
if !strings.Contains(repair, part) {
t.Errorf("repair turn lacks %q:\n%s", part, repair)
}
}
}
func TestQuickIdeasRefusesTooManyIdeas(t *testing.T) {
var f llm.Fake
var many []string
for i := range QuickIdeasRequested + 1 {
many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20))
}
f.QueueGenerate(reply(ideasReply(many...)))
f.QueueGenerate(reply(ideasReply(many...)))
if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") {
t.Fatalf("err = %v, want a bad output naming the limit", err)
}
}
func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) {
for _, in := range []QuickIdeasRequest{
{Context: quickTastes, Kind: ""},
{Context: quickTastes, Kind: "quick"},
{Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}},
} {
var f llm.Fake
if _, _, err := quick(t, &f, in, 0); err == nil {
t.Fatalf("%+v was accepted", in)
}
}
}
func TestCleanExclude(t *testing.T) {
got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"})
if err != nil {
t.Fatal(err)
}
if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CleanExclude = %q, want %q", got, want)
}
if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 {
t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err)
}
tooMany := make([]string, MaxExclude+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("dish %d", i)
}
for _, tc := range []struct {
name string
in []string
want string
}{
{"too many", tooMany, "exclude at most 60"},
{"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"},
{"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"},
{"filler only", []string{"Easy Recipe"}, "names no dinner"},
{"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestCheckQuickKind(t *testing.T) {
for _, kind := range QuickKinds {
if err := CheckQuickKind(kind); err != nil {
t.Errorf("CheckQuickKind(%q) = %v", kind, err)
}
}
for _, kind := range []string{"", "Crockpot", "under_30", "quick"} {
if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") {
t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err)
}
}
}
func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) {
a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}})
if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b {
t.Errorf("scopes of the same request differ: %q, %q", a, b)
}
for _, other := range []QuickIdeasRequest{
{Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}},
{Kind: QuickUnder30},
{Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}},
} {
if QuickIdeasScope(other) == a {
t.Errorf("%+v shares the scope %q", other, a)
}
}
}
-421
View File
@@ -1,421 +0,0 @@
package skills
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// ReasonLimit is the most characters a Side quest's reason, its "Why
// this?", may have.
const ReasonLimit = 180
// SideQuestsRequest is what suggest_side_quests reads.
type SideQuestsRequest struct {
// Context is the household context (see harness.BuildContext). It must
// hold at least one Regular, since every Side quest cites one.
Context harness.HouseholdContext
// Needed is how many Side quests the caller wants to keep. The skill
// asks the model for twice as many, since validation and grounding
// drop some. It must be positive.
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds every passed name — each passed Side quest and each
// dinner a Rotation check dropped — not only the recent ones Context
// holds.
Declined []string
}
// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty
// are the model's estimates until the dinner is grounded in a recipe.
type SideQuest struct {
Name string `json:"name"`
Cuisine string `json:"cuisine"`
// Method is one of stovetop, oven, crockpot, grill and other.
Method string `json:"method"`
EstMinutes int `json:"est_minutes"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Reason is the "Why this?": at most ReasonLimit characters, naming at
// least one of CitedRegulars.
Reason string `json:"reason"`
// CitedRegulars are names of the household's Regulars, as the
// household writes them.
CitedRegulars []string `json:"cited_regulars"`
// SearchQuery is a web search query that finds a recipe for the dinner.
SearchQuery string `json:"search_query"`
}
// SideQuests is suggest_side_quests' output.
type SideQuests struct {
// Suggestions are the Side quests that passed every rule, in the
// model's order. There may be more or fewer than Needed.
Suggestions []SideQuest `json:"suggestions"`
// Dropped are the Side quests the model gave that broke a rule, in the
// model's order. It is never part of the model's reply.
Dropped []DroppedSideQuest `json:"-"`
}
// DroppedSideQuest is a Side quest dropped by PostProcess.
type DroppedSideQuest struct {
Name string
// Why says which rule it broke.
Why string
}
// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side
// quests that cite only real Regulars, give a short reason naming one of
// them, and repeat no Regular, Quest log dinner, pending or passed Side
// quest, nor each other. The rest are in Dropped. scope names the batch
// (see harness.Run).
func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) {
return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in)
}
// suggestSideQuests is the skill. It holds its input because validation
// and post-processing compare the output with it.
type suggestSideQuests struct {
in SideQuestsRequest
}
var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{}
func (suggestSideQuests) Name() string { return "suggest_side_quests" }
func (suggestSideQuests) Version() int { return 1 }
func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout }
// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl.
type sideQuestsPrompt struct {
// Count is how many Side quests to ask for.
Count int
}
// requested is how many Side quests the model is asked for.
func (s suggestSideQuests) requested() int { return 2 * s.in.Needed }
func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) {
if in.Needed <= 0 {
return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed)
}
if len(in.Context.Regulars) == 0 {
return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on")
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending})
return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil
}
func (suggestSideQuests) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"cuisine": {"type": "string"},
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
"est_minutes": {"type": "integer"},
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
"reason": {"type": "string"},
"cited_regulars": {"type": "array", "items": {"type": "string"}},
"search_query": {"type": "string"}
},
"required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"],
"additionalProperties": false
}
}
},
"required": ["suggestions"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes a Side quest, requiring each of its fields as the
// schema does: encoding/json would otherwise read a missing or null one as
// empty.
func (q *SideQuest) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil {
return fmt.Errorf("suggestion %s", err)
}
type plain SideQuest
return json.Unmarshal(data, (*plain)(q))
}
// UnmarshalJSON decodes the output, requiring its one field as the schema
// does.
func (out *SideQuests) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "suggestions"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain SideQuests
return json.Unmarshal(data, (*plain)(out))
}
// Validate finds what makes the reply unusable as a whole: a wrong number
// of Side quests or one that is malformed. A well-formed Side quest that
// breaks a rule is dropped by PostProcess instead.
func (s suggestSideQuests) Validate(out SideQuests) []string {
var problems []string
switch n := len(out.Suggestions); {
case n == 0 && len(out.Dropped) == 0:
problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested()))
case n > s.requested():
problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested()))
}
for i, q := range out.Suggestions {
for _, f := range []struct{ field, value string }{
{"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery},
} {
if strings.TrimSpace(f.value) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field))
}
}
if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" {
problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name))
}
if !slices.Contains(methods, q.Method) {
problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", ")))
}
if q.EstMinutes <= 0 {
problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes))
}
if !slices.Contains(difficulties, q.Difficulty) {
problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", ")))
}
}
return problems
}
// PostProcess tidies every Side quest's text, writes its cited Regulars as
// the household does, and moves each one that breaks a rule to Dropped
// (see drop).
func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}}
var names []string
for _, r := range s.in.Context.Regulars {
regulars.add(r.Name)
names = append(names, r.Name)
}
regulars.all = names
known := harness.NewDeduper(harness.DedupeSources{
Regulars: names,
QuestLog: s.in.Context.QuestLog,
Pending: s.in.Pending,
Declined: s.in.Declined,
})
result := SideQuests{Suggestions: []SideQuest{}}
for _, q := range out.Suggestions {
q = SideQuest{
Name: oneLine(q.Name),
Cuisine: oneLine(q.Cuisine),
Method: q.Method,
EstMinutes: q.EstMinutes,
Difficulty: q.Difficulty,
Reason: oneLine(q.Reason),
CitedRegulars: q.CitedRegulars,
SearchQuery: oneLine(q.SearchQuery),
}
cited, why := drop(q, regulars, known, result.Suggestions)
if why != "" {
result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why})
continue
}
q.CitedRegulars = cited
result.Suggestions = append(result.Suggestions, q)
}
return result, nil
}
// regularNames finds the Regular a cited name means. Names need not be
// unique, even ignoring case, so each key holds every Regular with it.
type regularNames struct {
// exact is keyed by the name in lower case on one line.
exact map[string][]string
// normalized is keyed by harness.Normalize of the name.
normalized map[string][]string
// all holds every Regular's name, in the household's order.
all []string
}
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
func (r regularNames) add(name string) {
r.exact[exactName(name)] = append(r.exact[exactName(name)], name)
if n := harness.Normalize(name); n != "" {
r.normalized[n] = append(r.normalized[n], name)
}
}
// find returns the one Regular cited means, or "" and why there is none.
// A Regular named exactly cited, ignoring case and spacing, comes first;
// failing that, cited may name a Regular by its normalized name.
func (r regularNames) find(cited string) (string, string) {
matches := r.exact[exactName(cited)]
if len(matches) == 0 {
matches = r.normalized[harness.Normalize(cited)]
}
switch len(matches) {
case 0:
return "", fmt.Sprintf("cites %q, which is not a Regular", cited)
case 1:
return matches[0], ""
default:
return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches)
}
}
// drop returns why q must be dropped, or "" and its cited Regulars as the
// household writes them when it may be kept. q is dropped when it repeats
// a known dinner or a Side quest already kept, cites no Regular or one
// regulars finds no single Regular for, or has a reason over ReasonLimit
// characters or naming none of its cited Regulars.
func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) {
if known.IsDuplicate(q.Name) {
return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest"
}
for _, k := range kept {
if harness.Similar(q.Name, k.Name) {
return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name)
}
}
if len(q.CitedRegulars) == 0 {
return nil, "cites no Regular"
}
var cited []string
for _, c := range q.CitedRegulars {
name, why := regulars.find(c)
if why != "" {
return nil, why
}
cited = append(cited, name)
}
if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit {
return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit)
}
named := regulars.named(q.Reason)
if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) {
return nil, "reason names none of its cited Regulars"
}
return cited, ""
}
// named returns the Regulars text names, by one rule. A mention of a
// Regular is a place where text spells its name, ignoring case and spacing,
// with no letter, mark or digit just outside it: the start or end of text,
// white space, punctuation or a symbol must border it, so "Beef
// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef
// Chilis" does not, nor does "Cafe" with a combining accent after it
// mention "Cafe". Punctuation at either end of the name, with any spacing
// beside it, is optional, and a mention takes in as much of it as text
// spells, spaced in any way, with the border still holding after it: "Easy
// Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili
// !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef
// Chili!!" by "Beef Chili!" with one "!". A
// combining mark is part of its letter, never punctuation. A mention names
// its Regular unless a mention of a different Regular covers the same text
// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef
// Chili", and text that two Regulars spell alike names neither there.
func (r regularNames) named(text string) []string {
type mention struct {
name string
start, end int
}
text = exactName(text)
bordered := func(start, end int) bool {
before, _ := utf8.DecodeLastRuneInString(text[:start])
after, _ := utf8.DecodeRuneInString(text[end:])
return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after))
}
var mentions []mention
for _, name := range r.all {
full := exactName(name)
core := strings.TrimFunc(full, isOuter)
if core == "" {
continue
}
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))]
trail := full[len(lead)+len(core):]
for at := 0; ; {
i := strings.Index(text[at:], core)
if i < 0 {
break
}
start, end := at+i, at+i+len(core)
at = start + 1
if !bordered(start, end) {
continue
}
start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) })
end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) })
mentions = append(mentions, mention{name, start, end})
}
}
var names []string
for _, m := range mentions {
covered := slices.ContainsFunc(mentions, func(o mention) bool {
return o.name != m.name && o.start <= m.start && o.end >= m.end
})
if !covered && !slices.Contains(names, m.name) {
names = append(names, m.name)
}
}
return names
}
// spelledAfter returns where the longest start of outer that text spells
// from at ends, ignoring spacing, among those after which bordered holds,
// or at when there are none.
func spelledAfter(text string, at int, outer string, bordered func(int) bool) int {
end := at
for _, c := range strings.Join(strings.Fields(outer), "") {
at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace))
if !strings.HasPrefix(text[at:], string(c)) {
break
}
at += utf8.RuneLen(c)
if bordered(at) {
end = at
}
}
return end
}
// spelledBefore returns where the longest end of outer that text spells up
// to at starts, ignoring spacing, among those before which bordered holds,
// or at when there are none.
func spelledBefore(text string, at int, outer string, bordered func(int) bool) int {
start := at
punct := []rune(strings.Join(strings.Fields(outer), ""))
for i := len(punct) - 1; i >= 0; i-- {
at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace))
if !strings.HasSuffix(text[:at], string(punct[i])) {
break
}
at -= utf8.RuneLen(punct[i])
if bordered(at) {
start = at
}
}
return start
}
// isWord reports whether c is a letter, a mark on one or a digit.
func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) }
// isOuter reports whether c may be left off the end of a name: punctuation
// or white space.
func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }
@@ -1,488 +0,0 @@
package skills
import (
"context"
"encoding/json"
"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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
func date(y int, m time.Month, d int) store.LocalDate {
return store.LocalDateOf(time.Date(y, m, d, 0, 0, 0, 0, time.UTC))
}
// household asks for two Side quests, so four are requested.
var household = SideQuestsRequest{
Context: harness.HouseholdContext{
Today: date(2026, 10, 6),
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},
{Name: "Spaghetti Bolognese", Cuisine: "Italian", Method: "stovetop", Minutes: 45},
},
QuestLog: []string{"Pad Thai"},
Passed: []string{"Chicken Tikka Masala"},
Plans: []harness.PlannedDinner{
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
{Date: date(2026, 10, 6), Name: "Beef Chili"},
},
},
Needed: 2,
Pending: []string{"Shrimp Scampi"},
Declined: []string{"Chicken Tikka Masala", "Fish Tacos"},
}
// sideQuest renders one Side quest of a reply.
func sideQuest(name, reason string, cited ...string) string {
c, _ := json.Marshal(append([]string{}, cited...))
r, _ := json.Marshal(reason)
return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`,
name, r, c, strings.ToLower(name)+" recipe")
}
// batch renders a reply holding the Side quests given.
func batch(quests ...string) string {
return `{"suggestions":[` + strings.Join(quests, ",") + `]}`
}
var (
shawarma = sideQuest("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
ragu = sideQuest("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
verde = sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
bulgogi = sideQuest("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
)
// want returns the Side quest that sideQuest renders, with cited as the
// household writes them.
func want(name, reason string, cited ...string) SideQuest {
return SideQuest{
Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
Reason: reason, CitedRegulars: cited, SearchQuery: strings.ToLower(name) + " recipe",
}
}
var (
wantShawarma = want("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas")
wantRagu = want("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese")
wantVerde = want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili")
wantBulgogi = want("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili")
)
// suggest runs SuggestSideQuests on household with f and checks f was
// called exactly calls times, each at the household temperature.
func suggest(t *testing.T, f *llm.Fake, in SideQuestsRequest, calls int) (SideQuests, []llm.Request, error) {
t.Helper()
out, err := SuggestSideQuests(context.Background(), testConfig(t, f), "2026-W41", 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 != "suggest_side_quests" {
t.Fatalf("request %d schema = %v, want suggest_side_quests", i+1, req.Schema)
}
}
return out, reqs, err
}
func assertSideQuests(t *testing.T, got, want SideQuests) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("output = %+v\nwant %+v", got, want)
}
}
func TestSuggestSideQuestsCleanBatch(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(shawarma, ragu, verde, bulgogi)))
out, reqs, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi}})
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "suggest_side_quests_clean.system.golden", system)
golden(t, "suggest_side_quests_clean.user.golden", user)
for _, part := range []string{
"## Household context",
"- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)",
"### Quest log\n- Pad Thai",
"### Passed, oldest first\n- Chicken Tikka Masala",
"- 2026-10-06 Tue: Beef Chili",
"### Pending Side quests\n- Shrimp Scampi",
"## Output contract",
`"cited_regulars"`,
`"search_query"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Index(system, "## Household context") > strings.Index(system, "## Output contract") {
t.Error("the output contract is not after the household context")
}
if user != "Suggest 4 Side quests." {
t.Errorf("user message = %q, want twice the 2 needed", user)
}
}
func TestSuggestSideQuestsPersonaFirst(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(shawarma)))
cfg := testConfig(t, &f)
cfg.Persona = "You are the household's cheerful kitchen helper."
if _, err := SuggestSideQuests(context.Background(), cfg, "2026-W41", household); err != nil {
t.Fatal(err)
}
if system := f.Requests()[0].System; !strings.HasPrefix(system, cfg.Persona+"\n\n## Instructions\n") {
t.Fatalf("system prompt does not start with the persona then the instructions:\n%s", system)
}
}
func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) {
invented := sideQuest("Meatloaf Wellington", "Your Grandma's Meatloaf, wrapped in pastry.", "Grandma's Meatloaf")
partly := sideQuest("Fajita Meatballs", "Chicken Fajitas meets your Grandma's Meatloaf.", "Chicken Fajitas", "Grandma's Meatloaf")
none := sideQuest("Mushroom Risotto", "Creamy and comforting.")
var f llm.Fake
f.QueueGenerate(reply(batch(invented, shawarma, partly, none)))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantShawarma},
Dropped: []DroppedSideQuest{
{Name: "Meatloaf Wellington", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
{Name: "Fajita Meatballs", Why: `cites "Grandma's Meatloaf", which is not a Regular`},
{Name: "Mushroom Risotto", Why: "cites no Regular"},
},
})
}
func TestSuggestSideQuestsSharedNormalizedRegularNotRewritten(t *testing.T) {
in := household
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
plain := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili")
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "EASY beef chili")
either := sideQuest("Tamale Bake", "Your Beef Chili under a cornbread crust.", "Beef Chili!")
var f llm.Fake
f.QueueGenerate(reply(batch(plain, easy, either)))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{
want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili"),
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
},
Dropped: []DroppedSideQuest{
{Name: "Tamale Bake", Why: `cites "Beef Chili!", which could be any of the Regulars ["Beef Chili" "Easy Beef Chili"]`},
},
})
}
func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) {
in := household
in.Context.Regulars = append(slices.Clone(household.Context.Regulars),
harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
other := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Easy Beef Chili")
easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili")
plain := sideQuest("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili")
inside := sideQuest("Tamale Bake", "Your Easy Beef Chili under a cornbread crust.", "Beef Chili")
var f llm.Fake
f.QueueGenerate(reply(batch(other, easy, plain, inside)))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{
want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"),
want("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili"),
},
Dropped: []DroppedSideQuest{
{Name: "Chili Verde", Why: "reason names none of its cited Regulars"},
{Name: "Tamale Bake", Why: "reason names none of its cited Regulars"},
},
})
}
func TestSuggestSideQuestsReasonMentionRule(t *testing.T) {
const none = "reason names none of its cited Regulars"
for name, tc := range map[string]struct {
extra []string // Regulars added to household's
reason string
cited string
why string // "" when the Side quest is kept
}{
"exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""},
"other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""},
"sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""},
"possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""},
"joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""},
"no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none},
"letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none},
"stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""},
"stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""},
"inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none},
"inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none},
"longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""},
"shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none},
"longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none},
"shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""},
"punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""},
"bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none},
"spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none},
"punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none},
"letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none},
"spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""},
"spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""},
"stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""},
"inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
"stored punctuation partly": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Beef Chili!!", ""},
"inside partial punctuation": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Chili!", none},
"partly then a letter": {[]string{"Beef Chili!!", "Chili!"}, "Your Beef Chili!!X is our inspiration.", "Chili!", none},
"leading punctuation partly": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡¡Beef Chili", ""},
"inside partial leading": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡Beef", none},
"stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none},
"stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none},
"accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none},
} {
t.Run(name, func(t *testing.T) {
in := household
in.Context.Regulars = slices.Clone(household.Context.Regulars)
for _, r := range tc.extra {
in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
}
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited))))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}}
if tc.why != "" {
result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}}
}
assertSideQuests(t, out, result)
})
}
}
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")
regular := sideQuest("Easy Beef Chili", "Just like your Beef Chili.", "Beef Chili")
questLog := sideQuest("Pad Thai", "Noodles like your Spaghetti Bolognese.", "Spaghetti Bolognese")
pending := sideQuest("Shrimp Scampi", "Pasta like your Spaghetti Bolognese.", "Spaghetti Bolognese")
again := sideQuest("Chicken Shawarma Bowls", "Chicken Fajitas, but Middle Eastern.", "Chicken Fajitas")
var f llm.Fake
f.QueueGenerate(reply(batch(declined, oldDeclined, regular, questLog, pending, shawarma, again)))
in := household
in.Needed = 4
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
known := "repeats a Regular, Quest log dinner, pending or passed Side quest"
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantShawarma},
Dropped: []DroppedSideQuest{
{Name: "The Best Homemade Chicken Tikka-Masala", Why: known},
{Name: "Fish Tacos!", Why: known},
{Name: "Easy Beef Chili", Why: known},
{Name: "Pad Thai", Why: known},
{Name: "Shrimp Scampi", Why: known},
{Name: "Chicken Shawarma Bowls", Why: `repeats the Side quest "Chicken Shawarma Bowls" in the same batch`},
},
})
}
func TestSuggestSideQuestsReasonRulesDropped(t *testing.T) {
long := sideQuest("Beef Stroganoff", "Your Beef Chili"+strings.Repeat(" and more", 20)+".", "Beef Chili")
unnamed := sideQuest("Chicken Satay", "Grilled skewers with a peanut sauce.", "Chicken Fajitas")
wrongOne := sideQuest("Beef Tacos", "Like your Beef Chili, in a shell.", "Chicken Fajitas")
var f llm.Fake
f.QueueGenerate(reply(batch(long, unnamed, ragu, wrongOne)))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{wantRagu},
Dropped: []DroppedSideQuest{
{Name: "Beef Stroganoff", Why: "reason has 196 characters, over 180"},
{Name: "Chicken Satay", Why: "reason names none of its cited Regulars"},
{Name: "Beef Tacos", Why: "reason names none of its cited Regulars"},
},
})
}
func TestSuggestSideQuestsAllDroppedIsNotRepaired(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Pad Thai", "Like your Beef Chili.", "Beef Chili"))))
out, _, err := suggest(t, &f, household, 1)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{
Suggestions: []SideQuest{},
Dropped: []DroppedSideQuest{{Name: "Pad Thai", Why: "repeats a Regular, Quest log dinner, pending or passed Side quest"}},
})
}
func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) {
for name, tc := range map[string]struct{ first, want string }{
"truncated JSON": {
`{"suggestions":[` + shawarma + `,{"name":"Beef`,
"reply does not decode: unexpected EOF",
},
"omitted field": {
batch(strings.Replace(shawarma, `"cuisine":"Fusion",`, ``, 1)),
`suggestion has no "cuisine" field; give every field the schema requires`,
},
"unknown field": {
batch(strings.Replace(shawarma, `"easy",`, `"easy","spice":"mild",`, 1)),
`suggestion has unknown field "spice"`,
},
"bad method": {
batch(strings.Replace(shawarma, `"stovetop"`, `"air fryer"`, 1)),
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(tc.first), reply(batch(shawarma, ragu)))
out, reqs, err := suggest(t, &f, household, 2)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != tc.first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
if !strings.Contains(repair[2].Content, tc.want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, tc.want)
}
})
}
}
func TestSuggestSideQuestsFillerNameRepaired(t *testing.T) {
filler := sideQuest("The.", "Like your Beef Chili.", "Beef Chili")
first := batch(filler, filler)
var f llm.Fake
f.QueueGenerate(reply(first), reply(batch(shawarma, ragu)))
out, reqs, err := suggest(t, &f, household, 2)
if err != nil {
t.Fatal(err)
}
assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}})
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 _, want := range []string{
`suggestion 1 name "The." has only filler words; name the dish`,
`suggestion 2 name "The." has only filler words; name the dish`,
} {
if !strings.Contains(repair[2].Content, want) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
}
}
}
func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`))
out, _, err := suggest(t, &f, household, 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.Equal(bad.Repair, []string{"suggestions is empty; give 4 Side quests"}) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Suggestions != nil {
t.Fatalf("output = %+v, want none", out)
}
}
func TestSideQuestsValidate(t *testing.T) {
s := suggestSideQuests{in: household}
if p := s.Validate(SideQuests{Suggestions: []SideQuest{wantShawarma}}); len(p) != 0 {
t.Fatalf("valid output has problems: %q", p)
}
if p := s.Validate(SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "x", Why: "y"}}}); len(p) != 0 {
t.Fatalf("output whose Side quests were all dropped has problems: %q", p)
}
for name, tc := range map[string]struct {
out SideQuests
want []string
}{
"empty": {SideQuests{}, []string{"suggestions is empty; give 4 Side quests"}},
"too many": {
SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi, wantShawarma}},
[]string{"suggestions has 5 entries; give at most 4 Side quests"},
},
"malformed": {
SideQuests{Suggestions: []SideQuest{{Name: " ", Method: "air fryer", EstMinutes: 0, Difficulty: "trivial", CitedRegulars: []string{}}}},
[]string{
"suggestion 1 has an empty name; give one",
"suggestion 1 has an empty cuisine; give one",
"suggestion 1 has an empty reason; give one",
"suggestion 1 has an empty search_query; give one",
`suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`,
"suggestion 1 est_minutes 0 is not positive; give the minutes from prep to table",
`suggestion 1 difficulty "trivial" is not one of easy, medium, hard`,
},
},
} {
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 TestSideQuestsPromptInputRejects(t *testing.T) {
noRegulars := household
noRegulars.Context.Regulars = nil
none := household
none.Needed = 0
for name, tc := range map[string]struct {
in SideQuestsRequest
want string
}{
"no Regulars": {noRegulars, "household has no Regulars for Side quests to be based on"},
"none needed": {none, "0 Side quests needed; the number must be positive"},
} {
t.Run(name, func(t *testing.T) {
_, err := suggestSideQuests{in: tc.in}.PromptInput(tc.in)
if err == nil || err.Error() != tc.want {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
@@ -1,33 +0,0 @@
## Instructions
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
Rules:
- At most 48 characters, on one line.
- Words only: no emoji.
- No quotation marks around the title.
- Do not name a date or week number; the archive shows those.
Give:
- title: the title.
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
{"title": "Slow Simmers and Taco Night"}
## Household context
No household context.
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "archive_title" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"title": {
"type": "string"
}
},
"required": [
"title"
],
"additionalProperties": false
}
@@ -1,8 +0,0 @@
Title the week whose dinners are, in day order from Monday:
- Beef Chili
- Pot Roast
- Chicken Fajitas
- Spaghetti Bolognese
- Sheet Pan Salmon
- Pad Thai
- Fish Tacos
@@ -1,80 +0,0 @@
## 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
}
@@ -1 +0,0 @@
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,94 +0,0 @@
## Instructions
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
## Household context
### Ingredients on hand
- Chicken thighs
- rice
- cilantro
- black beans
- red onion
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"uses": {
"type": "array",
"items": {
"type": "string"
}
},
"missing": {
"type": "array",
"items": {
"type": "string"
}
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"uses",
"missing",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 8 dinner ideas from the ingredients on hand.
@@ -1,93 +0,0 @@
## Instructions
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea (an example only, not about this household):
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
## Household context
### Today
- Thursday 2026-10-08 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
### Quest log
- Pad Thai
### Passed, oldest first
(none)
### Recent plans
(none)
### Already shown
- Beef Stew
- Shrimp Tacos
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 12 crock pot dinner ideas.
@@ -1,93 +0,0 @@
## Instructions
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
Every idea must take 30 minutes or less from starting prep to the table.
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
Give for each idea:
- name: the dinner's usual name, as a recipe site would title it.
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- difficulty: "easy", "medium" or "hard" for a home cook.
- search_query: a web search query that finds a recipe for this dinner.
One valid idea (an example only, not about this household):
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
## Household context
### Today
- Thursday 2026-10-08 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
### Quest log
- Pad Thai
### Passed, oldest first
(none)
### Recent plans
(none)
### Already shown
- Beef Stew
- Shrimp Tacos
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"ideas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"est_minutes": {
"type": "integer"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"est_minutes",
"method",
"difficulty",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"ideas"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 12 dinner ideas ready in 30 minutes or less.
@@ -1,110 +0,0 @@
## Instructions
You suggest Side quests: new dinners for the household to try, based on the dinners it already makes.
Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch.
Give for each Side quest:
- name: the dinner's usual name, as a recipe site would title it.
- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex".
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
- est_minutes: your estimate of the whole minutes from starting prep to the table.
- difficulty: "easy", "medium" or "hard" for a home cook.
- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars.
- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list.
- search_query: a web search query that finds a recipe for this dinner.
One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household):
{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"}
## Household context
### Today
- Tuesday 2026-10-06 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
- Spaghetti Bolognese (Italian, stovetop, 45 min)
### Quest log
- Pad Thai
### Passed, oldest first
- Chicken Tikka Masala
### Recent plans
- 2026-10-05 Mon: Chicken Fajitas
- 2026-10-06 Tue: Beef Chili
### Pending Side quests
- Shrimp Scampi
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "suggest_side_quests" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"cuisine": {
"type": "string"
},
"method": {
"type": "string",
"enum": [
"stovetop",
"oven",
"crockpot",
"grill",
"other"
]
},
"est_minutes": {
"type": "integer"
},
"difficulty": {
"type": "string",
"enum": [
"easy",
"medium",
"hard"
]
},
"reason": {
"type": "string"
},
"cited_regulars": {
"type": "array",
"items": {
"type": "string"
}
},
"search_query": {
"type": "string"
}
},
"required": [
"name",
"cuisine",
"method",
"est_minutes",
"difficulty",
"reason",
"cited_regulars",
"search_query"
],
"additionalProperties": false
}
}
},
"required": [
"suggestions"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Suggest 4 Side quests.
+12 -14
View File
@@ -107,7 +107,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
return nil, err
}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: CheckRedirect}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
discovery := *client
recorder := &statusRecorder{next: http.DefaultTransport}
discovery.Transport = recorder
@@ -205,12 +205,11 @@ func checkEndpoint(name, raw string) error {
// maxRedirects matches net/http's default redirect limit.
const maxRedirects = 10
// CheckRedirect is the redirect policy of every client that talks to an
// OAuth provider. On a 307 or 308 to the same host Go resends the body and
// Authorization header, which carry the code and client secret, so every
// target must pass secureurl.Check and an https:// request never follows a
// redirect to plain http://, even loopback.
func CheckRedirect(req *http.Request, via []*http.Request) error {
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
// the same host Go resends the body and Authorization header, which carry the
// code and client secret, so every target must pass secureurl.Check and an
// https:// request never follows a redirect to plain http://, even loopback.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
@@ -351,7 +350,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return Claims{}, ExchangeError(err)
return Claims{}, exchangeError(err)
}
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
@@ -401,7 +400,7 @@ func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject
}
// userinfoError reduces a failed userinfo request to a fixed category, like
// ExchangeError: go-oidc's error text can carry the response body.
// exchangeError: go-oidc's error text can carry the response body.
func userinfoError(err error) error {
var transport *url.Error
if errors.As(err, &transport) {
@@ -426,11 +425,10 @@ func (a *Auth) member(groups []string) bool {
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
}
// ExchangeError reduces a failed OAuth code exchange to a fixed category and
// safe metadata. The oauth2 error text can carry the token endpoint's
// response body or error_description, which may echo credentials, so it is
// dropped.
func ExchangeError(err error) error {
// exchangeError reduces a failed code exchange to a fixed category and safe
// metadata. The oauth2 error text can carry the token endpoint's response
// body or error_description, which may echo credentials, so it is dropped.
func exchangeError(err error) error {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
+2 -2
View File
@@ -1060,7 +1060,7 @@ func TestCheckRedirect(t *testing.T) {
{"https://auth.example.com/token", "ftp://auth.example.com/token", false},
}
for _, tc := range tests {
err := CheckRedirect(request(tc.to), []*http.Request{request(tc.from)})
err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)})
if (err == nil) != tc.ok {
t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok)
}
@@ -1069,7 +1069,7 @@ func TestCheckRedirect(t *testing.T) {
for i := range via {
via[i] = request("https://auth.example.com/token")
}
if err := CheckRedirect(request("https://auth.example.com/token"), via); err == nil {
if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil {
t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects)
}
}
+24 -76
View File
@@ -27,86 +27,38 @@ type loginState struct {
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
FlowExpiry
Expires int64 `json:"e"` // Unix seconds
}
// loginCookie is the FlowCookie carrying loginState.
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
type loginCookie struct {
flow FlowCookie
key []byte
}
func newLoginCookie(secretKey []byte) loginCookie {
return loginCookie{flow: NewFlowCookie(loginCookieName, loginCookieKeyLabel, loginCookieTTL, secretKey)}
}
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
return c.flow.Set(w, &s, now)
}
func (c loginCookie) clear(w http.ResponseWriter) { c.flow.Clear(w) }
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
var s loginState
if err := c.flow.Read(r, &s, now); err != nil {
return loginState{}, err
}
return s, nil
}
// FlowExpiry is embedded in every value a FlowCookie carries; Set fills it
// and Read enforces it.
type FlowExpiry struct {
Expires int64 `json:"e"` // Unix seconds
}
func (e *FlowExpiry) flowExpiry() *FlowExpiry { return e }
// flowValue is a pointer to a struct that embeds FlowExpiry.
type flowValue interface {
flowExpiry() *FlowExpiry
}
// FlowCookie carries a redirect flow's state — the OAuth state, PKCE
// verifier and the like — from the request that sends the browser to a
// provider to the callback it returns to. The value is JSON, signed with
// HMAC-SHA256 under a key derived from the server secret and the cookie's
// own label, and expires after a fixed time. It is not encrypted: it must
// not hold anything the browser may not see.
type FlowCookie struct {
name string
ttl time.Duration
key []byte
}
// NewFlowCookie returns the flow cookie name, valid for ttl, signed under a
// key derived from secretKey and keyLabel. Each flow uses its own keyLabel so
// one flow's cookie never verifies as another's. name must carry the __Host-
// prefix: the cookie is Secure, host-only and Path=/.
func NewFlowCookie(name, keyLabel string, ttl time.Duration, secretKey []byte) FlowCookie {
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(keyLabel))
return FlowCookie{name: name, ttl: ttl, key: mac.Sum(nil)}
mac.Write([]byte(loginCookieKeyLabel))
return loginCookie{key: mac.Sum(nil)}
}
func (c FlowCookie) sign(payload string) string {
func (c loginCookie) sign(payload string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Set stores v, which expires ttl after now, in the cookie.
func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error {
v.flowExpiry().Expires = now.Add(c.ttl).Unix()
data, err := json.Marshal(v)
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
s.Expires = now.Add(loginCookieTTL).Unix()
data, err := json.Marshal(s)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(data)
http.SetCookie(w, &http.Cookie{
Name: c.name,
Name: loginCookieName,
Value: payload + "." + c.sign(payload),
Path: "/",
MaxAge: int(c.ttl / time.Second),
MaxAge: int(loginCookieTTL / time.Second),
Secure: true,
HttpOnly: true,
// Lax so the cookie rides the provider's top-level redirect back.
@@ -115,12 +67,9 @@ func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error
return nil
}
// Clear asks the browser to discard the cookie. A copy of it still verifies
// until it expires, so a flow that must be single-use also consumes its
// state on the server.
func (c FlowCookie) Clear(w http.ResponseWriter) {
func (c loginCookie) clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: c.name,
Name: loginCookieName,
Path: "/",
MaxAge: -1,
Secure: true,
@@ -129,28 +78,27 @@ func (c FlowCookie) Clear(w http.ResponseWriter) {
})
}
// Read verifies the cookie r carries and decodes it into v. It fails when
// the cookie is missing, its signature is invalid or it has expired at now.
func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
cookie, err := r.Cookie(c.name)
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
cookie, err := r.Cookie(loginCookieName)
if err != nil {
return fmt.Errorf("%s cookie missing", c.name)
return loginState{}, errors.New("login cookie missing")
}
payload, sig, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
return fmt.Errorf("%s cookie signature invalid", c.name)
return loginState{}, errors.New("login cookie signature invalid")
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return fmt.Errorf("%s cookie encoding: %w", c.name, err)
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
}
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("%s cookie payload: %w", c.name, err)
var s loginState
if err := json.Unmarshal(data, &s); err != nil {
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
}
if now.Unix() >= v.flowExpiry().Expires {
return fmt.Errorf("%s cookie expired", c.name)
if now.Unix() >= s.Expires {
return loginState{}, errors.New("login cookie expired")
}
return nil
return s, nil
}
// returnPath validates the optional post-login return path. Empty means "/".
-519
View File
@@ -1,519 +0,0 @@
// Package chat sends notifications to the household's Google Chat space
// through its incoming webhook, and keeps that webhook in chat_settings.
//
// The webhook URL carries its own key and token, so it is a secret: it is
// sealed with secretbox in webhook_url_ct and is never returned, logged or
// put in an error. Only its space label, a masked tail and the outcome of
// the last test message are shown.
package chat
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// MaxSpaceLabelChars bounds a space label.
MaxSpaceLabelChars = 100
// requestTimeout bounds one post to Google Chat.
requestTimeout = 15 * time.Second
// maxDrainBytes bounds how much of a Google Chat response is read.
maxDrainBytes = 4096
// replyOption threads a message under its threadKey, starting the
// thread when there is none yet.
replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
)
// Test outcomes, as chat_settings.last_test_status stores them.
const (
TestOK = "ok"
TestFailed = "failed"
)
var (
// ErrNotConfigured is returned when no webhook is stored.
ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured")
// ErrUnreachable is wrapped by the error of a post Google Chat did not
// answer.
ErrUnreachable = errors.New("chat: Google Chat did not answer")
// ErrRefused is matched by the *RefusedError of a post Google Chat
// answered with a status other than 2xx.
ErrRefused = errors.New("chat: Google Chat refused the message")
// ErrWebhookChanged is returned by Test when the webhook was replaced
// while the test message was being sent; nothing is recorded.
ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test")
)
// Settings is the stored webhook as the API shows it: never the URL, only
// its masked form. URLMasked is nil when no webhook is stored.
type Settings struct {
SpaceLabel *string `json:"space_label"`
// URLMasked is the webhook URL without its key and with only the last
// few characters of its token.
URLMasked *string `json:"url_masked"`
// LastTestAt and LastTestStatus ("ok" or "failed") are nil until a
// test message is sent to this webhook.
LastTestAt *string `json:"last_test_at"`
LastTestStatus *string `json:"last_test_status"`
}
// TestResult is the outcome of a test message.
type TestResult struct {
// HTTPStatus is the status Google Chat answered.
HTTPStatus int `json:"http_status"`
Settings
}
// RefusedError is a post Google Chat answered with Status, not 2xx. It
// matches ErrRefused.
type RefusedError struct {
Status int
}
func (e *RefusedError) Error() string {
return fmt.Sprintf("%v with status %d", ErrRefused, e.Status)
}
func (e *RefusedError) Unwrap() error { return ErrRefused }
// Retryable reports whether posting again may succeed: Google Chat was
// busy (429) or failing (5xx), rather than refusing the message.
func (e *RefusedError) Retryable() bool {
return e.Status == http.StatusTooManyRequests || e.Status >= 500
}
// Problem is one invalid field of a webhook update, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every problem with a webhook update. It never
// quotes the URL.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid Google Chat webhook: " + strings.Join(parts, "; ")
}
// Service keeps the webhook and posts to it. It is the notify.Sender of the
// chat channel.
type Service struct {
st *store.Store
box *secretbox.Box
// publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button
// links under.
publicURL *url.URL
client *http.Client
// now is the clock settings are saved and tests recorded on; tests
// replace it.
now func() time.Time
}
// NewService returns the service over st, sealing the webhook with box and
// linking cards under publicURL. Requests go through transport
// (http.DefaultTransport in production) and never follow a redirect.
func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("chat: nil store")
case box == nil:
return nil, errors.New("chat: nil secret box")
case publicURL == nil:
return nil, errors.New("chat: nil public URL")
case transport == nil:
return nil, errors.New("chat: nil transport")
}
return &Service{
st: st,
box: box,
publicURL: publicURL,
client: &http.Client{
Transport: transport,
Timeout: requestTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
now: time.Now,
}, nil
}
// Get returns the stored webhook's settings; every field is nil before one
// is saved.
func (s *Service) Get(ctx context.Context) (Settings, error) {
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return Settings{}, nil
}
if err != nil {
return Settings{}, fmt.Errorf("chat: read chat settings: %w", err)
}
hook, err := s.open(row)
if err != nil {
return Settings{}, err
}
return settingsFrom(row, hook), nil
}
// Put validates rawURL and spaceLabel and stores them, replacing any
// webhook stored before and clearing its test outcome. Every problem is
// reported in a *ValidationError.
func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) {
var problems []Problem
hook, err := ParseWebhook(rawURL)
if err != nil {
problems = append(problems, Problem{"url", err.Error()})
}
label := strings.TrimSpace(spaceLabel)
switch {
case label == "":
problems = append(problems, Problem{"space_label", "is required"})
case utf8.RuneCountInString(label) > MaxSpaceLabelChars:
problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)})
case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }):
problems = append(problems, Problem{"space_label", "must not contain control characters"})
}
if len(problems) > 0 {
return Settings{}, &ValidationError{Problems: problems}
}
ct, err := s.box.Encrypt([]byte(hook.String()))
if err != nil {
return Settings{}, fmt.Errorf("chat: seal webhook: %w", err)
}
var saved db.ChatSetting
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetChatSettings(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("chat: read chat settings: %w", err)
}
now := s.timestamp()
created := now
if err == nil {
created = row.CreatedAt
}
saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{
WebhookUrlCt: ct,
SpaceLabel: sql.NullString{String: label, Valid: true},
CreatedAt: created,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("chat: save chat settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
}
return settingsFrom(saved, hook), nil
}
// Test posts TestText to the stored webhook, unthreaded, and records when
// and whether Google Chat accepted it. A status Google Chat answers is a
// TestResult, whatever it is; a request that gets no answer is recorded as
// failed and returned as an error. A test cut short by ctx records nothing.
func (s *Service) Test(ctx context.Context) (TestResult, error) {
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return TestResult{}, ErrNotConfigured
}
if err != nil {
return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err)
}
hook, err := s.open(row)
if err != nil {
return TestResult{}, err
}
status, postErr := s.post(ctx, hook, message{Text: TestText}, "")
if err := ctx.Err(); err != nil {
return TestResult{}, err
}
outcome := TestFailed
if postErr == nil && status >= 200 && status < 300 {
outcome = TestOK
}
at := s.timestamp()
write, cancel := jobs.FinalWriteContext(ctx)
defer cancel()
n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{
LastTestAt: sql.NullString{String: at, Valid: true},
LastTestStatus: sql.NullString{String: outcome, Valid: true},
UpdatedAt: at,
WebhookUrlCt: row.WebhookUrlCt,
})
if err != nil {
return TestResult{}, fmt.Errorf("chat: record test: %w", err)
}
if n != 1 {
return TestResult{}, ErrWebhookChanged
}
if postErr != nil {
return TestResult{}, postErr
}
row.LastTestAt = sql.NullString{String: at, Valid: true}
row.LastTestStatus = sql.NullString{String: outcome, Valid: true}
row.UpdatedAt = at
return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil
}
// Send posts m to the stored webhook, threaded by the week of its payload,
// and is the chat channel's notify.Sender. Google Chat answering 2xx is
// success; 429 or 5xx, or no answer at all, is an error the outbox
// retries; any other status, or no webhook stored, wraps
// notify.ErrUndeliverable so the row fails at once.
func (s *Service) Send(ctx context.Context, m notify.Message) error {
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured)
}
if err != nil {
return fmt.Errorf("chat: read chat settings: %w", err)
}
hook, err := s.open(row)
if err != nil {
return err
}
msg, err := s.messageOf(m.Payload)
if err != nil {
return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err)
}
var thread string
if m.Payload.Week != "" {
week, err := store.ParseLocalDate(m.Payload.Week)
if err != nil {
return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err)
}
thread = ThreadKey(week)
}
status, err := s.post(ctx, hook, msg, thread)
switch {
case err != nil:
return err
case status >= 200 && status < 300:
return nil
case status == http.StatusTooManyRequests || status >= 500:
return fmt.Errorf("chat: Google Chat answered status %d", status)
default:
return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status)
}
}
// ThreadKey is the Chat thread of the week starting on week, such as
// "mealquest-week-2026-W42": every message about a week goes in one thread.
func ThreadKey(week store.LocalDate) string {
return "mealquest-week-" + household.PeriodKey(week)
}
// message is the body of a Google Chat webhook post.
type message struct {
Text string `json:"text"`
CardsV2 []cardV2 `json:"cardsV2,omitempty"`
}
type cardV2 struct {
CardID string `json:"cardId"`
Card card `json:"card"`
}
type card struct {
Header cardHeader `json:"header"`
Sections []cardSection `json:"sections"`
}
type cardHeader struct {
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
}
type cardSection struct {
Widgets []cardWidget `json:"widgets"`
}
type cardWidget struct {
ButtonList buttonList `json:"buttonList"`
}
type buttonList struct {
Buttons []button `json:"buttons"`
}
type button struct {
Text string `json:"text"`
OnClick onClick `json:"onClick"`
}
type onClick struct {
OpenLink openLink `json:"openLink"`
}
type openLink struct {
URL string `json:"url"`
}
// messageOf is the Chat message of p: its Text and, for a plan or Supply
// run, a card whose "Open" button links to p.URL under the public URL.
func (s *Service) messageOf(p notify.Payload) (message, error) {
msg := message{Text: Text(p)}
if !carded[p.Event] {
return msg, nil
}
c, err := s.openCard(string(p.Event), p.Title, p.Body, p.URL)
if err != nil {
return message{}, err
}
msg.CardsV2 = []cardV2{c}
return msg, nil
}
// openCard is a card headed title and subtitle whose "Open" button links
// to the app path under the public URL.
func (s *Service) openCard(id, title, subtitle, path string) (cardV2, error) {
ref, err := url.Parse(path)
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(path, "/") {
return cardV2{}, fmt.Errorf("payload url %q is not an app path", path)
}
link := s.publicURL.ResolveReference(ref)
return cardV2{
CardID: id,
Card: card{
Header: cardHeader{Title: title, Subtitle: subtitle},
Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{
Text: "Open",
OnClick: onClick{OpenLink: openLink{URL: link.String()}},
}}}}}}},
},
}, nil
}
// PostSupplyRun posts the Supply run of the week starting on week, named
// name, with groups, to the stored webhook in the week's thread: its
// SupplyRunText and a card whose "Open" button links to that week's Supply
// run. No webhook stored is ErrNotConfigured; a post Google Chat does not
// answer wraps ErrUnreachable, and one it answers with a status other than
// 2xx is a *RefusedError.
func (s *Service) PostSupplyRun(ctx context.Context, week store.LocalDate, name string, groups []SupplyRunGroup) error {
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotConfigured
}
if err != nil {
return fmt.Errorf("chat: read chat settings: %w", err)
}
hook, err := s.open(row)
if err != nil {
return err
}
path := "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode()
c, err := s.openCard("supply_run", "Supply run · "+name, "", path)
if err != nil {
return err
}
status, err := s.post(ctx, hook, message{Text: SupplyRunText(name, groups), CardsV2: []cardV2{c}}, ThreadKey(week))
if err != nil {
return err
}
if status < 200 || status >= 300 {
return &RefusedError{Status: status}
}
return nil
}
// post sends msg to hook, in thread when it is not empty, and returns the
// status Google Chat answered. Its error never quotes the webhook URL.
func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) {
body, err := json.Marshal(msg)
if err != nil {
return 0, fmt.Errorf("chat: encode message: %w", err)
}
target, err := url.Parse(hook.String())
if err != nil {
return 0, errors.New("chat: stored webhook URL does not parse")
}
if thread != "" {
q := target.Query()
q.Set("threadKey", thread)
q.Set("messageReplyOption", replyOption)
target.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body))
if err != nil {
return 0, errors.New("chat: build request to the webhook")
}
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
resp, err := s.client.Do(req)
if err != nil {
// A *url.Error quotes the request URL, the webhook; keep only its
// cause.
var ue *url.Error
if errors.As(err, &ue) {
return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err)
}
return 0, ErrUnreachable
}
defer resp.Body.Close()
// The status is the outcome; the body is read only so the connection
// can be reused.
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
return resp.StatusCode, nil
}
// open decrypts and validates the stored webhook. Its error never quotes
// the URL.
func (s *Service) open(row db.ChatSetting) (Webhook, error) {
raw, err := s.box.Decrypt(row.WebhookUrlCt)
if err != nil {
return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err)
}
hook, err := ParseWebhook(string(raw))
if err != nil {
return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err)
}
return hook, nil
}
func (s *Service) timestamp() string {
return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
}
func settingsFrom(row db.ChatSetting, hook Webhook) Settings {
masked := hook.Masked()
return Settings{
SpaceLabel: nullString(row.SpaceLabel),
URLMasked: &masked,
LastTestAt: nullString(row.LastTestAt),
LastTestStatus: nullString(row.LastTestStatus),
}
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
-588
View File
@@ -1,588 +0,0 @@
package chat
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"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/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// posted is one request the fake Chat endpoint received.
type posted struct {
Method, Host, Path string
Query url.Values
ContentType string
Body map[string]any
}
// fakeChat is an httptest Google Chat endpoint answering status.
type fakeChat struct {
srv *httptest.Server
mu sync.Mutex
// status is what it answers.
status int
got []posted
}
func newFakeChat(t *testing.T) *fakeChat {
t.Helper()
f := &fakeChat{status: http.StatusOK}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
var body map[string]any
if err := json.Unmarshal(data, &body); err != nil {
t.Errorf("Chat endpoint body %q: %v", data, err)
}
f.mu.Lock()
f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body})
status := f.status
f.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(`{}`))
}))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeChat) setStatus(status int) {
f.mu.Lock()
defer f.mu.Unlock()
f.status = status
}
func (f *fakeChat) requests() []posted {
f.mu.Lock()
defer f.mu.Unlock()
return append([]posted(nil), f.got...)
}
// redirectTo sends every request to the server at target, keeping its path
// and query, as if it were chat.googleapis.com.
type redirectTo struct {
target *url.URL
}
func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) {
out := req.Clone(req.Context())
out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host
out.Host = req.URL.Host
return http.DefaultTransport.RoundTrip(out)
}
func newTestStore(t *testing.T) *store.Store {
t.Helper()
ctx := context.Background()
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)
}
return st
}
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
return box
}
// newTestService returns a service over a migrated temp database whose
// requests go to f, with cards linking under https://mealquest.example.
func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) {
t.Helper()
st := newTestStore(t)
target, err := url.Parse(f.srv.URL)
if err != nil {
t.Fatal(err)
}
pub, err := url.Parse("https://mealquest.example")
if err != nil {
t.Fatal(err)
}
svc, err := NewService(st, newTestBox(t), pub, redirectTo{target})
if err != nil {
t.Fatal(err)
}
return svc, st
}
// assertNoSecret fails when s holds the webhook URL, its key or its token.
func assertNoSecret(t *testing.T, what, s string) {
t.Helper()
tokenDecoded, _ := url.QueryUnescape(testToken)
for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} {
if strings.Contains(s, secret) {
t.Errorf("%s holds the webhook URL or a credential: %s", what, s)
return
}
}
}
func TestNewServiceRequiresDependencies(t *testing.T) {
t.Parallel()
st, box := newTestStore(t), newTestBox(t)
pub, _ := url.Parse("https://mealquest.example")
for name, build := range map[string]func() (*Service, error){
"store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) },
"box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) },
"public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) },
"transport": func() (*Service, error) { return NewService(st, box, pub, nil) },
} {
if _, err := build(); err == nil {
t.Errorf("NewService without %s succeeded", name)
}
}
}
func TestPutGetStoresSealedWebhook(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := newTestService(t, newFakeChat(t))
got, err := svc.Get(ctx)
if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil {
t.Fatalf("Get unconfigured = %+v, %v", got, err)
}
put, err := svc.Put(ctx, testHook, " Family dinner ")
if err != nil {
t.Fatal(err)
}
if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil {
t.Errorf("Put = %+v", put)
}
got, err = svc.Get(ctx)
if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" {
t.Errorf("Get = %+v, %v", got, err)
}
row, err := db.New(st.Reader).GetChatSettings(ctx)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) {
t.Errorf("webhook_url_ct holds the URL in plain text")
}
plain, err := svc.box.Decrypt(row.WebhookUrlCt)
if err != nil || string(plain) != testHook {
t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err)
}
encoded, _ := json.Marshal(got)
assertNoSecret(t, "GET settings", string(encoded))
}
func TestPutRefusesInvalid(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc, st := newTestService(t, newFakeChat(t))
for _, tc := range []struct {
url, label string
fields []string
}{
{"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}},
{testHook, " ", []string{"space_label"}},
{testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}},
{testHook, "bad\nlabel", []string{"space_label"}},
{"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}},
} {
_, err := svc.Put(ctx, tc.url, tc.label)
var invalid *ValidationError
if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) {
t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields)
continue
}
for i, p := range invalid.Problems {
if p.Field != tc.fields[i] {
t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i])
}
}
assertNoSecret(t, "validation error", err.Error())
}
if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("a refused Put stored a row: %v", err)
}
}
func TestTestRecordsOutcome(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeChat(t)
svc, _ := newTestService(t, f)
svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) }
if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err)
}
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
res, err := svc.Test(ctx)
if err != nil {
t.Fatal(err)
}
if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" {
t.Errorf("Test = %+v, want 200 ok", res)
}
reqs := f.requests()
if len(reqs) != 1 {
t.Fatalf("Chat got %d requests, want 1", len(reqs))
}
r := reqs[0]
if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" ||
r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil {
t.Errorf("test request = %+v", r)
}
f.setStatus(http.StatusNotFound)
res, err = svc.Test(ctx)
if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed {
t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err)
}
got, _ := svc.Get(ctx)
if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" {
t.Errorf("Get after a failed test = %+v", got)
}
// A new webhook has no test outcome.
got, err = svc.Put(ctx, testHook, "Family")
if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil {
t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err)
}
f.srv.Close()
_, err = svc.Test(ctx)
if !errors.Is(err, ErrUnreachable) {
t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err)
} else {
assertNoSecret(t, "unreachable error", err.Error())
}
if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed {
t.Errorf("Get after an unanswered test = %+v, want failed", got)
}
}
func TestSendThreadsByWeek(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeChat(t)
svc, _ := newTestService(t, f)
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
week := mustDate(t, "2026-10-12")
events := []notify.Event{
PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}),
ListReady(week, 19, 7),
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")),
}
for _, ev := range events {
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
t.Fatalf("Send %s: %v", ev.Type, err)
}
}
reqs := f.requests()
if len(reqs) != len(events) {
t.Fatalf("Chat got %d requests, want %d", len(reqs), len(events))
}
for i, r := range reqs {
ev := events[i]
if r.Method != http.MethodPost || r.Path != "/v1/spaces/"+testSpace+"/messages" || !strings.HasPrefix(r.ContentType, "application/json") {
t.Errorf("%s: request = %+v", ev.Type, r)
}
if r.Query.Get("key") != testKey || r.Query.Get("token") != strings.ReplaceAll(testToken, "%3D", "=") {
t.Errorf("%s: key and token not kept: %v", ev.Type, r.Query)
}
if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" {
t.Errorf("%s: threading = %v", ev.Type, r.Query)
}
if want := Text(payloadOf(ev)); r.Body["text"] != want {
t.Errorf("%s: text = %q, want %q", ev.Type, r.Body["text"], want)
}
_, hasCards := r.Body["cardsV2"]
if hasCards != (ev.Type == notify.PlanReady || ev.Type == notify.ListReady) {
t.Errorf("%s: cardsV2 present = %v", ev.Type, hasCards)
}
}
if reqs[0].Body["text"] != "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →" {
t.Errorf("plan text = %q", reqs[0].Body["text"])
}
// The cards' "Open" buttons link under the public URL to the event's week.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
t.Fatalf("cardsV2 = %s, %v", data, err)
}
c := cards[0]
buttons := c.Card.Sections[0].Widgets[0].ButtonList.Buttons
if c.CardID != string(events[i].Type) || c.Card.Header.Title != events[i].Title || c.Card.Header.Subtitle != events[i].Body ||
len(buttons) != 1 || buttons[0].Text != "Open" || buttons[0].OnClick.OpenLink.URL != wantLink {
t.Errorf("%s card = %s, want an Open button to %s", events[i].Type, data, wantLink)
}
}
}
func TestSendWithoutWeekIsUnthreaded(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeChat(t)
svc, _ := newTestService(t, f)
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
p := notify.Payload{Event: notify.RotationReview, Title: "Keep it in rotation?", URL: "/", Tag: "rotation_review:x"}
if err := svc.Send(ctx, notify.Message{Payload: p}); err != nil {
t.Fatal(err)
}
if q := f.requests()[0].Query; q.Has("threadKey") || q.Has("messageReplyOption") {
t.Errorf("query = %v, want no threading", q)
}
}
func TestSendClassifiesStatus(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeChat(t)
svc, _ := newTestService(t, f)
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), mustDate(t, "2026-10-12"), []string{"Tacos"}))}
for _, tc := range []struct {
status int
ok, permanent bool
}{
{200, true, false},
{204, true, false},
{301, false, true},
{400, false, true},
{401, false, true},
{403, false, true},
{404, false, true},
{413, false, true},
{429, false, false},
{500, false, false},
{502, false, false},
{503, false, false},
} {
f.setStatus(tc.status)
err := svc.Send(ctx, m)
if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent {
t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent)
}
if err != nil {
assertNoSecret(t, "Send error", err.Error())
}
}
f.srv.Close()
err := svc.Send(ctx, m)
if err == nil || errors.Is(err, notify.ErrUndeliverable) {
t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err)
} else {
assertNoSecret(t, "Send error", err.Error())
}
}
func TestSendWithoutWebhookIsUndeliverable(t *testing.T) {
t.Parallel()
svc, _ := newTestService(t, newFakeChat(t))
err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))})
if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) {
t.Errorf("Send = %v, want undeliverable, not configured", err)
}
}
// TestOutboxRowsFollowClassification delivers through the notify worker: a
// 400 fails the row at once, a 503 leaves it pending for a retry. Neither
// the stored error nor the log holds the webhook URL.
func TestOutboxRowsFollowClassification(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
f := newFakeChat(t)
svc, st := newTestService(t, f)
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil {
t.Fatal(err)
}
var logs syncBuffer
notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil)))
if err != nil {
t.Fatal(err)
}
if err := notifier.Register(notify.ChannelChat, svc); err != nil {
t.Fatal(err)
}
if err := notifier.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
notifier.Wait()
})
week := mustDate(t, "2026-10-12")
f.setStatus(http.StatusBadRequest)
planned := PlanReady(week, week, []string{"Tacos"})
if err := notifier.Emit(ctx, planned); err != nil {
t.Fatal(err)
}
status, lastErr := waitForAttempt(t, st, planned.Key)
if status != "failed" || !strings.Contains(lastErr, "status 400") {
t.Errorf("row after a 400 = %s %q, want failed", status, lastErr)
}
f.setStatus(http.StatusServiceUnavailable)
listed := ListReady(week, 3, 1)
if err := notifier.Emit(ctx, listed); err != nil {
t.Fatal(err)
}
status, lastErr = waitForAttempt(t, st, listed.Key)
if status != "pending" || !strings.Contains(lastErr, "status 503") {
t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr)
}
cancel()
notifier.Wait()
assertNoSecret(t, "log", logs.String())
assertNoSecret(t, "last_error", lastErr)
}
// waitForAttempt waits for the chat row of key to have been attempted and
// returns its status and last error.
func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var status string
var attempts int
var lastErr sql.NullString
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key).
Scan(&status, &attempts, &lastErr)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatal(err)
}
if attempts > 0 {
return status, lastErr.String
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("chat row of %s was never attempted", key)
return "", ""
}
// syncBuffer is a bytes.Buffer safe for the worker's logger.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func TestPostSupplyRun(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeChat(t)
svc, _ := newTestService(t, f)
week := mustDate(t, "2026-10-12")
groups := []SupplyRunGroup{{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "tortillas"}}}}
if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("PostSupplyRun without a webhook = %v, want ErrNotConfigured", err)
}
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
t.Fatal(err)
}
if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); err != nil {
t.Fatal(err)
}
reqs := f.requests()
if len(reqs) != 1 {
t.Fatalf("Chat got %d requests, want 1", len(reqs))
}
r := reqs[0]
if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != replyOption {
t.Errorf("threading = %v", r.Query)
}
if want := SupplyRunText("Week 42 · Oct 12 – 18", groups); r.Body["text"] != want {
t.Errorf("text = %q, want %q", r.Body["text"], want)
}
data, _ := json.Marshal(r.Body["cardsV2"])
var cards []cardV2
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
t.Fatalf("cardsV2 = %s, %v", data, err)
}
buttons := cards[0].Card.Sections[0].Widgets[0].ButtonList.Buttons
if cards[0].Card.Header.Title != "Supply run · Week 42 · Oct 12 – 18" || len(buttons) != 1 ||
buttons[0].OnClick.OpenLink.URL != "https://mealquest.example/plan/shopping?week=2026-10-12" {
t.Errorf("card = %s, want an Open button to the week's Supply run", data)
}
f.setStatus(http.StatusForbidden)
err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups)
if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "403") {
t.Errorf("PostSupplyRun answered 403 = %v, want ErrRefused with the status", err)
}
if err != nil {
assertNoSecret(t, "refused error", err.Error())
}
f.srv.Close()
err = svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups)
if !errors.Is(err, ErrUnreachable) {
t.Errorf("PostSupplyRun unanswered = %v, want ErrUnreachable", err)
}
if err != nil {
assertNoSecret(t, "unreachable error", err.Error())
}
}
-97
View File
@@ -1,97 +0,0 @@
// Package chattest provides a fake Google Chat incoming webhook for tests
// of the features that post to Google Chat: an httptest server that
// records each post and answers a status the test sets, reached through a
// transport that sends chat.googleapis.com requests to it.
package chattest
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
)
// Webhook is a valid Google Chat incoming webhook URL to store.
const Webhook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&token=q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
// Post is one message the fake received.
type Post struct {
// Query is the request's query: key, token and any threading.
Query url.Values
// Body is the decoded JSON message.
Body map[string]any
}
// Text is the post's text.
func (p Post) Text() string {
s, _ := p.Body["text"].(string)
return s
}
// Chat is a fake Google Chat endpoint. It answers 200 until SetStatus.
type Chat struct {
srv *httptest.Server
mu sync.Mutex
status int
posts []Post
}
// New starts a fake Google Chat endpoint, closed when the test ends.
func New(t *testing.T) *Chat {
t.Helper()
c := &Chat{status: http.StatusOK}
c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
var body map[string]any
if err := json.Unmarshal(data, &body); err != nil {
t.Errorf("Google Chat post body %q: %v", data, err)
}
c.mu.Lock()
c.posts = append(c.posts, Post{Query: r.URL.Query(), Body: body})
status := c.status
c.mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(c.srv.Close)
return c
}
// Transport sends every request to the fake, as if it were
// chat.googleapis.com.
func (c *Chat) Transport() http.RoundTripper {
return transport{c}
}
type transport struct{ c *Chat }
func (tr transport) RoundTrip(req *http.Request) (*http.Response, error) {
target, err := url.Parse(tr.c.srv.URL)
if err != nil {
return nil, err
}
out := req.Clone(req.Context())
out.URL.Scheme, out.URL.Host = target.Scheme, target.Host
return http.DefaultTransport.RoundTrip(out)
}
// SetStatus makes the fake answer status from now on.
func (c *Chat) SetStatus(status int) {
c.mu.Lock()
defer c.mu.Unlock()
c.status = status
}
// Close stops the fake, so posts get no answer.
func (c *Chat) Close() {
c.srv.Close()
}
// Posts returns the messages received so far, in order.
func (c *Chat) Posts() []Post {
c.mu.Lock()
defer c.mu.Unlock()
return append([]Post(nil), c.posts...)
}
-236
View File
@@ -1,236 +0,0 @@
package chat
import (
"fmt"
"net/url"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// This file is the one place the notification texts are written. The
// builders make the notify.Event of each event type, whose title and body
// the push channel shows as they are; Text turns its payload into the Google
// Chat message, such as "This week's plan is ready: Tacos, Bolognese,
// Tikka masala + 4 more. Open the plan →".
// TestText is the message POST /settings/chat-webhook/test sends.
const TestText = "MealQuest · test message"
// listedNames is how many names a message lists before "+ N more".
const listedNames = 3
// actions is the closing call to action of each event's Chat message.
var actions = map[notify.EventType]string{
notify.PlanReady: "Open the plan",
notify.ListReady: "Open the Supply run",
notify.DayOfReminder: "Open the recipe",
notify.NewSuggestions: "Open Discover",
notify.RotationReview: "Open MealQuest",
}
// carded are the events whose Chat message also has a card with an "Open"
// button.
var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true}
// PlanReady is the plan_ready event of the week starting on week, drafted
// in the week starting on thisWeek, naming its dinners in plan order. Its
// title names the week as seen from thisWeek: "Next week's plan is ready",
// "This week's plan is ready", or "The plan for the week of Oct 26 is
// ready" for any other week.
func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
return notify.Event{
Type: notify.PlanReady,
Key: string(notify.PlanReady) + ":" + week.String(),
Title: planTitle(week, thisWeek),
Body: nameList(dinners),
URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
// ListReady is the list_ready event of the Supply run of the week starting
// on week: needed items to buy for its dinners.
func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
return notify.Event{
Type: notify.ListReady,
Key: string(notify.ListReady) + ":" + week.String(),
Title: "This week's Supply run is ready",
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
// own call to action.
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
return notify.Event{
Type: notify.DayOfReminder,
Key: string(notify.DayOfReminder) + ":" + date.String(),
Title: "Tonight: " + name + " · Recipe →",
URL: "/dinner/" + dinnerID,
Week: household.WeekStart(date),
}
}
// StartReminder is the day_of_reminder event, keyed
// day_of_reminder:start:{date}, reminding the household to start the
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
// a time in the household zone: "Start the crock pot by 9:30 am".
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
return notify.Event{
Type: notify.DayOfReminder,
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
Body: name,
URL: "/dinner/" + dinnerID,
Week: household.WeekStart(date),
}
}
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
// one, of the week starting on week: how many new Side quests there are,
// and their names.
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
return notify.Event{
Type: notify.NewSuggestions,
Key: string(notify.NewSuggestions) + ":" + id,
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
Body: nameList(names),
URL: "/discover",
Week: week,
}
}
// RotationReview is the rotation_review event, keyed
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
// in rotation?". It opens Home, where the Rotation check card asks.
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
return notify.Event{
Type: notify.RotationReview,
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
Title: "How was " + name + "? Keep it in rotation?",
URL: "/",
Week: household.WeekStart(planDate),
}
}
// SupplyRunItem is a Supply run item still needed, as sent to Google Chat;
// Qty is empty when the recipe gives none.
type SupplyRunItem struct {
Name string
Qty string
}
// SupplyRunGroup is one dinner of a Supply run sent to Google Chat, with
// its items still needed.
type SupplyRunGroup struct {
Dinner string
Items []SupplyRunItem
}
// SupplyRunText is the Google Chat message of the Supply run named name
// (its title, or its week label until it has one): each dinner with items
// still needed in bold, its items one per line, and the call to action.
//
// Supply run · Week 42 · Oct 12 – 18
//
// *Tacos*
// • ground beef — 1 lb
// • tortillas
//
// Open the Supply run →
//
// A Supply run with nothing still needed says so.
func SupplyRunText(name string, groups []SupplyRunGroup) string {
var b strings.Builder
b.WriteString("Supply run · " + name)
listed := false
for _, g := range groups {
if len(g.Items) == 0 {
continue
}
listed = true
b.WriteString("\n\n*" + g.Dinner + "*")
for _, it := range g.Items {
b.WriteString("\n• " + it.Name)
if it.Qty != "" {
b.WriteString(" — " + it.Qty)
}
}
}
if !listed {
b.WriteString("\n\nNothing left to buy.")
}
b.WriteString("\n\n" + actions[notify.ListReady] + " →")
return b.String()
}
// planTitle is the plan_ready title of the week starting on week, seen
// from the week starting on thisWeek.
func planTitle(week, thisWeek store.LocalDate) string {
y, m, d := thisWeek.Date()
next := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
switch week {
case thisWeek:
return "This week's plan is ready"
case next:
return "Next week's plan is ready"
}
_, m, d = week.Date()
return fmt.Sprintf("The plan for the week of %s %d is ready", m.String()[:3], d)
}
// Text is the Google Chat message of p: its title, its body after a colon
// and the event's call to action with an arrow. A title ending in an arrow,
// with no body, carries its own call to action and is the message as it is.
func Text(p notify.Payload) string {
text := p.Title
if p.Body == "" && strings.HasSuffix(text, "→") {
return text
}
if p.Body != "" {
if endsSentence(text) {
text += " "
} else {
text += ": "
}
text += p.Body
}
if !endsSentence(text) {
text += "."
}
if action, ok := actions[p.Event]; ok {
text += " " + action + " →"
}
return text
}
// endsSentence reports whether s ends with sentence punctuation.
func endsSentence(s string) bool {
return strings.HasSuffix(s, ".") || strings.HasSuffix(s, "?") || strings.HasSuffix(s, "!")
}
// nameList lists the first listedNames of names, then how many more there
// are: "Tacos, Bolognese, Tikka masala + 4 more".
func nameList(names []string) string {
if len(names) <= listedNames {
return strings.Join(names, ", ")
}
return fmt.Sprintf("%s + %d more", strings.Join(names[:listedNames], ", "), len(names)-listedNames)
}
// count is n with the singular or plural noun.
func count(n int, one, many string) string {
if n == 1 {
return "1 " + one
}
return fmt.Sprintf("%d %s", n, many)
}
-195
View File
@@ -1,195 +0,0 @@
package chat
import (
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
func mustDate(t *testing.T, s string) store.LocalDate {
t.Helper()
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
// payloadOf is the payload notify stores for ev.
func payloadOf(ev notify.Event) notify.Payload {
p := notify.Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
if !ev.Week.IsZero() {
p.Week = ev.Week.String()
}
return p
}
func TestMessages(t *testing.T) {
t.Parallel()
week := mustDate(t, "2026-10-12")
for _, tc := range []struct {
name string
ev notify.Event
key, url, week string
title, body, chatT string
}{
{
name: "plan ready",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
key: "plan_ready:2026-10-12",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 4 more",
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →",
},
{
name: "plan ready with three dinners",
ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}),
key: "plan_ready:2026-10-12",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "This week's plan is ready",
body: "Tacos, Bolognese, Tikka masala",
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →",
},
{
name: "plan ready for next week",
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
key: "plan_ready:2026-10-12",
url: "/plan?week=2026-10-12",
week: "2026-10-12",
title: "Next week's plan is ready",
body: "Tacos, Bolognese, Tikka masala + 1 more",
chatT: "Next week's plan is ready: Tacos, Bolognese, Tikka masala + 1 more. Open the plan →",
},
{
name: "plan ready for a later week",
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
key: "plan_ready:2026-10-26",
url: "/plan?week=2026-10-26",
week: "2026-10-26",
title: "The plan for the week of Oct 26 is ready",
body: "Tacos",
chatT: "The plan for the week of Oct 26 is ready: Tacos. Open the plan →",
},
{
name: "list ready",
ev: ListReady(week, 19, 7),
key: "list_ready:2026-10-12",
url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "19 items to buy for 7 dinners",
chatT: "This week's Supply run is ready: 19 items to buy for 7 dinners. Open the Supply run →",
},
{
name: "list ready singular",
ev: ListReady(week, 1, 1),
key: "list_ready:2026-10-12",
url: "/plan/shopping?week=2026-10-12",
week: "2026-10-12",
title: "This week's Supply run is ready",
body: "1 item to buy for 1 dinner",
chatT: "This week's Supply run is ready: 1 item to buy for 1 dinner. Open the Supply run →",
},
{
name: "day-of reminder",
ev: DayOfReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Tacos"),
key: "day_of_reminder:2026-10-15",
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
week: "2026-10-12",
title: "Tonight: Tacos · Recipe →",
body: "",
chatT: "Tonight: Tacos · Recipe →",
},
{
name: "crock-pot start reminder",
ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))),
key: "day_of_reminder:start:2026-10-15",
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
week: "2026-10-12",
title: "Start the crock pot by 9:30 am",
body: "Beef Chili",
chatT: "Start the crock pot by 9:30 am: Beef Chili. Open the recipe →",
},
{
name: "new suggestions",
ev: NewSuggestions("01J9BATCH", week, []string{"Lemon orzo", "Bibimbap", "Shakshuka", "Pho", "Paella"}),
key: "new_suggestions:01J9BATCH",
url: "/discover",
week: "2026-10-12",
title: "5 new Side quests are in",
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
},
{
name: "new suggestions of a scheduled week",
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
key: "new_suggestions:2026-W42",
url: "/discover",
week: "2026-10-12",
title: "1 new Side quest is in",
body: "Pho",
chatT: "1 new Side quest is in: Pho. Open Discover →",
},
{
name: "rotation review",
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
key: "rotation_review:01J9DINNER:2026-10-14",
url: "/",
week: "2026-10-12",
title: "How was Lemon orzo? Keep it in rotation?",
body: "",
chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →",
},
} {
ev := tc.ev
if ev.Key != tc.key || ev.URL != tc.url || ev.Week.String() != tc.week || ev.Title != tc.title || ev.Body != tc.body {
t.Errorf("%s: event = %+v, want key %q url %q week %s title %q body %q", tc.name, ev, tc.key, tc.url, tc.week, tc.title, tc.body)
}
if got := Text(payloadOf(ev)); got != tc.chatT {
t.Errorf("%s: Text = %q, want %q", tc.name, got, tc.chatT)
}
}
}
func TestSupplyRunText(t *testing.T) {
t.Parallel()
got := SupplyRunText("Week 42 · Oct 12 – 18", []SupplyRunGroup{
{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "tortillas"}}},
{Dinner: "Bolognese"},
{Dinner: "Pho", Items: []SupplyRunItem{{Name: "rice noodles", Qty: "200 g"}}},
})
want := "Supply run · Week 42 · Oct 12 – 18\n\n*Tacos*\n• ground beef — 1 lb\n• tortillas\n\n*Pho*\n• rice noodles — 200 g\n\nOpen the Supply run →"
if got != want {
t.Errorf("SupplyRunText =\n%s\nwant\n%s", got, want)
}
got = SupplyRunText("Cozy week", []SupplyRunGroup{{Dinner: "Tacos"}})
if want := "Supply run · Cozy week\n\nNothing left to buy.\n\nOpen the Supply run →"; got != want {
t.Errorf("SupplyRunText with nothing needed = %q, want %q", got, want)
}
}
func TestTextWithoutBody(t *testing.T) {
t.Parallel()
got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"})
if want := "This week's plan is ready. Open the plan →"; got != want {
t.Errorf("Text = %q, want %q", got, want)
}
}
func TestThreadKey(t *testing.T) {
t.Parallel()
for date, want := range map[string]string{
"2026-10-12": "mealquest-week-2026-W42",
"2026-12-28": "mealquest-week-2026-W53",
"2027-01-04": "mealquest-week-2027-W01",
} {
if got := ThreadKey(mustDate(t, date)); got != want {
t.Errorf("ThreadKey(%s) = %q, want %q", date, got, want)
}
}
}
-115
View File
@@ -1,115 +0,0 @@
package chat
import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
)
const (
// webhookPrefix is how every accepted webhook URL starts, compared
// exactly: no other scheme, host spelling, port or user info.
webhookPrefix = "https://chat.googleapis.com/v1/spaces/"
// webhookHost is the only host a webhook URL may name.
webhookHost = "chat.googleapis.com"
// MaxWebhookURLLength bounds an accepted webhook URL.
MaxWebhookURLLength = 2048
// MinCredentialLength is the shortest key or token accepted, so the
// masked URL's token tail never gives away most of it.
MinCredentialLength = 16
// maskedTail is how many trailing characters of the token the masked
// URL shows.
maskedTail = 4
)
// webhookPath is the path of a space's incoming webhook; the group is the
// space id.
var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`)
// credentialChars is the alphabet of a decoded key or token: URL-safe
// ASCII, so its length in bytes is its length in characters.
var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`)
// Webhook is a validated Google Chat incoming webhook URL. Its String is
// the full URL, a secret: it is only ever sealed for storage or sent to
// Google Chat, never logged, shown or put in an error.
type Webhook struct {
raw string
space string
token string
}
// ParseWebhook validates raw as
// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…,
// whose raw query is exactly key=…&token=…, in that order, each decoding to
// at least MinCredentialLength characters of credentialChars. Its error
// never quotes raw.
func ParseWebhook(raw string) (Webhook, error) {
switch {
case raw == "":
return Webhook{}, errors.New("is required")
case len(raw) > MaxWebhookURLLength:
return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength)
case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }):
return Webhook{}, errors.New("must not contain spaces or control characters")
case !strings.HasPrefix(raw, webhookPrefix):
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
case strings.Contains(raw, "#"):
return Webhook{}, errors.New("must not have a fragment")
}
u, err := url.Parse(raw)
if err != nil {
return Webhook{}, errors.New("is not a valid URL")
}
if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" {
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
}
m := webhookPath.FindStringSubmatch(u.Path)
if m == nil {
return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages")
}
// The raw query is exactly key=…&token=…: no empty segment, no second
// "?", no other or repeated parameter.
if strings.Contains(u.RawQuery, "?") {
return Webhook{}, errors.New("query must not contain ?")
}
segments := strings.Split(u.RawQuery, "&")
if len(segments) != 2 {
return Webhook{}, errors.New("query must be exactly key=…&token=…")
}
creds := make([]string, 2)
for i, name := range []string{"key", "token"} {
escaped, ok := strings.CutPrefix(segments[i], name+"=")
if !ok {
return Webhook{}, errors.New("query must be exactly key=…&token=…")
}
v, err := url.QueryUnescape(escaped)
if err != nil {
return Webhook{}, errors.New("query is not valid")
}
if !credentialChars.MatchString(v) {
return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name)
}
if len(v) < MinCredentialLength {
return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength)
}
creds[i] = v
}
return Webhook{raw: raw, space: m[1], token: creds[1]}, nil
}
// String returns the full webhook URL, a secret.
func (w Webhook) String() string {
return w.raw
}
// Masked returns the URL with its key hidden and only the last few
// characters of its token, such as
// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk".
func (w Webhook) Masked() string {
// The token is ASCII of at least MinCredentialLength bytes, so the
// tail is maskedTail of its many characters.
return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:]
}
-101
View File
@@ -1,101 +0,0 @@
package chat
import (
"strings"
"testing"
)
const (
testKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
testToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
testSpace = "AAQA1b2C3d4"
testHook = "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken
)
func TestParseWebhookAccepts(t *testing.T) {
t.Parallel()
for _, raw := range []string{
testHook,
"https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken,
} {
hook, err := ParseWebhook(raw)
if err != nil {
t.Errorf("ParseWebhook(%q) = %v, want accepted", raw, err)
continue
}
if hook.String() != raw {
t.Errorf("String() = %q, want %q", hook.String(), raw)
}
}
}
func TestParseWebhookRejects(t *testing.T) {
t.Parallel()
q := "?key=" + testKey + "&token=" + testToken
for name, raw := range map[string]string{
"empty": "",
"http": "http://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
"upper-case scheme": "HTTPS://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
"lookalike suffix": "https://chat.googleapis.com.evil.example/v1/spaces/" + testSpace + "/messages" + q,
"lookalike prefix": "https://evilchat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
"other google host": "https://www.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
"upper-case host": "https://CHAT.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
"port": "https://chat.googleapis.com:443/v1/spaces/" + testSpace + "/messages" + q,
"user info": "https://user@chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
"trailing dot host": "https://chat.googleapis.com./v1/spaces/" + testSpace + "/messages" + q,
"no space": "https://chat.googleapis.com/v1/spaces//messages" + q,
"extra path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages/x" + q,
"other version": "https://chat.googleapis.com/v2/spaces/" + testSpace + "/messages" + q,
"members path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/members" + q,
"escaped path": "https://chat.googleapis.com/v1/spaces/AA%2FQA/messages" + q,
"dot segment": "https://chat.googleapis.com/v1/spaces/../messages" + q,
"no query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages",
"no key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken,
"no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey,
"empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=",
"short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken,
"token first": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey,
"empty segment": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&&token=" + testToken,
"leading ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?&" + q[1:],
"trailing ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&",
"second question": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "?x",
"multi-byte token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + strings.Repeat("%F0%9F%98%80", 4),
"multi-byte key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + strings.Repeat("%F0%9F%98%80", 4) + "&token=" + testToken,
"plus in token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken + "+",
"repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey,
"extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x",
"bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz",
"fragment": testHook + "#frag",
"space": testHook + " ",
"newline": testHook + "\n",
"too long": testHook + strings.Repeat("A", MaxWebhookURLLength),
"not a URL": "chat webhook please",
} {
_, err := ParseWebhook(raw)
if err == nil {
t.Errorf("%s: ParseWebhook(%q) accepted", name, raw)
continue
}
for _, secret := range []string{testKey, testToken, testSpace} {
if strings.Contains(err.Error(), secret) {
t.Errorf("%s: error %q quotes the URL", name, err)
}
}
}
}
func TestWebhookMasked(t *testing.T) {
t.Parallel()
hook, err := ParseWebhook(testHook)
if err != nil {
t.Fatal(err)
}
// The tail is of the token as it decodes: "%3D" is "=".
want := "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=…&token=…ZxQ="
if got := hook.Masked(); got != want {
t.Errorf("Masked() = %q, want %q", got, want)
}
if m := hook.Masked(); strings.Contains(m, testKey) || strings.Contains(m, testToken) || strings.Contains(m, testToken[:8]) {
t.Errorf("Masked() = %q holds the key or token", m)
}
}
-93
View File
@@ -3,11 +3,9 @@
package config
import (
"crypto/ecdh"
"errors"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"strconv"
@@ -16,7 +14,6 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// Environment variable names.
@@ -32,13 +29,6 @@ const (
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
@@ -71,15 +61,6 @@ type Config struct {
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
// GoogleClientID and GoogleClientSecret are the Google OAuth web client
// Push to Calendar connects with. Its redirect URI is PublicURL's origin
// followed by /api/v1/integrations/google/callback.
GoogleClientID string
GoogleClientSecret Secret
// VAPID is the key pair and contact subject Web Push signs with.
VAPID VAPID
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
@@ -103,20 +84,6 @@ type Config struct {
SearXNGURL string
}
// VAPID is the Web Push signing identity (RFC 8292). All three variables
// are required together, and the private key must belong to the public key.
type VAPID struct {
// PublicKey is the unpadded base64url uncompressed P-256 point, as
// browsers take it for applicationServerKey.
PublicKey string
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
PrivateKey Secret
// Subject is the push services' contact for this sender: a mailto: URI
// or an https:// URL.
Subject string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
// that are safe to log.
func (c Config) PublicOrigin() string {
@@ -143,11 +110,6 @@ func load(lookup func(string) (string, bool)) (Config, error) {
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
GoogleClientID: r.str(EnvGoogleClientID),
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
VAPID: r.vapid(),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
@@ -413,61 +375,6 @@ func (r *reader) secretFile(name string) Secret {
return Secret{value: value}
}
// vapid reads the VAPID public key, private key file and subject, each
// required, and checks the private key against the public key by deriving
// its public point. A missing variable is reported by name; the private key
// is never echoed.
func (r *reader) vapid() VAPID {
cfg := VAPID{
PublicKey: r.str(EnvVAPIDPublicKey),
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
Subject: r.vapidSubject(EnvVAPIDSubject),
}
var public *ecdh.PublicKey
if cfg.PublicKey != "" {
k, err := vapid.ParsePublicKey(cfg.PublicKey)
if err != nil {
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
}
public = k
}
var private *ecdh.PrivateKey
if cfg.PrivateKey.Reveal() != "" {
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
if err != nil {
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
}
private = k
}
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
}
return cfg
}
// vapidSubject reads a VAPID subject: mailto: followed by one email
// address, or an absolute https:// URL.
func (r *reader) vapidSubject(name string) string {
v, ok := r.required(name)
if !ok {
return ""
}
if addr, found := strings.CutPrefix(v, "mailto:"); found {
parsed, err := mail.ParseAddress(addr)
if err != nil || parsed.Address != addr {
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
return ""
}
return v
}
u, err := url.Parse(v)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
return ""
}
return v
}
func unwrapPathError(err error) error {
var pe *os.PathError
if errors.As(err, &pe) {
+1 -217
View File
@@ -14,24 +14,8 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
// cmd/vapidgen tells the operator to, and returns the pair.
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
t.Helper()
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
return pair, path
}
func TestLoad(t *testing.T) {
t.Parallel()
@@ -45,24 +29,10 @@ func TestLoad(t *testing.T) {
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
googleSecretPath := filepath.Join(dir, "google_client_secret")
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
emptyPath := filepath.Join(dir, "empty_secret")
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
}
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
t.Fatal(err)
}
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
t.Fatal(err)
}
valid := func() map[string]string {
return map[string]string{
@@ -76,13 +46,6 @@ func TestLoad(t *testing.T) {
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: "mealquest-admin",
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvVAPIDPublicKey: vapidPair.PublicKey,
EnvVAPIDPrivateKeyFile: vapidKeyPath,
EnvVAPIDSubject: "mailto:owner@example.com",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -131,109 +94,8 @@ func TestLoad(t *testing.T) {
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
EnvGoogleClientID, EnvGoogleClientSecretFile,
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
},
},
{
name: "VAPID public key missing",
env: with(nil, EnvVAPIDPublicKey),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID private key file missing",
env: with(nil, EnvVAPIDPrivateKeyFile),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID subject missing",
env: with(nil, EnvVAPIDSubject),
badVars: []string{EnvVAPIDSubject},
},
{
name: "only the VAPID public key set",
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
},
{
name: "no VAPID variable set",
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
},
{
name: "VAPID private key file unreadable",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID private key file empty",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "inline VAPID private key rejected",
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
},
{
name: "VAPID private key as PEM",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "padded VAPID private key",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID private key from another pair",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID public key from another pair",
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID public key malformed",
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID public key padded",
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID private key given as the public key",
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID https subject",
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
},
{
name: "VAPID subject without mailto:",
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID mailto: subject without an address",
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID mailto: subject with a display name",
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID plain http subject",
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "invalid duration",
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
@@ -349,31 +211,6 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
badVars: []string{"MEALQUEST_SECRET_KEY"},
},
{
name: "Google client ID missing",
env: with(nil, EnvGoogleClientID),
badVars: []string{EnvGoogleClientID},
},
{
name: "Google client secret file missing",
env: with(nil, EnvGoogleClientSecretFile),
badVars: []string{EnvGoogleClientSecretFile},
},
{
name: "Google client secret file unreadable",
env: with(map[string]string{EnvGoogleClientSecretFile: unreadablePath}),
badVars: []string{EnvGoogleClientSecretFile},
},
{
name: "Google client secret file empty",
env: with(map[string]string{EnvGoogleClientSecretFile: emptyPath}),
badVars: []string{EnvGoogleClientSecretFile},
},
{
name: "inline Google client secret rejected",
env: with(map[string]string{"MEALQUEST_GOOGLE_CLIENT_SECRET": "inline"}),
badVars: []string{"MEALQUEST_GOOGLE_CLIENT_SECRET"},
},
{
name: "inline OIDC client secret rejected",
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
@@ -572,11 +409,6 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
googleSecretPath := filepath.Join(dir, "google_client_secret")
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
@@ -589,13 +421,6 @@ func TestLoadValues(t *testing.T) {
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvVAPIDPublicKey: vapidPair.PublicKey,
EnvVAPIDPrivateKeyFile: vapidKeyPath,
EnvVAPIDSubject: "mailto:owner@example.com",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
@@ -629,15 +454,6 @@ func TestLoadValues(t *testing.T) {
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
}
if cfg.GoogleClientID != "mealquest.apps.googleusercontent.com" {
t.Errorf("GoogleClientID = %q", cfg.GoogleClientID)
}
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
}
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
@@ -676,7 +492,7 @@ func TestSecretRedacted(t *testing.T) {
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
cfg := Config{SecretKey: s, OIDCClientSecret: s}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)
@@ -685,38 +501,6 @@ func TestSecretRedacted(t *testing.T) {
}
}
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
// problem names the variable without echoing the file's contents.
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pair, _ := writeVAPIDKey(t, dir, "unused")
other, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
for name, contents := range map[string]string{
"mismatched": other.PrivateKey,
"padded": pair.PrivateKey + "=",
"truncated": pair.PrivateKey[:40],
} {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
var cerr *Error
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
}
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
t.Errorf("%s: error quotes the private key: %v", name, err)
}
}
}
func TestPublicURLWithCredentialsRejected(t *testing.T) {
t.Parallel()
-221
View File
@@ -214,26 +214,6 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
return result.RowsAffected()
}
const getActivePhotoBackfillJob = `-- name: GetActivePhotoBackfillJob :one
SELECT id
FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.dinner_id') IS NULL
ORDER BY id
LIMIT 1
`
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error) {
row := q.db.QueryRowContext(ctx, getActivePhotoBackfillJob, kind)
var id string
err := row.Scan(&id)
return id, err
}
const getDinner = `-- name: GetDinner :one
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 id = ?
@@ -364,58 +344,6 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
return i, err
}
const groundFailedDinner = `-- name: GroundFailedDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?8
`
type GroundFailedDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
func (q *Queries) GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundFailedDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
@@ -516,64 +444,6 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
return items, nil
}
const listPhotoBackfillDinners = `-- name: ListPhotoBackfillDinners :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 <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND id = COALESCE(?1, id)
ORDER BY normalized_name, id
`
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
func (q *Queries) ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listPhotoBackfillDinners, id)
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 listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT 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, recipes.source_url AS recipe_source_url
FROM dinners
@@ -633,97 +503,6 @@ func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDi
return items, nil
}
const listUnplannedQuestLogDinners = `-- name: ListUnplannedQuestLogDinners :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 = '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
)
ORDER BY normalized_name, id
`
// The Quest log: dinners with status quest_log that no plan for the week
// starting from_week_start or later has on any day.
func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listUnplannedQuestLogDinners, fromWeekStart)
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 refailDinnerGrounding = `-- name: RefailDinnerGrounding :execrows
UPDATE dinners
SET grounding_error = ?1,
updated_at = ?2
WHERE id = ?3
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?4
`
type RefailDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
func (q *Queries) RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, refailDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
+9 -15
View File
@@ -52,26 +52,20 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
return items, nil
}
const listRecentPassedNames = `-- name: ListRecentPassedNames :many
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
ORDER BY decided_at DESC, id DESC
SELECT dinners.name
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'declined'
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
LIMIT ?
`
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
func (q *Queries) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentPassedNames, limit)
// The names of the most recently declined suggestions, newest first.
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
if err != nil {
return nil, err
}
+9 -14
View File
@@ -228,7 +228,6 @@ type RotationReview struct {
PlanDate string
Status string
Decision sql.NullString
Name sql.NullString
DecidedAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
@@ -258,7 +257,6 @@ type ShoppingGroup struct {
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
@@ -276,23 +274,21 @@ type ShoppingItem struct {
}
type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ChatSendUnconfirmedAt sql.NullString
ID string
WeekStart string
PlanID string
Title string
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
}
type Suggestion struct {
ID string
DinnerID string
BatchID string
Name string
Reason string
CitedRegularIds string
Status string
@@ -319,5 +315,4 @@ type WeekPlan struct {
CalendarSyncedAt sql.NullString
CreatedAt string
UpdatedAt string
Revision int64
}
-326
View File
@@ -1,326 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: notifications.sql
package db
import (
"context"
"database/sql"
)
const claimDueNotification = `-- name: ClaimDueNotification :one
UPDATE notification_outbox
SET next_attempt_at = ?1, updated_at = ?2
WHERE id = (
SELECT id FROM notification_outbox
WHERE status = 'pending' AND next_attempt_at <= ?2
ORDER BY next_attempt_at, id
LIMIT 1
)
RETURNING id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
`
type ClaimDueNotificationParams struct {
LeaseUntil sql.NullString
Now string
}
// Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being
// delivered. Returns sql.ErrNoRows when none is due.
func (q *Queries) ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) {
row := q.db.QueryRowContext(ctx, claimDueNotification, arg.LeaseUntil, arg.Now)
var i NotificationOutbox
err := row.Scan(
&i.ID,
&i.EventKey,
&i.Channel,
&i.Payload,
&i.Status,
&i.Attempts,
&i.NextAttemptAt,
&i.LastError,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const failNotification = `-- name: FailNotification :execrows
UPDATE notification_outbox
SET status = 'failed', attempts = attempts + ?1, next_attempt_at = NULL, last_error = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
`
type FailNotificationParams struct {
Attempted int64
LastError sql.NullString
Now string
ID string
LeaseUntil sql.NullString
}
// Gives up on a notification. attempts counts the delivery attempt that
// failed, if one was made. The lease must still be the claim's.
func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failNotification,
arg.Attempted,
arg.LastError,
arg.Now,
arg.ID,
arg.LeaseUntil,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getNotificationPref = `-- name: GetNotificationPref :one
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
WHERE event = ?
`
func (q *Queries) GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) {
row := q.db.QueryRowContext(ctx, getNotificationPref, event)
var i NotificationPref
err := row.Scan(
&i.Event,
&i.Push,
&i.Chat,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const hasNotificationSince = `-- name: HasNotificationSince :one
SELECT EXISTS (
SELECT 1 FROM notification_outbox
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
) AS found
`
// Reports whether a notification was stored after since, or at all when
// since is NULL.
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
var found bool
err := row.Scan(&found)
return found, err
}
const insertNotification = `-- name: InsertNotification :execrows
INSERT INTO notification_outbox (
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
) VALUES (
?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6
)
ON CONFLICT (event_key, channel) DO NOTHING
`
type InsertNotificationParams struct {
ID string
EventKey string
Channel string
Payload string
NextAttemptAt sql.NullString
Now string
}
// Stores a pending notification for one channel, due at once. A row already
// stored for the event key and channel, in any status, is left unchanged and
// no row is inserted.
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, insertNotification,
arg.ID,
arg.EventKey,
arg.Channel,
arg.Payload,
arg.NextAttemptAt,
arg.Now,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const listNotificationPrefs = `-- name: ListNotificationPrefs :many
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
`
func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error) {
rows, err := q.db.QueryContext(ctx, listNotificationPrefs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []NotificationPref
for rows.Next() {
var i NotificationPref
if err := rows.Scan(
&i.Event,
&i.Push,
&i.Chat,
&i.CreatedAt,
&i.UpdatedAt,
); 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 listRecentNotifications = `-- name: ListRecentNotifications :many
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
WHERE o.id = (
SELECT i.id FROM notification_outbox i
WHERE i.event_key = o.event_key
ORDER BY i.created_at DESC, i.id DESC
LIMIT 1
)
ORDER BY o.created_at DESC, o.event_key DESC
LIMIT ?1
`
type ListRecentNotificationsRow struct {
EventKey string
Payload string
CreatedAt string
}
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRecentNotificationsRow
for rows.Next() {
var i ListRecentNotificationsRow
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); 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 markNotificationSent = `-- name: MarkNotificationSent :execrows
UPDATE notification_outbox
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
WHERE id = ?2 AND status = 'pending' AND next_attempt_at = ?3
`
type MarkNotificationSentParams struct {
Now string
ID string
LeaseUntil sql.NullString
}
// Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's.
func (q *Queries) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markNotificationSent, arg.Now, arg.ID, arg.LeaseUntil)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const nextNotificationDue = `-- name: NextNotificationDue :one
SELECT next_attempt_at FROM notification_outbox
WHERE status = 'pending'
ORDER BY next_attempt_at
LIMIT 1
`
// Returns when the next pending notification is due. Returns sql.ErrNoRows
// when none is pending.
func (q *Queries) NextNotificationDue(ctx context.Context) (sql.NullString, error) {
row := q.db.QueryRowContext(ctx, nextNotificationDue)
var next_attempt_at sql.NullString
err := row.Scan(&next_attempt_at)
return next_attempt_at, err
}
const retryNotification = `-- name: RetryNotification :execrows
UPDATE notification_outbox
SET attempts = attempts + 1, next_attempt_at = ?1, last_error = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
`
type RetryNotificationParams struct {
NextAttemptAt sql.NullString
LastError sql.NullString
Now string
ID string
LeaseUntil sql.NullString
}
// Records a failed delivery and when to try again. The lease must still be
// the claim's.
func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) {
result, err := q.db.ExecContext(ctx, retryNotification,
arg.NextAttemptAt,
arg.LastError,
arg.Now,
arg.ID,
arg.LeaseUntil,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const updateNotificationPref = `-- name: UpdateNotificationPref :execrows
UPDATE notification_prefs
SET push = ?, chat = ?, updated_at = ?
WHERE event = ?
`
type UpdateNotificationPrefParams struct {
Push int64
Chat int64
UpdatedAt string
Event string
}
// Sets which channels an event goes to; affects no row when the event has
// no preference row.
func (q *Queries) UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error) {
result, err := q.db.ExecContext(ctx, updateNotificationPref,
arg.Push,
arg.Chat,
arg.UpdatedAt,
arg.Event,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-450
View File
@@ -1,450 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: plans.sql
package db
import (
"context"
"database/sql"
)
const clearCalendarEvents = `-- name: ClearCalendarEvents :exec
UPDATE plan_days
SET calendar_event_id = NULL, updated_at = ?
WHERE calendar_event_id IS NOT NULL
`
// Clears every day's calendar event id: none is on the chosen calendar.
func (q *Queries) ClearCalendarEvents(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, clearCalendarEvents, updatedAt)
return err
}
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, plan_days.calendar_event_id, 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
CalendarEventID sql.NullString
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.CalendarEventID,
&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 markCalendarPlansEdited = `-- name: MarkCalendarPlansEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE EXISTS (
SELECT 1 FROM plan_days
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
)
`
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
func (q *Queries) MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, markCalendarPlansEdited, updatedAt)
return err
}
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 markWeekPlanSynced = `-- name: MarkWeekPlanSynced :execrows
UPDATE week_plans
SET status = 'synced', calendar_synced_at = ?, updated_at = ?
WHERE id = ? AND revision = ?
`
type MarkWeekPlanSyncedParams struct {
CalendarSyncedAt sql.NullString
UpdatedAt string
ID string
Revision int64
}
// Records a Push to Calendar of plan id as it was at revision: the plan
// becomes synced. A plan edited since (another revision) is left as it is
// (0 rows).
func (q *Queries) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markWeekPlanSynced,
arg.CalendarSyncedAt,
arg.UpdatedAt,
arg.ID,
arg.Revision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
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,
calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END,
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. An existing day keeps its calendar
// event id while its dinner stays the same; a new dinner clears it, since
// Calendar no longer shows the day as planned.
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 setPlanDayCalendarEvent = `-- name: SetPlanDayCalendarEvent :execrows
UPDATE plan_days
SET calendar_event_id = ?, updated_at = ?
WHERE plan_id = ? AND date = ?
`
type SetPlanDayCalendarEventParams struct {
CalendarEventID sql.NullString
UpdatedAt string
PlanID string
Date string
}
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
func (q *Queries) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setPlanDayCalendarEvent,
arg.CalendarEventID,
arg.UpdatedAt,
arg.PlanID,
arg.Date,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const swapPlanDay = `-- name: SwapPlanDay :execrows
UPDATE plan_days
SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, 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, clearing its calendar
// event id since Calendar no longer shows the day as planned; 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()
}
-160
View File
@@ -1,160 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: push_subscriptions.sql
package db
import (
"context"
"database/sql"
)
const deletePushSubscription = `-- name: DeletePushSubscription :execrows
DELETE FROM push_subscriptions
WHERE endpoint = ?
`
func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) {
result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec
DELETE FROM push_subscriptions
WHERE id = ?
`
// Removes a subscription the push service reports gone (404 or 410).
func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id)
return err
}
const listPushSubscriptions = `-- name: ListPushSubscriptions :many
SELECT id, endpoint, p256dh, auth
FROM push_subscriptions
ORDER BY created_at, id
`
type ListPushSubscriptionsRow struct {
ID string
Endpoint string
P256dh string
Auth string
}
// Every stored subscription with its keys, for the push sender, oldest
// first.
func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) {
rows, err := q.db.QueryContext(ctx, listPushSubscriptions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPushSubscriptionsRow
for rows.Next() {
var i ListPushSubscriptionsRow
if err := rows.Scan(
&i.ID,
&i.Endpoint,
&i.P256dh,
&i.Auth,
); 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 recordPushFailure = `-- name: RecordPushFailure :exec
UPDATE push_subscriptions
SET failure_count = failure_count + 1
WHERE id = ?
`
// Counts a delivery to a subscription that the push service did not accept.
func (q *Queries) RecordPushFailure(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, recordPushFailure, id)
return err
}
const recordPushSuccess = `-- name: RecordPushSuccess :exec
UPDATE push_subscriptions
SET last_success_at = ?
WHERE id = ?
`
type RecordPushSuccessParams struct {
LastSuccessAt sql.NullString
ID string
}
// Records that the push service accepted a notification for a subscription.
func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error {
_, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID)
return err
}
const upsertPushSubscription = `-- name: UpsertPushSubscription :one
INSERT INTO push_subscriptions (
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, 0, ?, ?
)
ON CONFLICT (endpoint) DO UPDATE SET
p256dh = excluded.p256dh,
auth = excluded.auth,
user_agent = excluded.user_agent,
failure_count = 0,
updated_at = excluded.updated_at
RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at
`
type UpsertPushSubscriptionParams struct {
ID string
Endpoint string
P256dh string
Auth string
UserAgent sql.NullString
CreatedAt string
UpdatedAt string
}
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
// an endpoint already stored replaces its keys and user agent and clears
// its failure count, keeping its id, created_at and last_success_at.
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) {
row := q.db.QueryRowContext(ctx, upsertPushSubscription,
arg.ID,
arg.Endpoint,
arg.P256dh,
arg.Auth,
arg.UserAgent,
arg.CreatedAt,
arg.UpdatedAt,
)
var i PushSubscription
err := row.Scan(
&i.ID,
&i.Endpoint,
&i.P256dh,
&i.Auth,
&i.UserAgent,
&i.FailureCount,
&i.LastSuccessAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+2 -261
View File
@@ -12,41 +12,17 @@ import (
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error)
// Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being
// delivered. Returns sql.ErrNoRows when none is due.
ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error)
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
// Claims one period of a schedule. The insert is the claim: 1 row means
// this caller owns the period, 0 rows means it was already claimed.
ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error)
// Clears every day's calendar event id: none is on the chosen calendar.
ClearCalendarEvents(ctx context.Context, updatedAt string) error
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Clears the send of list id begun, once Google Chat refused it.
ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
// How many suggestions ListDeck returns.
CountDeck(ctx context.Context) (int64, error)
// How many Regulars the household has; archived dinners are not counted.
CountRegularDinners(ctx context.Context) (int64, error)
// How many Rotation checks dinner_id has had, pending or decided.
CountRotationReviews(ctx context.Context, dinnerID string) (int64, error)
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
@@ -56,47 +32,16 @@ type Querier interface {
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error
// A new item is needed until the household marks it "have it".
CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error
// A new, untitled Supply run: the title is written later, by the AI.
CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error
// name is the name the dish is suggested under, kept whatever its dinner is
// 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 Rotation check, with the
// dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, 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)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteIntegrationsGoogle(ctx context.Context) error
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
// Removes a subscription the push service reports gone (404 or 410).
DeletePushSubscriptionByID(ctx context.Context, id string) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
DeleteShoppingGroup(ctx context.Context, id string) error
DeleteShoppingItem(ctx context.Context, id string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Gives up on a notification. attempts counts the delivery attempt that
// failed, if one was made. The lease must still be the claim's.
FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
// archived dinner is left as it is; archiving stopped its grounding. Run at
// boot, after the job runner fails the jobs a previous process left running.
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
// Fails every batch still pending or running whose job is no longer queued
// or running: the process stopped, or recording the batch's end failed,
// before its handler recorded a result. Run before reading the active batch.
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
// Records why the background grounding of an unarchived dinner still
// pending at the grounding_revision it was grounded for failed; a dinner
// grounded some other way, renamed or archived meanwhile is left as it is
@@ -104,49 +49,16 @@ type Querier interface {
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
// Fails every job left running, at boot before any worker starts.
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (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 id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
// Returns sql.ErrNoRows until the row exists.
GetChatSettings(ctx context.Context) (ChatSetting, error)
GetDinner(ctx context.Context, id string) (Dinner, error)
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error)
// Returns sql.ErrNoRows until the row exists.
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
// Returns sql.ErrNoRows until the row exists.
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
GetJob(ctx context.Context, id string) (Job, error)
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
// is none.
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
GetNotificationPref(ctx context.Context, event string) (NotificationPref, error)
// The pending Rotation check of dinner_id. MarkTried makes one only for a
// dinner in the Quest log, which it moves to tried, so a dinner has at
// most one. Returns sql.ErrNoRows when there is none.
GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error)
GetPhoto(ctx context.Context, id string) (Photo, error)
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
@@ -155,21 +67,6 @@ type Querier interface {
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
// Returns sql.ErrNoRows until the row exists.
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
// such item.
GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error)
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
// when there is none.
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// Batch id. Returns sql.ErrNoRows when there is none.
GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, 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
@@ -179,13 +76,6 @@ type Querier interface {
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
@@ -193,172 +83,27 @@ type Querier interface {
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// Reports whether a notification was stored after since, or at all when
// since is NULL.
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
// Stores a pending notification for one channel, due at once. A row already
// stored for the event key and channel, in any status, is left unchanged and
// no row is inserted.
InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error)
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
ListDeck(ctx context.Context) ([]ListDeckRow, 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)
ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error)
// Every name the household turned down, oldest decision first: the name
// each declined suggestion was suggested under, which renaming its dinner
// does not change, and the name each dinner a Rotation check dropped
// ("Not for us") had when dropped, which renaming it does not change.
ListPassedNames(ctx context.Context) ([]string, error)
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]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)
// Every stored subscription with its keys, for the push sender, oldest
// first.
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
// The names of the most recently declined suggestions, newest first.
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// The groups of list list_id with their dinner's name, in list order.
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
// The groups of list list_id, in list order.
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
// 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 that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
// Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's.
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, 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
// Records a Push to Calendar of plan id as it was at revision: the plan
// becomes synced. A plan edited since (another revision) is left as it is
// (0 rows).
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error
// Places item id in group group_id at position with quantity qty; its
// name and needed flag are kept.
MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) 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)
// Moves a dinner still tried to status (regular, archived or quest_log)
// by a Rotation check decision, recording updated_by; its origin is kept.
// A dinner no longer tried is left as it is (0 rows).
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
// Returns when the next pending notification is due. Returns sql.ErrNoRows
// when none is pending.
NextNotificationDue(ctx context.Context) (sql.NullString, 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. An existing day keeps its calendar
// event id while its dinner stays the same; a new dinner clears it, since
// Calendar no longer shows the day as planned.
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
// Records the outcome of a test message sent to the webhook sealed in
// webhook_url_ct; affects no row when the webhook has been replaced or
// removed since the test began.
RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error)
// Counts a delivery to a subscription that the push service did not accept.
RecordPushFailure(ctx context.Context, id string) error
// Records that the push service accepted a notification for a subscription.
RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error
// Records a new draft over plan id: its status and note, raising its
// revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error)
// Records a generation of list id from plan plan_id.
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
// Records a failed delivery and when to try again. The lease must still be
// the claim's.
RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Sets the chosen calendar; affects no row when nothing is connected.
SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error)
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
// Marks item id needed (1) or "have it" (0).
SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error
// Titles list id, unless it has a title already or is frozen into the
// Supply archive.
SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, 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, clearing its calendar
// event id since Calendar no longer shows the day as planned; 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.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Sets which channels an event goes to; affects no row when the event has
// no preference row.
UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
@@ -371,10 +116,6 @@ type Querier interface {
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
// an endpoint already stored replaces its keys and user agent and clears
// its failure count, keeping its id, created_at and last_success_at.
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
-257
View File
@@ -1,257 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: rotation_reviews.sql
package db
import (
"context"
"database/sql"
)
const countRotationReviews = `-- name: CountRotationReviews :one
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?
`
// How many Rotation checks dinner_id has had, pending or decided.
func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) {
row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRotationReview = `-- name: CreateRotationReview :exec
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?)
`
type CreateRotationReviewParams struct {
ID string
DinnerID string
PlanDate string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error {
_, err := q.db.ExecContext(ctx, createRotationReview,
arg.ID,
arg.DinnerID,
arg.PlanDate,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const decideRotationReview = `-- name: DecideRotationReview :execrows
UPDATE rotation_reviews
SET status = 'decided',
decision = ?1,
name = ?2,
decided_at = ?3,
updated_by = ?4,
updated_at = ?3
WHERE id = ?5 AND status = 'pending'
`
type DecideRotationReviewParams struct {
Decision sql.NullString
Name sql.NullString
Now sql.NullString
UpdatedBy sql.NullString
ID string
}
// Records the household's decision on a pending Rotation check, with the
// dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideRotationReview,
arg.Decision,
arg.Name,
arg.Now,
arg.UpdatedBy,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getPendingRotationReview = `-- name: GetPendingRotationReview :one
SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending'
`
// The pending Rotation check of dinner_id. MarkTried makes one only for a
// dinner in the Quest log, which it moves to tried, so a dinner has at
// most one. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) {
row := q.db.QueryRowContext(ctx, getPendingRotationReview, dinnerID)
var i RotationReview
err := row.Scan(
&i.ID,
&i.DinnerID,
&i.PlanDate,
&i.Status,
&i.Decision,
&i.Name,
&i.DecidedAt,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listPendingRotationReviews = `-- name: ListPendingRotationReviews :many
SELECT rotation_reviews.plan_date, 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 rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
ORDER BY rotation_reviews.plan_date, rotation_reviews.id
`
type ListPendingRotationReviewsRow struct {
PlanDate string
Dinner Dinner
}
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
func (q *Queries) ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingRotationReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingRotationReviewsRow
for rows.Next() {
var i ListPendingRotationReviewsRow
if err := rows.Scan(
&i.PlanDate,
&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 listTriedCandidates = `-- name: ListTriedCandidates :many
SELECT DISTINCT dinners.id
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.date = ?1
AND dinners.status = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM rotation_reviews
WHERE rotation_reviews.dinner_id = dinners.id
AND rotation_reviews.plan_date = plan_days.date
)
ORDER BY dinners.id
`
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
func (q *Queries) ListTriedCandidates(ctx context.Context, date string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listTriedCandidates, date)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markDinnerTried = `-- name: MarkDinnerTried :execrows
UPDATE dinners
SET status = 'tried', updated_at = ?
WHERE id = ? AND status = 'quest_log'
`
type MarkDinnerTriedParams struct {
UpdatedAt string
ID string
}
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
func (q *Queries) MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markDinnerTried, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveTriedDinner = `-- name: MoveTriedDinner :execrows
UPDATE dinners
SET status = ?1, updated_by = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'tried'
`
type MoveTriedDinnerParams struct {
Status string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Moves a dinner still tried to status (regular, archived or quest_log)
// by a Rotation check decision, recording updated_by; its origin is kept.
// A dinner no longer tried is left as it is (0 rows).
func (q *Queries) MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveTriedDinner,
arg.Status,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-38
View File
@@ -1,38 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: schedule_runs.sql
package db
import (
"context"
)
const claimScheduleRun = `-- name: ClaimScheduleRun :execrows
INSERT INTO schedule_runs (id, schedule, period_key, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (schedule, period_key) DO NOTHING
`
type ClaimScheduleRunParams struct {
ID string
Schedule string
PeriodKey string
CreatedAt string
}
// Claims one period of a schedule. The insert is the claim: 1 row means
// this caller owns the period, 0 rows means it was already claimed.
func (q *Queries) ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) {
result, err := q.db.ExecContext(ctx, claimScheduleRun,
arg.ID,
arg.Schedule,
arg.PeriodKey,
arg.CreatedAt,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-99
View File
@@ -10,38 +10,6 @@ import (
"database/sql"
)
const clearIntegrationsGoogleToken = `-- name: ClearIntegrationsGoogleToken :execrows
UPDATE integrations_google
SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ?
WHERE id = 1
`
type ClearIntegrationsGoogleTokenParams struct {
LastSyncError sql.NullString
UpdatedAt string
}
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
func (q *Queries) ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearIntegrationsGoogleToken, arg.LastSyncError, arg.UpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec
DELETE FROM integrations_google
WHERE id = 1
`
func (q *Queries) DeleteIntegrationsGoogle(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteIntegrationsGoogle)
return err
}
const getAISettings = `-- name: GetAISettings :one
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
WHERE id = 1
@@ -161,73 +129,6 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
return i, err
}
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
UPDATE household_settings
SET updates_seen_at = CAST(?1 AS TEXT)
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
`
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const recordChatTest = `-- name: RecordChatTest :execrows
UPDATE chat_settings
SET last_test_at = ?, last_test_status = ?, updated_at = ?
WHERE id = 1 AND webhook_url_ct = ?
`
type RecordChatTestParams struct {
LastTestAt sql.NullString
LastTestStatus sql.NullString
UpdatedAt string
WebhookUrlCt []byte
}
// Records the outcome of a test message sent to the webhook sealed in
// webhook_url_ct; affects no row when the webhook has been replaced or
// removed since the test began.
func (q *Queries) RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) {
result, err := q.db.ExecContext(ctx, recordChatTest,
arg.LastTestAt,
arg.LastTestStatus,
arg.UpdatedAt,
arg.WebhookUrlCt,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
UPDATE integrations_google
SET calendar_id = ?, calendar_name = ?, updated_at = ?
WHERE id = 1
`
type SetIntegrationsGoogleCalendarParams struct {
CalendarID sql.NullString
CalendarName sql.NullString
UpdatedAt string
}
// Sets the chosen calendar; affects no row when nothing is connected.
func (q *Queries) SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setIntegrationsGoogleCalendar, arg.CalendarID, arg.CalendarName, arg.UpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertAISettings = `-- name: UpsertAISettings :one
INSERT INTO ai_settings (
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
-533
View File
@@ -1,533 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: shopping.sql
package db
import (
"context"
"database/sql"
)
const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type BeginShoppingListChatSendParams struct {
ChatSendUnconfirmedAt sql.NullString
UpdatedAt string
ID string
}
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ?
`
type ClearShoppingListChatSendParams struct {
UpdatedAt string
ID string
}
// Clears the send of list id begun, once Google Chat refused it.
func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`
type CreateShoppingGroupParams struct {
ID string
ListID string
DinnerID string
Date string
Position int64
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error {
_, err := q.db.ExecContext(ctx, createShoppingGroup,
arg.ID,
arg.ListID,
arg.DinnerID,
arg.Date,
arg.Position,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createShoppingItem = `-- name: CreateShoppingItem :exec
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
`
type CreateShoppingItemParams struct {
ID string
GroupID string
Name string
Qty sql.NullString
Position int64
CreatedAt string
UpdatedAt string
}
// A new item is needed until the household marks it "have it".
func (q *Queries) CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error {
_, err := q.db.ExecContext(ctx, createShoppingItem,
arg.ID,
arg.GroupID,
arg.Name,
arg.Qty,
arg.Position,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createShoppingList = `-- name: CreateShoppingList :exec
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
VALUES (?, ?, ?, 'current', ?, ?)
`
type CreateShoppingListParams struct {
ID string
WeekStart string
PlanID string
CreatedAt string
UpdatedAt string
}
// A new, untitled Supply run: the title is written later, by the AI.
func (q *Queries) CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error {
_, err := q.db.ExecContext(ctx, createShoppingList,
arg.ID,
arg.WeekStart,
arg.PlanID,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const deleteShoppingGroup = `-- name: DeleteShoppingGroup :exec
DELETE FROM shopping_groups
WHERE id = ?
`
func (q *Queries) DeleteShoppingGroup(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteShoppingGroup, id)
return err
}
const deleteShoppingItem = `-- name: DeleteShoppingItem :exec
DELETE FROM shopping_items
WHERE id = ?
`
func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, deleteShoppingItem, id)
return err
}
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
UPDATE shopping_lists
SET status = 'archived', frozen_at = ?, updated_at = ?
WHERE id = ? AND frozen_at IS NULL
`
type FreezeShoppingListParams struct {
FrozenAt sql.NullString
UpdatedAt string
ID string
}
// Freezes list id into the Supply archive at frozen_at, unless it is
// frozen already.
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getShoppingItemList = `-- name: GetShoppingItemList :one
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
WHERE shopping_items.id = ?
`
type GetShoppingItemListRow struct {
ShoppingList ShoppingList
}
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
// such item.
func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error) {
row := q.db.QueryRowContext(ctx, getShoppingItemList, id)
var i GetShoppingItemListRow
err := row.Scan(
&i.ShoppingList.ID,
&i.ShoppingList.WeekStart,
&i.ShoppingList.PlanID,
&i.ShoppingList.Title,
&i.ShoppingList.Status,
&i.ShoppingList.SentToChatAt,
&i.ShoppingList.FrozenAt,
&i.ShoppingList.CreatedAt,
&i.ShoppingList.UpdatedAt,
&i.ShoppingList.ChatSendUnconfirmedAt,
)
return i, err
}
const getShoppingList = `-- name: GetShoppingList :one
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start = ?
`
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
// when there is none.
func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) {
row := q.db.QueryRowContext(ctx, getShoppingList, weekStart)
var i ShoppingList
err := row.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
)
return i, err
}
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
FROM shopping_groups
JOIN dinners ON dinners.id = shopping_groups.dinner_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_groups.id
`
type ListShoppingGroupDinnersRow struct {
ShoppingGroup ShoppingGroup
DinnerName string
}
// The groups of list list_id with their dinner's name, in list order.
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListShoppingGroupDinnersRow
for rows.Next() {
var i ListShoppingGroupDinnersRow
if err := rows.Scan(
&i.ShoppingGroup.ID,
&i.ShoppingGroup.ListID,
&i.ShoppingGroup.DinnerID,
&i.ShoppingGroup.Date,
&i.ShoppingGroup.Position,
&i.ShoppingGroup.CreatedAt,
&i.ShoppingGroup.UpdatedAt,
&i.DinnerName,
); 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 listShoppingGroups = `-- name: ListShoppingGroups :many
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
WHERE list_id = ?
ORDER BY position, id
`
// The groups of list list_id, in list order.
func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) {
rows, err := q.db.QueryContext(ctx, listShoppingGroups, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingGroup
for rows.Next() {
var i ShoppingGroup
if err := rows.Scan(
&i.ID,
&i.ListID,
&i.DinnerID,
&i.Date,
&i.Position,
&i.CreatedAt,
&i.UpdatedAt,
); 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 listShoppingItems = `-- name: ListShoppingItems :many
SELECT shopping_items.id, shopping_items.group_id, shopping_items.name, shopping_items.qty, shopping_items.needed, shopping_items.position, shopping_items.created_at, shopping_items.updated_at, shopping_groups.dinner_id
FROM shopping_items
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
WHERE shopping_groups.list_id = ?
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id
`
type ListShoppingItemsRow struct {
ShoppingItem ShoppingItem
DinnerID string
}
// The items of list list_id with their group's dinner, in list order: by
// group, then by item.
func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) {
rows, err := q.db.QueryContext(ctx, listShoppingItems, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListShoppingItemsRow
for rows.Next() {
var i ListShoppingItemsRow
if err := rows.Scan(
&i.ShoppingItem.ID,
&i.ShoppingItem.GroupID,
&i.ShoppingItem.Name,
&i.ShoppingItem.Qty,
&i.ShoppingItem.Needed,
&i.ShoppingItem.Position,
&i.ShoppingItem.CreatedAt,
&i.ShoppingItem.UpdatedAt,
&i.DinnerID,
); 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 listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
WHERE week_start <= ?
ORDER BY week_start DESC
`
// The Supply runs of the weeks starting on or before week_start, newest
// week first.
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ShoppingList
for rows.Next() {
var i ShoppingList
if err := rows.Scan(
&i.ID,
&i.WeekStart,
&i.PlanID,
&i.Title,
&i.Status,
&i.SentToChatAt,
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
); 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 markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type MarkShoppingListSentToChatParams struct {
SentToChatAt sql.NullString
UpdatedAt string
ID string
}
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
WHERE id = ?
`
type MoveShoppingGroupParams struct {
Position int64
UpdatedAt string
ID string
}
func (q *Queries) MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error {
_, err := q.db.ExecContext(ctx, moveShoppingGroup, arg.Position, arg.UpdatedAt, arg.ID)
return err
}
const moveShoppingItem = `-- name: MoveShoppingItem :exec
UPDATE shopping_items
SET group_id = ?, qty = ?, position = ?, updated_at = ?
WHERE id = ?
`
type MoveShoppingItemParams struct {
GroupID string
Qty sql.NullString
Position int64
UpdatedAt string
ID string
}
// Places item id in group group_id at position with quantity qty; its
// name and needed flag are kept.
func (q *Queries) MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error {
_, err := q.db.ExecContext(ctx, moveShoppingItem,
arg.GroupID,
arg.Qty,
arg.Position,
arg.UpdatedAt,
arg.ID,
)
return err
}
const regenerateShoppingList = `-- name: RegenerateShoppingList :exec
UPDATE shopping_lists
SET plan_id = ?, updated_at = ?
WHERE id = ?
`
type RegenerateShoppingListParams struct {
PlanID string
UpdatedAt string
ID string
}
// Records a generation of list id from plan plan_id.
func (q *Queries) RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error {
_, err := q.db.ExecContext(ctx, regenerateShoppingList, arg.PlanID, arg.UpdatedAt, arg.ID)
return err
}
const setShoppingItemNeeded = `-- name: SetShoppingItemNeeded :exec
UPDATE shopping_items
SET needed = ?, updated_at = ?
WHERE id = ?
`
type SetShoppingItemNeededParams struct {
Needed int64
UpdatedAt string
ID string
}
// Marks item id needed (1) or "have it" (0).
func (q *Queries) SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error {
_, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID)
return err
}
const setShoppingListTitle = `-- name: SetShoppingListTitle :execrows
UPDATE shopping_lists
SET title = ?, updated_at = ?
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL
`
type SetShoppingListTitleParams struct {
Title sql.NullString
UpdatedAt string
ID string
}
// Titles list id, unless it has a title already or is frozen into the
// Supply archive.
func (q *Queries) SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setShoppingListTitle, arg.Title, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-494
View File
@@ -1,494 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: suggestions.sql
package db
import (
"context"
"database/sql"
)
const countDeck = `-- name: CountDeck :one
SELECT COUNT(*)
FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
`
// How many suggestions ListDeck returns.
func (q *Queries) CountDeck(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, countDeck)
var count int64
err := row.Scan(&count)
return count, err
}
const createSuggestion = `-- name: CreateSuggestion :exec
INSERT INTO suggestions (
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, 'pending', ?, ?
)
`
type CreateSuggestionParams struct {
ID string
DinnerID string
BatchID string
Name string
Reason string
CitedRegularIds string
CreatedAt string
UpdatedAt string
}
// name is the name the dish is suggested under, kept whatever its dinner is
// later renamed.
func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error {
_, err := q.db.ExecContext(ctx, createSuggestion,
arg.ID,
arg.DinnerID,
arg.BatchID,
arg.Name,
arg.Reason,
arg.CitedRegularIds,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const createSuggestionBatch = `-- name: CreateSuggestionBatch :one
INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)
RETURNING id, "trigger", status, ai_run_id, created_at, updated_at
`
type CreateSuggestionBatchParams struct {
ID string
Trigger string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, createSuggestionBatch,
arg.ID,
arg.Trigger,
arg.CreatedAt,
arg.UpdatedAt,
)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const decideSuggestion = `-- name: DecideSuggestion :execrows
UPDATE suggestions
SET status = ?1, decided_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'pending'
`
type DecideSuggestionParams struct {
Status string
Now sql.NullString
ID string
}
// Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows).
func (q *Queries) DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideSuggestion, arg.Status, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failOrphanedSuggestionBatches = `-- name: FailOrphanedSuggestionBatches :execrows
UPDATE suggestion_batches
SET status = 'failed', updated_at = ?1
WHERE status IN ('pending', 'running')
AND NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.kind = ?2
AND jobs.status IN ('queued', 'running')
AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id
)
`
type FailOrphanedSuggestionBatchesParams struct {
UpdatedAt string
Kind string
}
// Fails every batch still pending or running whose job is no longer queued
// or running: the process stopped, or recording the batch's end failed,
// before its handler recorded a result. Run before reading the active batch.
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const finishSuggestionBatch = `-- name: FinishSuggestionBatch :execrows
UPDATE suggestion_batches
SET status = ?, updated_at = ?
WHERE id = ? AND status IN ('pending', 'running')
`
type FinishSuggestionBatchParams struct {
Status string
UpdatedAt string
ID string
}
// Records a pending or running batch as done or failed; a batch already
// finished is left as it is (0 rows).
func (q *Queries) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) {
result, err := q.db.ExecContext(ctx, finishSuggestionBatch, arg.Status, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getActiveSuggestionBatch = `-- name: GetActiveSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
WHERE status IN ('pending', 'running')
ORDER BY id
LIMIT 1
`
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getActiveSuggestionBatch)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many
SELECT reason FROM suggestions
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
ORDER BY id
`
// The reason of each suggestion of dinner_id that can have created it,
// none for a dinner no suggestion created. A suggestion is only ever
// created with a new dinner, and declining one archives its dinner, so a
// live dinner's creating suggestion is pending (the dinner still
// suggested) or accepted; a declined one did not create it. More than one
// row means nothing records which did.
func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var reason string
if err := rows.Scan(&reason); err != nil {
return nil, err
}
items = append(items, reason)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
ORDER BY id DESC
LIMIT 1
`
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
// is none.
func (q *Queries) GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getLatestSuggestionBatch)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestion = `-- name: GetSuggestion :one
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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 suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.id = ?
`
type GetSuggestionRow struct {
Suggestion Suggestion
Dinner Dinner
}
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) {
row := q.db.QueryRowContext(ctx, getSuggestion, id)
var i GetSuggestionRow
err := row.Scan(
&i.Suggestion.ID,
&i.Suggestion.DinnerID,
&i.Suggestion.BatchID,
&i.Suggestion.Name,
&i.Suggestion.Reason,
&i.Suggestion.CitedRegularIds,
&i.Suggestion.Status,
&i.Suggestion.DecidedAt,
&i.Suggestion.CreatedAt,
&i.Suggestion.UpdatedAt,
&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,
)
return i, err
}
const getSuggestionBatch = `-- name: GetSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
WHERE id = ?
`
// Batch id. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getSuggestionBatch, id)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
SELECT id FROM jobs
WHERE kind = ?1
AND json_extract(payload, '$.batch_id') = ?2
ORDER BY id
LIMIT 1
`
type GetSuggestionBatchJobIDParams struct {
Kind string
BatchID string
}
// The id of the job that runs batch batch_id.
func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) {
row := q.db.QueryRowContext(ctx, getSuggestionBatchJobID, arg.Kind, arg.BatchID)
var id string
err := row.Scan(&id)
return id, err
}
const listDeck = `-- name: ListDeck :many
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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 suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
ORDER BY suggestions.created_at, suggestions.id
`
type ListDeckRow struct {
Suggestion Suggestion
Dinner Dinner
}
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
rows, err := q.db.QueryContext(ctx, listDeck)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListDeckRow
for rows.Next() {
var i ListDeckRow
if err := rows.Scan(
&i.Suggestion.ID,
&i.Suggestion.DinnerID,
&i.Suggestion.BatchID,
&i.Suggestion.Name,
&i.Suggestion.Reason,
&i.Suggestion.CitedRegularIds,
&i.Suggestion.Status,
&i.Suggestion.DecidedAt,
&i.Suggestion.CreatedAt,
&i.Suggestion.UpdatedAt,
&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 listPassedNames = `-- name: ListPassedNames :many
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
ORDER BY decided_at, id
`
// Every name the household turned down, oldest decision first: the name
// each declined suggestion was suggested under, which renaming its dinner
// does not change, and the name each dinner a Rotation check dropped
// ("Not for us") had when dropped, which renaming it does not change.
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listPassedNames)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveSuggestedDinner = `-- name: MoveSuggestedDinner :execrows
UPDATE dinners
SET status = ?1, updated_by = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'suggested'
`
type MoveSuggestedDinnerParams struct {
Status string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// 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).
func (q *Queries) MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveSuggestedDinner,
arg.Status,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const startSuggestionBatch = `-- name: StartSuggestionBatch :execrows
UPDATE suggestion_batches
SET status = 'running', updated_at = ?
WHERE id = ? AND status = 'pending'
`
type StartSuggestionBatchParams struct {
UpdatedAt string
ID string
}
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
func (q *Queries) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) {
result, err := q.db.ExecContext(ctx, startSuggestionBatch, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-199
View File
@@ -1,199 +0,0 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindBackfillPhotos is the job that grounds, by name, the dinners left
// without a photo: those whose grounding failed and that have no pasted
// recipe_url (ListPhotoBackfillDinners).
const KindBackfillPhotos = "backfill_dinner_photos"
// Photo backfill outcomes for one dinner.
const (
BackfillGrounded = "grounded"
BackfillFailed = "failed"
// BackfillSkipped: the dinner was grounded by hand, renamed or archived
// while the backfill grounded it, so the backfill left it as it is.
BackfillSkipped = "skipped"
)
// PhotoBackfillEligible reports whether a photo backfill grounds r: the
// condition ListPhotoBackfillDinners lists dinners by, and GroundFailedDinner
// and RefailDinnerGrounding write under, for one dinner. The dinner is
// unarchived, failed, with no photo and no pasted recipe_url.
func PhotoBackfillEligible(r db.Dinner) bool {
return r.Status != StatusArchived &&
r.GroundingStatus == GroundingFailed &&
!r.PhotoID.Valid &&
!r.RecipeUrl.Valid
}
// BackfillJob is the KindBackfillPhotos payload. DinnerID narrows the
// backfill to that dinner; empty backfills every dinner without a photo.
type BackfillJob struct {
DinnerID string `json:"dinner_id,omitempty"`
}
// BackfillOutcome is what a photo backfill did for one dinner. Reason is
// set only for BackfillFailed: the Reason recorded as its grounding_error.
type BackfillOutcome struct {
DinnerID string `json:"dinner_id"`
Outcome string `json:"outcome"`
Reason *string `json:"reason"`
}
// backfillResult is the KindBackfillPhotos result: the outcome of each
// dinner the backfill tried, in the order it tried them.
type backfillResult struct {
Dinners []BackfillOutcome `json:"dinners"`
}
func (r backfillResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// StartPhotoBackfill enqueues a KindBackfillPhotos job over every dinner
// without a photo and returns its id, without waiting for it. While a full
// backfill is queued or running it enqueues nothing and returns that job's
// id instead, so two passes never ground the same dinner at once; the jobs
// table holds the claim, so it holds across restarts too.
func (s *Service) StartPhotoBackfill(ctx context.Context) (string, error) {
var id string
created := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
active, err := db.New(tx).GetActivePhotoBackfillJob(ctx, KindBackfillPhotos)
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("dinners: read active photo backfill: %w", err)
default:
id = active
return nil
}
id, err = s.backfillJob.EnqueueTx(ctx, tx, BackfillJob{})
if err != nil {
return fmt.Errorf("dinners: enqueue photo backfill: %w", err)
}
created = true
return nil
})
if err != nil {
return "", err
}
if created {
s.backfillJob.Wake()
}
return id, nil
}
// errBackfillNotRecorded fails a photo backfill that could not record the
// outcome of one or more of its dinners.
var errBackfillNotRecorded = errors.New("dinners: photo backfill: dinner outcomes not recorded")
// runBackfillJob grounds each dinner ListPhotoBackfillDinners lists for p,
// one at a time, with recipes.Grounder.Ground on its name: the first ranked
// candidate whose photo caches and whose ingredients normalize grounds it,
// with the recipe's source URL and site kept as any grounding keeps them.
// Success records the recipe and photo with grounding_status grounded;
// failure leaves the dinner failed with the new Reason as its
// grounding_error, for the household to fix its recipe by hand. One
// dinner's failure, its search's, model's or database write's, never stops
// the others. A failure to list the dinners, or the job's context ending,
// fails the job at once. A write that failed fails the job once every
// dinner has been tried, as a grounding job's failed write does, with the
// outcomes published as its result, so the job never reports done for a
// dinner whose outcome is not stored.
func (s *Service) runBackfillJob(ctx context.Context, p BackfillJob, publish func(backfillResult) error) (backfillResult, error) {
q := db.New(s.st.Reader)
rows, err := q.ListPhotoBackfillDinners(ctx, sql.NullString{String: p.DinnerID, Valid: p.DinnerID != ""})
if err != nil {
return backfillResult{}, fmt.Errorf("dinners: photo backfill: list: %w", err)
}
res := backfillResult{Dinners: make([]BackfillOutcome, 0, len(rows))}
grounded, unrecorded := false, false
for _, row := range rows {
out, recorded, err := s.backfillDinner(ctx, row)
if err != nil {
return backfillResult{}, err
}
grounded = grounded || out.Outcome == BackfillGrounded
unrecorded = unrecorded || !recorded
res.Dinners = append(res.Dinners, out)
}
if grounded {
s.wake()
}
if unrecorded {
if err := publish(res); err != nil {
return backfillResult{}, errors.Join(errBackfillNotRecorded, err)
}
return backfillResult{}, errBackfillNotRecorded
}
return res, nil
}
// backfillDinner grounds row, a dinner ListPhotoBackfillDinners listed, as
// runBackfillJob describes, and reports whether its outcome was recorded.
// Its error is only the job's context ending.
func (s *Service) backfillDinner(ctx context.Context, row db.Dinner) (BackfillOutcome, bool, error) {
out := BackfillOutcome{DinnerID: row.ID}
w := db.New(s.st.Writer)
gr, err := s.grounder.Ground(ctx, row.Name, row.Name)
if ctxErr := ctx.Err(); ctxErr != nil {
return BackfillOutcome{}, false, ctxErr
}
if err != nil {
reason := reasonFor(err, false)
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner photo backfill failed", "dinner_id", row.ID, "reason", reason)
n, err := w.RefailDinnerGrounding(ctx, db.RefailDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillFailed, reason, n, err)
return out, recorded, nil
}
n, err := w.GroundFailedDinner(ctx, db.GroundFailedDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillGrounded, "", n, err)
return out, recorded, nil
}
// backfillRecorded is out once the backfill recorded outcome (with reason,
// for BackfillFailed) on the dinner, writing n rows with err: a dinner that
// changed meanwhile (no row) is BackfillSkipped, and one whose write failed
// is BackfillFailed with ReasonGroundingError, the dinner left as it was,
// and not recorded.
func (s *Service) backfillRecorded(ctx context.Context, out BackfillOutcome, outcome, reason string, n int64, err error) (BackfillOutcome, bool) {
switch {
case err != nil:
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner photo backfill not recorded", "dinner_id", out.DinnerID, "error", err)
out.Outcome, reason = BackfillFailed, ReasonGroundingError
case n == 0:
out.Outcome, reason = BackfillSkipped, ""
default:
out.Outcome = outcome
}
if reason != "" {
out.Reason = &reason
}
return out, err == nil
}
-425
View File
@@ -1,425 +0,0 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"slices"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"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/jobs"
"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/store"
)
// failedDinner stores a Regular named name whose grounding failed with
// no_recipe_found, without a photo or recipe_url, and returns its id.
func (e *env) failedDinner(t *testing.T, name string) string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: dinners.StatusRegular,
Origin: "regular",
GroundingStatus: dinners.GroundingPending,
UpdatedBy: sql.NullString{String: member, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
UpdatedAt: now,
ID: row.ID,
GroundingRevision: row.GroundingRevision,
}); err != nil {
t.Fatal(err)
}
return row.ID
}
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
}
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
t.Helper()
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
if string(gj) != string(wj) {
t.Fatalf("outcomes = %s, want %s", gj, wj)
}
}
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
// curryA ranks first but its photo is gone; curryB's photo caches.
delete(e.images.JPEGs, "https://a.example/curry.jpg")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
id := e.failedDinner(t, "Chicken curry")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
// One reply: the second dinner must reuse the recipe the first stored.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
first := e.failedDinner(t, "Chicken curry")
second := e.failedDinner(t, "Curry chicken")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(first, dinners.BackfillGrounded, nil),
outcome(second, dinners.BackfillGrounded, nil),
})
a, b := e.dinner(t, first), e.dinner(t, second)
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("normalize_recipe ran %d times, want 1", n)
}
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
}
}
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
// Listed by normalized name: both failures come before the curry.
broken := e.failedDinner(t, "Broken search")
curry := e.failedDinner(t, "Chicken curry")
absent := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
outcome(curry, dinners.BackfillGrounded, nil),
})
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
}
}
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
t.Errorf("curry = %+v, want grounded with a photo", row)
}
}
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *c.GroundingJobID)
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded", row)
}
// A failed dinner with a pasted link keeps that link for a fix by hand.
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Fatalf("linked dinner = %+v, want failed", row)
}
archived := e.failedDinner(t, "Old stew")
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
t.Fatal(err)
}
searched := len(e.search.Queries())
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
}
if got := e.search.Queries()[searched:]; len(got) != 0 {
t.Errorf("backfill searched %q, want nothing", got)
}
}
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
// backfill grounds and for each kind it never does.
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *grounded.GroundingJobID)
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
failed := e.failedDinner(t, "Abalone surprise")
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
if err != nil {
t.Fatal(err)
}
shown := map[string]dinners.Dinner{archived.ID: archived}
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
for _, d := range regulars {
shown[d.ID] = d
}
q := db.New(e.st.Reader)
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
if err != nil {
t.Fatal(err)
}
d, ok := shown[id]
if !ok {
t.Fatalf("dinner %s not shown", id)
}
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
}
}
}
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
curry := e.failedDinner(t, "Chicken curry")
other := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("other dinner = %+v, want left failed", row)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want only the dinner's name", got)
}
}
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
id := e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
done := make(chan []dinners.BackfillOutcome)
go func() {
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
if err != nil {
t.Error(err)
}
done <- got
}()
// The backfill listed the dinner and waits at its search.
for len(e.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
t.Fatal(err)
}
close(gate)
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("dinner = %+v, want left failed without a photo", row)
}
}
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
// together and returns the job ids they got.
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
t.Helper()
ids := make([]string, n)
errs := make([]error, n)
release := make(chan struct{})
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-release
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
}()
}
close(release)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatal(err)
}
return ids
}
// backfillJobs counts the KindBackfillPhotos jobs in st.
func backfillJobs(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
t.Parallel()
const triggers = 8
dataDir := t.TempDir()
st := openStore(t, dataDir)
e := newUnstartedEnvOn(t, st, dataDir)
e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
// Queued: simultaneous triggers all get the one pass they enqueued.
ids := triggerAtOnce(t, e.svc, triggers)
first := ids[0]
for _, id := range ids {
if id != first {
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
}
// After a restart the queued pass is still the one: the claim is the
// stored job, not the process's memory.
if err := st.Close(); err != nil {
t.Fatal(err)
}
st = openStore(t, dataDir)
restarted := newUnstartedEnvOn(t, st, dataDir)
restarted.search.Gate = gate
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
}
// Running: the pass waits at its search, and simultaneous triggers all
// return it.
restarted.start(t)
for len(restarted.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
if id != first {
t.Fatalf("trigger while running = %q, want the running %q", id, first)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs, want 1", n)
}
// Finished: the next trigger starts a new pass.
close(gate)
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
}
next, err := restarted.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
if next == first {
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
}
restarted.waitJob(t, next)
}
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
absent := e.failedDinner(t, "Abalone surprise")
curry := e.failedDinner(t, "Chicken curry")
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
t.Fatal(err)
}
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
}
var got struct {
Dinners []dinners.BackfillOutcome `json:"dinners"`
}
if err := json.Unmarshal(job.Result, &got); err != nil {
t.Fatalf("result %s: %v", job.Result, err)
}
// The other dinner was still tried and recorded.
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
})
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("curry = %+v, want left failed without a photo", row)
}
}
-293
View File
@@ -1,293 +0,0 @@
package dinners
import (
"context"
"database/sql"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
)
// Detail is a dinner with its recipe, as the recipe detail screen and
// Kitchen reading mode show it.
type Detail struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
// PhotoURLs is the cached photo at each stored width, keyed by width
// ("400", "1200"); null when the dinner has no photo.
PhotoURLs map[string]string `json:"photo_urls"`
// Ingredients and Steps are the recipe's, in recipe order; empty when
// the dinner has no recipe.
Ingredients []Ingredient `json:"ingredients"`
Steps []string `json:"steps"`
// Source is the site of the recipe page the dinner is grounded in;
// null for a recipe entered by hand or a dinner with no recipe.
Source *Source `json:"source"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
// WhyPicked is the reason of the suggestion that created the dinner
// (see GetDinnerSuggestionReasons); null for a dinner no suggestion
// created.
WhyPicked *string `json:"why_picked"`
}
// Ingredient is one recipe line. RawText is the line as the recipe gives
// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each
// null when it has none, and Name, Qty and Unit always null for a recipe
// entered by hand (see IsManualRecipe).
type Ingredient struct {
Name *string `json:"name"`
Qty *string `json:"qty"`
Unit *string `json:"unit"`
Note *string `json:"note"`
RawText string `json:"raw_text"`
}
// Source is the site a recipe was fetched from. SiteName is the name the
// page itself gives its site, and null when it gives none or the stored
// name could have come from a link (see pageSiteName). URL is the page's
// link only when it is verifiably free of secrets (see safeSourceURL), and
// null otherwise: a link pasted or offered as a candidate may carry a
// personal token, so a client links the credit only when URL is not null.
// Source is set for every fetched recipe, so a client always credits it:
// by SiteName, else by URL's host, else without naming the site.
type Source struct {
SiteName *string `json:"site_name"`
URL *string `json:"url"`
}
// Get returns dinner id with its recipe and the reason it was suggested,
// read in one read transaction. An unknown or archived dinner is
// ErrNotFound: an archived dinner has no detail to show.
func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Detail{}, fmt.Errorf("dinners: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
row, err := q.GetDinner(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
if row.Status == StatusArchived {
return Detail{}, ErrNotFound
}
d = Detail{
ID: row.ID,
Name: row.Name,
Status: row.Status,
Origin: row.Origin,
Method: nullString(row.Method),
Difficulty: nullString(row.Difficulty),
Cuisine: nullString(row.Cuisine),
IsFavorite: row.IsFavorite == 1,
Ingredients: []Ingredient{},
Steps: []string{},
GroundingStatus: row.GroundingStatus,
GroundingError: nullString(row.GroundingError),
}
if row.MinutesTotal.Valid {
d.MinutesTotal = &row.MinutesTotal.Int64
}
if row.PhotoID.Valid {
d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes))
for _, size := range recipes.PhotoSizes {
d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size)
}
}
if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid {
return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus)
}
if row.RecipeID.Valid {
if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil {
return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err)
}
}
reasons, err := q.GetDinnerSuggestionReasons(ctx, id)
if err != nil {
return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err)
}
switch len(reasons) {
case 0:
case 1:
d.WhyPicked = &reasons[0]
default:
// Nothing records which of them created the dinner, and any
// one picked would be a guess.
return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons))
}
return d, nil
}
// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID.
func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error {
rec, err := q.GetRecipe(ctx, recipeID)
if err != nil {
return fmt.Errorf("recipe %s: %w", recipeID, err)
}
if !IsManualRecipe(rec.SourceUrl) {
src, err := SourceOf(rec.SiteName, rec.SourceUrl)
if err != nil {
return err
}
d.Source = &src
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
return fmt.Errorf("ingredients: %w", err)
}
for i, ing := range ings {
if i > 0 && ing.Position == ings[i-1].Position {
return duplicatePosition("ingredient", ing.Position)
}
d.Ingredients = append(d.Ingredients, Ingredient{
Name: nullString(ing.Name),
Qty: nullString(ing.Qty),
Unit: nullString(ing.Unit),
Note: nullString(ing.Note),
RawText: ing.RawText,
})
}
steps, err := q.ListRecipeSteps(ctx, recipeID)
if err != nil {
return fmt.Errorf("steps: %w", err)
}
for i, step := range steps {
if i > 0 && step.Position == steps[i-1].Position {
return duplicatePosition("step", step.Position)
}
d.Steps = append(d.Steps, step.Text)
}
return nil
}
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
// site name is siteName: the name only when the page gave it (see
// pageSiteName) and the link only when it is free of secrets (see
// safeSourceURL). It is the one way a fetched recipe's source reaches a
// client. A sourceURL that does not parse is an error, whether or not a
// site name is stored.
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return Source{}, errors.New("source url does not parse")
}
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
}
// pageSiteName is the stored site name of a recipe fetched from link
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
// subdomain), or that of a page the link redirected to, which is not
// stored. Nothing records which a stored name is, so any name that could
// be a host — the link's own host, an IP address, or a dotted host name —
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its name with them: a credit that does not
// name the site is better than one that leaks a link.
func pageSiteName(stored sql.NullString, link *url.URL) *string {
if !stored.Valid {
return nil
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil
}
return &name
}
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
// https link to a host, with no user or password, no query (not even a
// bare "?") and no fragment (not even a bare "#") — else nil. Those are
// the places a link carries a token; a link failing any check, or not
// parsing, is withheld rather than trimmed, since a trimmed link is not the
// page the recipe came from. The client (sourceUrl in
// web/src/dinners/detail.ts) parses the link with the browser's URL parser
// and refuses the whole recipe when it fails, so the host and port must
// also be ones it parses: see browserHost.
func safeSourceURL(sourceURL string) *string {
u, err := url.Parse(sourceURL)
if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil ||
u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") {
return nil
}
// url.Parse decodes a percent-encoded host; the link must spell its
// host out as it is checked.
authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/")
if authority != u.Host || !browserHost(u) {
return nil
}
if port := u.Port(); port != "" {
if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 {
return nil
}
}
return &sourceURL
}
// browserHost says whether u's host is one the browser's URL parser (the
// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more,
// such as "[bad]". The rule is narrower than the browser's: a bracketed
// host is an IPv6 address without a zone; any other is ASCII letters,
// digits and hyphens in dot-separated labels, none empty but a trailing
// one, none punycode ("xn--"), since the browser refuses invalid punycode
// and checking it is not worth a decoder; and a host whose last label is
// a number, which the browser reads as an IPv4 address, must be a dotted
// quad. A link to any other host is withheld.
func browserHost(u *url.URL) bool {
host := u.Hostname()
if strings.HasPrefix(u.Host, "[") {
ip := net.ParseIP(host)
return ip != nil && strings.Contains(host, ":")
}
labels := strings.Split(strings.TrimSuffix(host, "."), ".")
for _, label := range labels {
if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") {
return false
}
for _, c := range label {
if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') {
return false
}
}
}
last := labels[len(labels)-1]
if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") {
ip := net.ParseIP(host)
return ip != nil && ip.To4() != nil && !strings.Contains(host, ":")
}
return true
}
// duplicatePosition is the error for two recipe items of kind at position.
// Position is the only record of recipe order, and the id that breaks the
// tie says nothing about it, so a recipe with a duplicate is refused rather
// than shown in an order nobody can trust.
func duplicatePosition(kind string, position int64) error {
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
}
+11 -172
View File
@@ -17,12 +17,10 @@ import (
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"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/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -79,11 +77,6 @@ type Dinner struct {
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
// (PhotoBackfillEligible): false for one with a pasted recipe link,
// which a search never replaces.
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
}
// NewRegular is a Regular to add: its Name, which the household always
@@ -143,11 +136,6 @@ type Service struct {
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// backfillJob enqueues KindBackfillPhotos jobs.
backfillJob *jobs.Kind[BackfillJob]
// wake is called after every committed change to a dinner's method or
// minutes, which can move its crock-pot start reminder.
wake func()
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
@@ -167,11 +155,10 @@ type Service struct {
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder,
// calls wake (the scheduler's Wake) after every committed change to a
// dinner's method or minutes, and registers the KindGroundDinner and
// KindBackfillPhotos jobs on runner, which must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
// NewService returns a service over st that grounds dinners with grounder
// and registers the KindGroundDinner job on runner, which must not have
// started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("dinners: nil store")
@@ -179,26 +166,16 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
return nil, errors.New("dinners: nil grounder")
case runner == nil:
return nil, errors.New("dinners: nil job runner")
case wake == nil:
return nil, errors.New("dinners: nil wake")
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
rand.Read(s.offerKey) // never fails: it crashes the program instead
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
}
s.groundJob = kind
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
if err != nil {
return nil, err
}
if err := backfill.ServiceOnly(); err != nil {
return nil, err
}
s.backfillJob = backfill
return s, nil
}
@@ -309,104 +286,13 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
return row, nil
}
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
// for Cook from what I have, quick for Quick dinners.
var IdeaOrigins = []string{"ingredients", "quick"}
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
// the recipe the idea was already grounded in, and its origin, one of
// IdeaOrigins.
type NewQuestLog struct {
Name string
RecipeID string
Origin string
}
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
// (its method, minutes, difficulty and photo), and returns it with
// created true. When an unarchived dinner already repeats it, by the same
// rule that refuses a duplicate Regular (a harness.Similar name, or the
// recipe's link), that dinner is returned as it is, with created false,
// so adding the same idea again never makes a second dinner: a Regular
// stays a Regular and a Quest log dinner stays in the Quest log. A
// repeated dinner still awaiting the household's choice (a suggested Side
// quest, or one tried and awaiting its Rotation check) is a
// *DuplicateError and changes nothing: adding it would make that choice
// for them. An unknown recipe is an error.
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
if updatedBy == "" {
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
}
if !slices.Contains(IdeaOrigins, n.Origin) {
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
}
name, err := checkName(n.Name)
if err != nil {
return Dinner{}, false, err
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
recipe, err := q.GetRecipe(ctx, n.RecipeID)
if err != nil {
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
}
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
if err != nil {
return err
}
if found {
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
return &DuplicateError{Existing: fromRow(existing)}
}
row = existing
return nil
}
now := store.Now().String()
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: StatusQuestLog,
Origin: n.Origin,
Method: recipe.Method,
MinutesTotal: recipe.MinutesTotal,
Difficulty: recipe.Difficulty,
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
PhotoID: recipe.PhotoID,
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: add to Quest log: %w", err)
}
created = true
return nil
})
if err != nil {
return Dinner{}, false, err
}
return fromRow(row), created, nil
}
// List returns the dinners with status, which must be regular or
// quest_log, ordered by normalized name. The Quest log is the dinners
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
// household's current week or a later one is left out of it.
// quest_log, ordered by normalized name.
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
if !slices.Contains(listable, status) {
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
}
var rows []db.Dinner
var err error
if status == StatusQuestLog {
rows, err = s.listQuestLog(ctx)
} else {
rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
}
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
if err != nil {
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
}
@@ -417,29 +303,6 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
return out, nil
}
// listQuestLog reads the Quest log in one read transaction, so the
// household's current week and the plans come from the same snapshot.
func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return nil, fmt.Errorf("read household settings: %w", err)
}
loc, err := household.LoadZone(settings.Timezone)
if err != nil {
return nil, fmt.Errorf("household settings: %w", err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
}
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner raises its
@@ -523,9 +386,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return Dinner{}, err
}
if p.Method != nil || p.MinutesTotal != nil {
s.wake()
}
if renamed {
updateCommitted()
s.clearOffers(row.ID, row.GroundingRevision)
@@ -658,23 +518,9 @@ func checkRecipeURL(raw string) (*url.URL, error) {
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
if err != nil {
return err
}
if found {
return &DuplicateError{Existing: fromRow(row)}
}
return nil
}
// findRepeat returns the first unarchived dinner other than self that
// name or recipeURL repeats, as checkUnique decides it, and whether there
// is one.
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
return fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
@@ -685,20 +531,15 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return row.Dinner, true, nil
return &DuplicateError{Existing: fromRow(row.Dinner)}
}
}
return db.Dinner{}, false, nil
return nil
}
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return photoURL(id, photoListWidth)
}
// photoURL is where the API serves photo id at stored width size.
func photoURL(id string, size int) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
}
func fromRow(r db.Dinner) Dinner {
@@ -717,8 +558,6 @@ func fromRow(r db.Dinner) Dinner {
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PhotoBackfillEligible: PhotoBackfillEligible(r),
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
-104
View File
@@ -1,7 +1,6 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"strings"
@@ -75,106 +74,3 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
}
check("duplicate error", dup.Error())
}
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
// the link's own, in any case and without "www.", an IP address or a dotted
// host name — is withheld; a name the page gave, dotless or with spaces, is
// kept.
func TestPageSiteNameWithholdsHosts(t *testing.T) {
t.Parallel()
const link = "https://www.TokenHost/recipes/stew"
for _, tc := range []struct {
stored string
valid bool
want *string
}{
{"", false, nil},
{"tokenhost", true, nil},
{"TOKENHOST", true, nil},
{"private-token.example.com", true, nil},
{"Food.com", true, nil},
{"203.0.113.7", true, nil},
{"2001:db8::1", true, nil},
{"Delish", true, ptr("Delish")},
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
} {
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
if err != nil {
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
}
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
}
}
// A link that does not parse is an error with or without a site name.
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
}
}
}
// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it
// is https to a host with no user, password, query or fragment, each of
// which can carry a token; any other link, or one that does not parse, is
// withheld whole.
func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) {
t.Parallel()
for _, link := range []string{
"https://www.example.com/recipes/chicken-curry",
"https://example.com",
"https://example.com:8443/stew/",
"https://[2001:db8::1]/curry",
"https://1.2.3.4/curry",
"https://Kitchen-2.Example.com./curry",
} {
if got := dinners.SafeSourceURL(link); got == nil || *got != link {
t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got))
}
}
for _, link := range []string{
"https://example.com/curry?access_token=query-token",
"https://example.com/curry?",
"https://user:password@example.com/curry",
"https://token@example.com/curry",
"https://@example.com/curry",
"http://example.com/curry",
"https://example.com/curry#token=fragment-token",
"https://example.com/curry#",
"ftp://example.com/curry",
"https:example.com/curry",
"https:///curry",
"https://example.com:99999/curry",
"https://example.com:0/curry",
"//example.com/curry",
"https://secret-token\x7f.example/%zz",
"",
// Hosts url.Parse accepts but the browser's URL parser refuses
// (or reads as another host), which would fail the whole recipe
// screen.
"https://[bad]/curry",
"https://[fe80::1%25en0]/curry",
"https://[1.2.3.4]/curry",
"https://1.2.3.999/curry",
"https://1.2.3.4.5/curry",
"https://999/curry",
"https://example.0x/curry",
"https://xn--a/curry",
"https://ex%61mple.com/curry",
"https://ex%2fample.com/curry",
"https://a..b/curry",
"https://café.example/curry",
} {
if got := dinners.SafeSourceURL(link); got != nil {
t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got)
}
}
}
func deref(s *string) any {
if s == nil {
return nil
}
return *s
}
-18
View File
@@ -71,21 +71,3 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
r, err := s.runGroundJob(ctx, p, nil)
return r.GroundingStatus, err
}
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
// RunBackfillJob runs the KindBackfillPhotos handler for p in the caller's
// goroutine, as the job runner would, and returns its outcomes: on an
// error, those it last published.
func (s *Service) RunBackfillJob(ctx context.Context, p BackfillJob) ([]BackfillOutcome, error) {
var published backfillResult
r, err := s.runBackfillJob(ctx, p, func(partial backfillResult) error {
published = partial
return nil
})
if err != nil {
return published.Dinners, err
}
return r.Dinners, nil
}
+3 -23
View File
@@ -68,13 +68,6 @@ const (
manualSourcePrefix = "mealquest:manual:"
)
// IsManualRecipe reports whether sourceURL, a recipes.source_url, is that
// of a recipe entered by hand. Its ingredients are stored as typed, in
// raw_text only: no model normalizes them, so name, qty and unit are NULL.
func IsManualRecipe(sourceURL string) bool {
return strings.HasPrefix(sourceURL, manualSourcePrefix)
}
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
// the request: the page fetch (recipes.Timeout), its photo download and
// normalize_recipe (harness.DefaultTimeout), with some slack.
@@ -92,11 +85,6 @@ var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrCandidatesFailed wraps any unexpected failure finding a dinner's
// candidates. Its text may quote a candidate's URL and dependency errors,
// so it is never logged.
var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
@@ -196,7 +184,6 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
if n == 0 {
return s.currentGrounding(ctx, row.ID)
}
s.wake()
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
@@ -345,24 +332,19 @@ type offerSearch struct {
// replace the dinner's earlier candidates: one not offered again no longer
// grounds it. They ground it for offerLifetime, and not at all once it is
// grounded by hand, renamed or archived, even while this search runs. A
// missing dinner is ErrNotFound; any other failure wraps
// ErrCandidatesFailed, and a search that fails also wraps
// recipes.ErrSearchFailed.
// search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
// Tracked before the dinner is read: grounding, renaming or archiving
// it after the read keeps this search from storing what it found.
keep, done := s.trackOffers(id)
defer done()
row, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
return nil, err
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
return nil, err
}
out := make([]Candidate, len(offers))
urls := make(map[string]string, len(offers))
@@ -578,7 +560,6 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
s.wake()
s.clearOffers(cur.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -702,7 +683,6 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
s.wake()
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
+2 -3
View File
@@ -39,7 +39,6 @@ type env struct {
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
}
@@ -105,11 +104,11 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
svc, err := dinners.NewService(st, g, runner, logger)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, images: images, model: model}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
-313
View File
@@ -1,313 +0,0 @@
package gcal
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// calendarPageSize is the calendarList page size: Google's maximum.
calendarPageSize = 250
// maxCalendarPages caps how many calendarList pages a listing reads.
maxCalendarPages = 20
// maxCalendarBody caps how much of one calendarList page is read.
maxCalendarBody = 1 << 20
)
var (
// ErrReconnect means Google no longer accepts the stored grant: the
// household revoked MealQuest's access in their Google account, or the
// refresh token expired. Only connecting again restores it.
ErrReconnect = errors.New("gcal: Google no longer accepts MealQuest's access; connect Google Calendar again")
// ErrUnknownCalendar means the calendar chosen is not one the connected
// account can push to.
ErrUnknownCalendar = errors.New("gcal: not a calendar MealQuest can add dinners to")
)
// Calendar is one calendar the connected account can push to.
type Calendar struct {
ID string `json:"id"`
Summary string `json:"summary"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
}
// Selection is the calendar MealQuest pushes to.
type Selection struct {
CalendarID string `json:"calendar_id"`
CalendarName string `json:"calendar_name"`
}
// CalendarError is a Calendar API or token refresh request Google refused
// or that failed in transit. Its text never holds a token or Google's
// response body.
type CalendarError struct {
msg string
// retryable is set when Google answered every try with a transient
// failure (HTTP 429 or 5xx).
retryable bool
}
func (e *CalendarError) Error() string { return e.msg }
// Retryable reports whether Google was only briefly unable to answer, so
// trying again later may succeed. Nothing was disconnected.
func (e *CalendarError) Retryable() bool { return e.retryable }
// Calendars lists the connected account's calendars it can push to: those
// it is a writer or owner of, in Google's order. A grant Google refuses is
// ErrReconnect, recorded as reconnect required (recordRefusal).
func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
s.account.Lock()
defer s.account.Unlock()
cals, err := s.calendars(ctx)
if err != nil {
return nil, s.recordRefusal(ctx, err)
}
return cals, nil
}
// SelectCalendar makes id the calendar MealQuest pushes to, storing its id
// and name. id must be among Calendars at the time of the call; anything
// else, including a calendar chosen earlier that has since been deleted or
// is no longer writable, is ErrUnknownCalendar and leaves the stored
// choice as it was. Choosing another calendar than the one chosen returns
// every plan pushed to that one to not synced; see saveSelection. A grant
// Google refuses is ErrReconnect, recorded as reconnect required
// (recordRefusal).
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
s.account.Lock()
defer s.account.Unlock()
cals, err := s.calendars(ctx)
if err != nil {
return Selection{}, s.recordRefusal(ctx, err)
}
for _, c := range cals {
if c.ID != id {
continue
}
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
if err := s.saveSelection(ctx, sel); err != nil {
return Selection{}, err
}
return sel, nil
}
return Selection{}, ErrUnknownCalendar
}
// saveSelection stores sel as the chosen calendar. Choosing another calendar
// than the one stored also forgets every plan day's calendar event id, in
// the same transaction, since those events are on the calendar no longer
// chosen (forgetEvents): each plan with one is edited, so a synced plan
// becomes changed and its next push writes every day to sel. The caller
// holds s.account, which a push also holds from its event writes through
// recording them, so a push never records events written to another
// calendar than the chosen one.
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save calendar: %w", err)
}
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
return nil
}
return forgetEvents(ctx, q, now)
})
}
// forgetEvents records, in the caller's transaction q, that no calendar
// event id stored is on the chosen calendar any more: every plan with one is
// edited (a synced plan becomes changed, and a push that read it before is
// refused), then every id is cleared. Every change that ends the chosen
// calendar — choosing another (saveSelection) or disconnecting
// (Disconnect) — runs it in the transaction that makes the change.
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
}
if err := q.ClearCalendarEvents(ctx, now); err != nil {
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
}
return nil
}
// calendars lists the writable calendars, each once, with the stored refresh
// token. The caller holds s.account.
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
row, err := s.grantedConnection(ctx)
if err != nil {
return nil, err
}
access, err := s.accessToken(ctx, row)
if err != nil {
return nil, err
}
cals := []Calendar{}
// listed holds the ids in cals: pages can overlap, and a calendar is
// listed once, as Google first lists it.
listed := map[string]bool{}
pageToken := ""
for range maxCalendarPages {
page, err := s.calendarPage(ctx, access, pageToken)
if err != nil {
return nil, err
}
for _, item := range page.Items {
if item.ID == "" {
return nil, &CalendarError{msg: "gcal: Google listed a calendar without an id"}
}
if (item.AccessRole == "writer" || item.AccessRole == "owner") && !listed[item.ID] {
listed[item.ID] = true
cals = append(cals, Calendar{ID: item.ID, Summary: item.Summary, Primary: item.Primary, AccessRole: item.AccessRole})
}
}
if page.NextPageToken == "" {
return cals, nil
}
pageToken = page.NextPageToken
}
return nil, &CalendarError{msg: fmt.Sprintf("gcal: Google listed more than %d pages of calendars", maxCalendarPages)}
}
// connection reads the connected account, or ErrNotConnected.
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return db.IntegrationsGoogle{}, ErrNotConnected
}
if err != nil {
return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err)
}
return row, nil
}
// grantedConnection reads the connected account for a Calendar call that
// uses the stored grant: listing, choosing and pushing. It decides the
// precedence of their preconditions: nothing connected is ErrNotConnected,
// then a grant already recorded as refused (no refresh token stored) is
// ErrReconnect, before anything else a call checks, such as whether a
// calendar is chosen. Only connecting again restores a refused grant, so
// it is reported whatever the calendar choice, and the refused token is
// never offered to Google.
func (s *Service) grantedConnection(ctx context.Context) (db.IntegrationsGoogle, error) {
row, err := s.connection(ctx)
if err != nil {
return db.IntegrationsGoogle{}, err
}
if row.RefreshTokenCt == nil {
return db.IntegrationsGoogle{}, ErrReconnect
}
return row, nil
}
// accessToken trades row's refresh token, from grantedConnection, for an
// access token, retrying a transient failure as send does. A grant Google
// reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return "", fmt.Errorf("gcal: open refresh token: %w", err)
}
for attempt := 0; ; attempt++ {
if attempt > 0 {
if err := s.backoff(ctx, attempt); err != nil {
return "", err
}
}
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
&oauth2.Token{RefreshToken: string(refresh)}).Token()
if err == nil {
return token.AccessToken, nil
}
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
case errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant":
return "", ErrReconnect
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode) && attempt < maxRetries:
continue
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode):
return "", &CalendarError{retryable: true,
msg: fmt.Sprintf("gcal: Google could not take the token refresh (HTTP %d) in %d tries", retrieve.Response.StatusCode, maxRetries+1)}
case errors.As(err, &retrieve):
return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)}
case errors.As(err, &transport):
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return "", &CalendarError{msg: fmt.Sprintf("gcal: token refresh failed: %s: %v", transport.Op, transport.Err)}
default:
return "", &CalendarError{msg: "gcal: token refresh returned an unusable response"}
}
}
}
// calendarListPage is the part of a calendarList page MealQuest reads.
type calendarListPage struct {
Items []struct {
ID string `json:"id"`
Summary string `json:"summary"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
} `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
// calendarPage fetches the calendarList page at pageToken ("" for the
// first) with access. HTTP 401 means Google no longer accepts the grant:
// the access token was just refreshed from it.
func (s *Service) calendarPage(ctx context.Context, access, pageToken string) (calendarListPage, error) {
q := url.Values{"maxResults": {fmt.Sprint(calendarPageSize)}}
if pageToken != "" {
q.Set("pageToken", pageToken)
}
status, body, err := s.send(ctx, "calendar list", maxCalendarBody+1, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+access)
return req, nil
})
if err != nil {
return calendarListPage{}, err
}
switch {
case status == http.StatusUnauthorized:
return calendarListPage{}, ErrReconnect
case status != http.StatusOK:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", status)}
case len(body) > maxCalendarBody:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)}
}
var page calendarListPage
if err := json.Unmarshal(body, &page); err != nil {
return calendarListPage{}, &CalendarError{msg: "gcal: Google returned an unreadable calendar list"}
}
return page, nil
}
-302
View File
@@ -1,302 +0,0 @@
package gcal_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
)
// Calendars the fake lists: two the household can push to, two it cannot,
// and a third writable one with Google fields MealQuest must not pass on.
var (
ownPrimary = gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"}
family = gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer",
Description: "private notes", SummaryOverride: "Fam"}
holidays = gcaltest.Calendar{ID: "en.usa#holiday@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"}
busy = gcaltest.Calendar{ID: "coworker@example.com", Summary: "Coworker", AccessRole: "freeBusyReader"}
dinners = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
)
// connected is a harness with the household's Google account connected
// and the fake listing every calendar above, two to a page.
func connected(t *testing.T) *harness {
t.Helper()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
h.google.SetCalendars(2, ownPrimary, holidays, family, busy, dinners)
return h
}
func TestCalendarsListsWritableCalendars(t *testing.T) {
t.Parallel()
h := connected(t)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
got, err := json.Marshal(cals)
if err != nil {
t.Fatal(err)
}
// Exactly the writer and owner calendars, in Google's order, with only
// id, summary, primary and accessRole.
want := `[` +
`{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` +
`{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]`
if string(got) != want {
t.Errorf("Calendars =\n%s\nwant\n%s", got, want)
}
if n := h.google.CalendarCalls(); n != 3 {
t.Errorf("calendarList called %d times, want 3 (every page)", n)
}
if _, ok := h.row(t); !ok {
t.Error("listing changed the connection")
}
}
func TestCalendarsOverlappingPages(t *testing.T) {
t.Parallel()
h := connected(t)
// Two to a page, family ends the first page and starts the second
// again, renamed in between.
renamed := family
renamed.Summary = "Family (renamed)"
h.google.SetCalendars(2, ownPrimary, family, renamed, dinners)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
got, err := json.Marshal(cals)
if err != nil {
t.Fatal(err)
}
// family once, as first listed, in Google's order.
want := `[` +
`{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` +
`{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]`
if string(got) != want {
t.Errorf("Calendars =\n%s\nwant\n%s", got, want)
}
if n := h.google.CalendarCalls(); n != 2 {
t.Errorf("calendarList called %d times, want 2 (every page)", n)
}
}
func TestCalendarsEmpty(t *testing.T) {
t.Parallel()
h := connected(t)
h.google.SetCalendars(0, holidays)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
if got, _ := json.Marshal(cals); string(got) != "[]" {
t.Errorf("Calendars = %s, want []", got)
}
}
func TestCalendarsNotConnected(t *testing.T) {
t.Parallel()
h := newHarness(t)
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("Calendars = %v, want ErrNotConnected", err)
}
if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("SelectCalendar = %v, want ErrNotConnected", err)
}
if h.google.TokenCalls() != 0 || h.google.CalendarCalls() != 0 {
t.Error("Google was called with nothing connected")
}
}
func TestSelectCalendarStoresIDAndName(t *testing.T) {
t.Parallel()
h := connected(t)
before, _ := h.row(t)
h.clock = h.clock.Add(time.Hour)
sel, err := h.svc.SelectCalendar(context.Background(), family.ID)
if err != nil {
t.Fatalf("SelectCalendar: %v", err)
}
if sel != (gcal.Selection{CalendarID: family.ID, CalendarName: "Family"}) {
t.Errorf("selection = %+v", sel)
}
row, _ := h.row(t)
if row.CalendarID.String != family.ID || !row.CalendarID.Valid ||
row.CalendarName.String != "Family" || !row.CalendarName.Valid {
t.Errorf("stored calendar = %v %v, want %s Family", row.CalendarID, row.CalendarName, family.ID)
}
if row.UpdatedAt != "2026-10-07T19:30:00.000Z" {
t.Errorf("updated_at = %q, want the choice's time", row.UpdatedAt)
}
if !bytes.Equal(row.RefreshTokenCt, before.RefreshTokenCt) || row.ConnectedAt != before.ConnectedAt || row.CreatedAt != before.CreatedAt {
t.Error("choosing a calendar changed the connection")
}
// Choosing again replaces the choice.
if _, err := h.svc.SelectCalendar(context.Background(), ownPrimary.ID); err != nil {
t.Fatalf("second SelectCalendar: %v", err)
}
if row, _ := h.row(t); row.CalendarID.String != ownPrimary.ID || row.CalendarName.String != ownPrimary.Summary {
t.Errorf("stored calendar = %v %v, want the primary", row.CalendarID, row.CalendarName)
}
}
func TestSelectCalendarRejectsUnlistedCalendar(t *testing.T) {
t.Parallel()
for name, id := range map[string]string{
"unknown": "nobody@group.calendar.google.com",
"reader": holidays.ID,
"free/busy reader": busy.ID,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
before, _ := h.row(t)
if _, err := h.svc.SelectCalendar(context.Background(), id); !errors.Is(err, gcal.ErrUnknownCalendar) {
t.Fatalf("SelectCalendar(%s) = %v, want ErrUnknownCalendar", id, err)
}
if row, _ := h.row(t); !reflect.DeepEqual(row, before) {
t.Errorf("a refused choice changed the row: %+v", row)
}
})
}
}
// TestSelectCalendarNoLongerWritable chooses a calendar, then has Google
// stop listing it as writable, as when it is deleted or the household's
// access drops to reader. It leaves the listing, and choosing it again is
// refused; the stored choice stays until the household chooses another.
func TestSelectCalendarNoLongerWritable(t *testing.T) {
t.Parallel()
for name, now := range map[string][]gcaltest.Calendar{
"deleted": {ownPrimary},
"demoted to read": {ownPrimary, {ID: family.ID, Summary: family.Summary, AccessRole: "reader"}},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), family.ID); err != nil {
t.Fatal(err)
}
h.google.SetCalendars(0, now...)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatal(err)
}
if len(cals) != 1 || cals[0].ID != ownPrimary.ID {
t.Errorf("Calendars = %+v, want only the primary", cals)
}
if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrUnknownCalendar) {
t.Errorf("choosing it again = %v, want ErrUnknownCalendar", err)
}
if row, _ := h.row(t); row.CalendarID.String != family.ID {
t.Errorf("stored calendar = %v, want the earlier choice kept", row.CalendarID)
}
})
}
}
// TestCalendarsGrantNoLongerAccepted lists calendars after Google stops
// accepting MealQuest's grant. The failure is ErrReconnect; the connection
// is left as it is.
func TestCalendarsGrantNoLongerAccepted(t *testing.T) {
t.Parallel()
for name, edit := range map[string]func(*gcaltest.Google){
"refresh refused": (*gcaltest.Google).RevokeGrant,
"calendar list unauthorized": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusUnauthorized) },
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
edit(h.google)
_, err := h.svc.Calendars(context.Background())
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
_, err = h.svc.SelectCalendar(context.Background(), family.ID)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("SelectCalendar = %v, want ErrReconnect", err)
}
row, ok := h.row(t)
if !ok || row.CalendarID.Valid {
t.Errorf("row = %+v, %v: want the connection kept with no calendar", row, ok)
}
})
}
}
func TestCalendarsGoogleFailureIsRedacted(t *testing.T) {
t.Parallel()
for name, edit := range map[string]func(*gcaltest.Google){
"token endpoint": func(g *gcaltest.Google) { g.FailToken(http.StatusServiceUnavailable) },
"calendar list": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusForbidden) },
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
edit(h.google)
_, err := h.svc.Calendars(context.Background())
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("Calendars = %v, want *CalendarError", err)
}
assertNoSecret(t, "error", err.Error())
})
}
}
// TestSelectCalendarHoldsTheAccount holds a calendar choice at Google while
// a disconnect starts: the disconnect waits for the choice, so it never
// revokes the token the choice is using.
func TestSelectCalendarHoldsTheAccount(t *testing.T) {
t.Parallel()
h := connected(t)
h.google.SetCalendars(0, family)
arrived, release := h.google.HoldCalendarList()
defer release()
selected := make(chan error, 1)
go func() {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
selected <- err
}()
<-arrived
disconnected := make(chan error, 1)
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a calendar choice was at Google", err)
case <-time.After(200 * time.Millisecond):
}
if n := len(h.google.Revoked()); n != 0 {
t.Fatalf("revoked %d tokens while a calendar choice was at Google", n)
}
release()
if err := <-selected; err != nil {
t.Errorf("SelectCalendar: %v", err)
}
if err := <-disconnected; err != nil {
t.Errorf("Disconnect: %v", err)
}
if _, ok := h.row(t); ok {
t.Error("connection stored after the disconnect")
}
}
-426
View File
@@ -1,426 +0,0 @@
// Package gcal connects the household's Google account for Push to Calendar.
// The service is a confidential OAuth web client: the authorization code,
// tokens and client secret stay on the server. The refresh token is stored
// sealed by secretbox in integrations_google and is never logged or
// returned.
package gcal
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// CallbackPath is where Google returns the browser after consent. It is
// served under the signed-in /api/v1 router, so the redirect URI registered
// with Google is MEALQUEST_PUBLIC_URL's origin followed by this path.
const CallbackPath = "/api/v1/integrations/google/callback"
// Scopes requested at connect: create and change the events MealQuest pushes,
// and list the calendars the household can push to.
var Scopes = []string{
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
}
// GoogleRevokeURL is Google's OAuth token revocation endpoint.
const GoogleRevokeURL = "https://oauth2.googleapis.com/revoke"
// GoogleCalendarAPI is the base URL of Google's Calendar API v3.
const GoogleCalendarAPI = "https://www.googleapis.com/calendar/v3"
// GoogleEndpoint is Google's OAuth authorization and token endpoint.
var GoogleEndpoint = oauth2.Endpoint{
AuthURL: endpoints.Google.AuthURL,
TokenURL: endpoints.Google.TokenURL,
AuthStyle: oauth2.AuthStyleInParams,
}
const (
connectCookieName = "__Host-mealquest_google"
connectCookieTTL = 10 * time.Minute
// connectCookieKeyLabel separates the connect cookie's signing key from
// the login cookie's and every other key derived from the server secret.
connectCookieKeyLabel = "mealquest google connect cookie v1"
// googleTimeout bounds every request to Google.
googleTimeout = 10 * time.Second
// maxRevokeBody caps how much of a revoke response is read.
maxRevokeBody = 4 << 10
)
var (
// ErrBadState means the callback's state is missing, does not match the
// connect cookie, or the cookie is missing, expired or belongs to another
// session.
ErrBadState = errors.New("gcal: connect state is missing, expired or does not match this session")
// ErrDenied means Google returned an authorization error, usually because
// the household declined consent.
ErrDenied = errors.New("gcal: Google did not grant access")
// ErrNoCode means the callback carries no authorization code.
ErrNoCode = errors.New("gcal: callback has no authorization code")
// ErrNoRefreshToken means Google granted access without a refresh token,
// so MealQuest could not push later; the household must connect again.
ErrNoRefreshToken = errors.New("gcal: Google returned no refresh token")
// ErrScopes means the household did not grant every scope in Scopes.
ErrScopes = errors.New("gcal: Google did not grant every Calendar permission")
// ErrNotConnected means no Google account is connected.
ErrNotConnected = errors.New("gcal: Google Calendar is not connected")
)
// Config is what NewService needs to talk to Google.
type Config struct {
ClientID string
ClientSecret string
// PublicURL is the service's public URL; its origin prefixes CallbackPath
// to form the redirect URI.
PublicURL *url.URL
// SecretKey is the server key the connect cookie's signing key derives
// from.
SecretKey []byte
// Endpoint is Google's authorization and token endpoint; production uses
// GoogleEndpoint and tests a fake.
Endpoint oauth2.Endpoint
// RevokeURL is Google's revocation endpoint; production uses
// GoogleRevokeURL and tests a fake.
RevokeURL string
// CalendarAPI is the Calendar API's base URL; production uses
// GoogleCalendarAPI and tests a fake.
CalendarAPI string
}
// Service connects and disconnects the household's Google account,
// chooses the calendar MealQuest pushes to and writes events to it.
// MealQuest runs as one process, so the service's in-memory lock and pending
// states see every request.
type Service struct {
st *store.Store
box *secretbox.Box
oauth oauth2.Config
revokeURL string
// calendarAPI is the Calendar API's base URL.
calendarAPI string
client *http.Client
cookie auth.FlowCookie
// retryDelay is the wait before the first retry of a transient
// failure; tests shorten it.
retryDelay time.Duration
// now is the clock; tests replace it.
now func() time.Time
// account serializes every use of and change to the connected account —
// a connect's exchange and store, a disconnect's read, revoke and delete,
// a calendar listing's read and Calendar API calls, a calendar choice's
// listing and store, an event push's read, event writes and record —
// across the requests to Google and the database write. Google revokes the whole grant, so a revoke overlapping a
// connect would also kill the token the connect stores.
account sync.Mutex
// pending holds the state of every connect started and not yet
// completed, with when it expires. Complete consumes a state once, so a
// replayed cookie finds it gone.
pendingMu sync.Mutex
pending map[string]time.Time
}
// NewService returns the service over st, sealing refresh tokens with box.
func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("gcal: nil store")
case box == nil:
return nil, errors.New("gcal: nil secret box")
case cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("gcal: client ID and client secret are required")
case cfg.PublicURL == nil:
return nil, errors.New("gcal: public URL is required")
case len(cfg.SecretKey) == 0:
return nil, errors.New("gcal: secret key is required")
case cfg.Endpoint.AuthURL == "", cfg.Endpoint.TokenURL == "", cfg.RevokeURL == "", cfg.CalendarAPI == "":
return nil, errors.New("gcal: authorization, token, revoke and Calendar API endpoints are required")
}
return &Service{
st: st,
box: box,
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: cfg.Endpoint,
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
Scopes: slices.Clone(Scopes),
},
revokeURL: cfg.RevokeURL,
calendarAPI: cfg.CalendarAPI,
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
retryDelay: firstRetryDelay,
now: time.Now,
pending: map[string]time.Time{},
}, nil
}
// connectState travels from Connect to Complete in the signed connect
// cookie.
type connectState struct {
State string `json:"s"`
Verifier string `json:"v"`
// Session binds the state to the session that started the connect: the
// SHA-256 of its CSRF token, which is unique to the session and never
// changes during it.
Session string `json:"b"`
auth.FlowExpiry
}
// sessionBinding is the value connectState.Session must hold for s.
func sessionBinding(s auth.Session) string {
sum := sha256.Sum256([]byte(s.CSRFToken))
return hex.EncodeToString(sum[:])
}
// Connect sets the connect cookie on w, bound to session, and returns
// Google's consent URL: offline access, consent always prompted so Google
// always issues a refresh token, and PKCE (S256).
func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string, error) {
if session.CSRFToken == "" {
return "", errors.New("gcal: connect needs a session")
}
state := connectState{
State: rand.Text(),
Verifier: oauth2.GenerateVerifier(),
Session: sessionBinding(session),
}
now := s.now()
if err := s.cookie.Set(w, &state, now); err != nil {
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
}
s.addPending(state.State, now)
return s.oauth.AuthCodeURL(state.State,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce,
oauth2.S256ChallengeOption(state.Verifier),
), nil
}
// Complete finishes the connect Google redirected r back with. It clears the
// connect cookie, checks the state against it and session, consumes the
// state, exchanges the code and stores the refresh token, sealed, with
// connected_at now. A reconnect replaces the stored token and keeps the
// chosen calendar.
func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.Session) error {
ctx := r.Context()
s.cookie.Clear(w)
var state connectState
if err := s.cookie.Read(r, &state, s.now()); err != nil {
return fmt.Errorf("%w: %v", ErrBadState, err)
}
q := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
return fmt.Errorf("%w: state mismatch", ErrBadState)
}
if subtle.ConstantTimeCompare([]byte(sessionBinding(session)), []byte(state.Session)) != 1 {
return fmt.Errorf("%w: started by another session", ErrBadState)
}
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !s.consumePending(state.State) {
return fmt.Errorf("%w: already used", ErrBadState)
}
if q.Get("error") != "" {
// error and error_description are Google-supplied text; neither is
// kept.
return ErrDenied
}
code := q.Get("code")
if code == "" {
return ErrNoCode
}
s.account.Lock()
defer s.account.Unlock()
token, err := s.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, s.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return fmt.Errorf("gcal: %w", auth.ExchangeError(err))
}
if token.RefreshToken == "" {
return ErrNoRefreshToken
}
if err := checkScopes(token); err != nil {
return err
}
sealed, err := s.box.Encrypt([]byte(token.RefreshToken))
if err != nil {
return fmt.Errorf("gcal: seal refresh token: %w", err)
}
return s.store(ctx, sealed)
}
// addPending records state, started at now, as pending until the connect
// cookie expires, and forgets expired ones.
func (s *Service) addPending(state string, now time.Time) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
for k, expires := range s.pending {
if !now.Before(expires) {
delete(s.pending, k)
}
}
s.pending[state] = now.Add(connectCookieTTL)
}
// consumePending removes state from the pending states, reporting whether
// it was there.
func (s *Service) consumePending(state string) bool {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
_, ok := s.pending[state]
delete(s.pending, state)
return ok
}
// checkScopes fails with ErrScopes unless token's granted scopes include
// every scope in Scopes. Google lets the household untick a permission on
// the consent screen and reports what was granted in "scope".
func checkScopes(token *oauth2.Token) error {
granted, _ := token.Extra("scope").(string)
have := strings.Fields(granted)
for _, want := range Scopes {
if !slices.Contains(have, want) {
return ErrScopes
}
}
return nil
}
// store saves sealed as the refresh token with connected_at now, clearing
// any earlier sync error.
func (s *Service) store(ctx context.Context, sealed []byte) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
exists := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
created := now
if exists {
created = row.CreatedAt
}
if _, err := q.UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: sealed,
CalendarID: row.CalendarID,
CalendarName: row.CalendarName,
ConnectedAt: now,
CreatedAt: created,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save Google integration: %w", err)
}
return nil
})
}
// Disconnect revokes the stored refresh token at Google, then deletes the
// connection. A token Google no longer knows (the household already removed
// MealQuest's access in their Google account) counts as revoked. Any other
// revoke failure keeps the connection so the household can retry.
//
// A connection whose grant Google already refused (reconnect required, no
// refresh token stored) has nothing to revoke and is deleted.
//
// No calendar is chosen once disconnected, so the same transaction that
// deletes the connection forgets every pushed event (forgetEvents): every
// synced plan becomes changed, and the next push after connecting again and
// choosing a calendar writes the whole week.
func (s *Service) Disconnect(ctx context.Context) error {
s.account.Lock()
defer s.account.Unlock()
row, err := s.connection(ctx)
if err != nil {
return err
}
if row.RefreshTokenCt != nil {
token, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return fmt.Errorf("gcal: open refresh token: %w", err)
}
if err := s.revoke(ctx, string(token)); err != nil {
return err
}
}
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := q.DeleteIntegrationsGoogle(ctx); err != nil {
return fmt.Errorf("gcal: delete Google integration: %w", err)
}
return forgetEvents(ctx, q, store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String())
})
}
// RevokeError is a revoke Google refused or that failed in transit. Its text
// never holds the token or Google's response body.
type RevokeError struct {
msg string
}
func (e *RevokeError) Error() string { return e.msg }
// revoke revokes token at Google. HTTP 200 and HTTP 400 invalid_token (the
// token is already revoked or expired) both leave it unusable.
func (s *Service) revoke(ctx context.Context, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.revokeURL,
strings.NewReader(url.Values{"token": {token}}.Encode()))
if err != nil {
return fmt.Errorf("gcal: build revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return &RevokeError{msg: fmt.Sprintf("gcal: revoke request failed: %s: %v", transport.Op, transport.Err)}
}
return &RevokeError{msg: "gcal: revoke request failed"}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRevokeBody))
if err != nil {
return &RevokeError{msg: "gcal: read revoke response failed"}
}
if resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusBadRequest {
var reply struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &reply) == nil && reply.Error == "invalid_token" {
return nil
}
}
return &RevokeError{msg: fmt.Sprintf("gcal: Google refused the revoke (HTTP %d)", resp.StatusCode)}
}
-589
View File
@@ -1,589 +0,0 @@
package gcal_test
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const publicURL = "https://meals.example.com/app"
var (
redirectURL = "https://meals.example.com" + gcal.CallbackPath
session = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-one"}
other = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-two"}
)
type harness struct {
svc *gcal.Service
google *gcaltest.Google
st *store.Store
box *secretbox.Box
clock time.Time
}
func newHarness(t *testing.T) *harness {
t.Helper()
ctx := context.Background()
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)
}
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
pub, err := url.Parse(publicURL)
if err != nil {
t.Fatal(err)
}
google := gcaltest.New(t, redirectURL)
svc, err := gcal.NewService(st, box, gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: google.Endpoint(),
RevokeURL: google.RevokeURL(),
CalendarAPI: google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
gcal.SetClock(svc, func() time.Time { return h.clock })
gcal.SetRetryDelay(svc, 0)
return h
}
// connect is a started connect: Google's consent URL and the connect
// cookie.
type connect struct {
consent *url.URL
cookie *http.Cookie
}
func (h *harness) connect(t *testing.T, s auth.Session) connect {
t.Helper()
rec := httptest.NewRecorder()
raw, err := h.svc.Connect(rec, s)
if err != nil {
t.Fatal(err)
}
consent, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("Connect set %d cookies, want 1", len(cookies))
}
return connect{consent: consent, cookie: cookies[0]}
}
// callback completes c as session s with query, returning the error and the
// recorder Complete wrote to.
func (h *harness) callback(c connect, s auth.Session, query url.Values) (*httptest.ResponseRecorder, error) {
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+query.Encode(), nil)
if c.cookie != nil {
req.AddCookie(c.cookie)
}
rec := httptest.NewRecorder()
return rec, h.svc.Complete(rec, req, s)
}
// consented is the query Google sends back after consent to c.
func (h *harness) consented(c connect) url.Values {
q := c.consent.Query()
return url.Values{"state": {q.Get("state")}, "code": {h.google.Authorize(q.Get("code_challenge"))}}
}
func (h *harness) row(t *testing.T) (db.IntegrationsGoogle, bool) {
t.Helper()
row, err := db.New(h.st.Reader).GetIntegrationsGoogle(context.Background())
if errors.Is(err, sql.ErrNoRows) {
return db.IntegrationsGoogle{}, false
}
if err != nil {
t.Fatal(err)
}
return row, true
}
func TestConnectRedirectsToConsent(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if got, want := c.consent.Scheme+"://"+c.consent.Host+c.consent.Path, h.google.Endpoint().AuthURL; got != want {
t.Errorf("consent URL = %s, want %s", got, want)
}
q := c.consent.Query()
for name, want := range map[string]string{
"client_id": gcaltest.ClientID,
"redirect_uri": redirectURL,
"response_type": "code",
"access_type": "offline",
"prompt": "consent",
"code_challenge_method": "S256",
} {
if got := q.Get(name); got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
if got := strings.Fields(q.Get("scope")); !slices.Equal(got, []string{
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
}) {
t.Errorf("scope = %q", q.Get("scope"))
}
if q.Get("state") == "" || q.Get("code_challenge") == "" {
t.Errorf("state %q, code_challenge %q: want both set", q.Get("state"), q.Get("code_challenge"))
}
if q.Get("client_secret") != "" || strings.Contains(c.consent.String(), gcaltest.ClientSecret) {
t.Error("consent URL carries the client secret")
}
ck := c.cookie
if ck.Name != "__Host-mealquest_google" || ck.Path != "/" || !ck.Secure || !ck.HttpOnly ||
ck.SameSite != http.SameSiteLaxMode || ck.MaxAge != 600 || ck.Domain != "" {
t.Errorf("connect cookie = %+v, want __Host-, Path=/, Secure, HttpOnly, Lax, 10 minutes", ck)
}
// Two connects never share a state.
if again := h.connect(t, session); again.consent.Query().Get("state") == q.Get("state") {
t.Error("two connects share a state")
}
}
func TestCompleteStoresSealedRefreshToken(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
rec, err := h.callback(c, session, h.consented(c))
if err != nil {
t.Fatalf("Complete: %v", err)
}
row, ok := h.row(t)
if !ok {
t.Fatal("no integration stored")
}
if bytes.Contains(row.RefreshTokenCt, []byte(gcaltest.RefreshToken)) {
t.Error("refresh token stored in plain text")
}
plain, err := h.box.Decrypt(row.RefreshTokenCt)
if err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("stored token opens to %q, %v; want the refresh token", plain, err)
}
if want := "2026-10-07T18:30:00.000Z"; row.ConnectedAt != want || row.CreatedAt != want || row.UpdatedAt != want {
t.Errorf("connected_at %q created_at %q updated_at %q, want %s", row.ConnectedAt, row.CreatedAt, row.UpdatedAt, want)
}
if row.CalendarID.Valid || row.CalendarName.Valid || row.LastSyncError.Valid {
t.Errorf("new connection has calendar or sync error: %+v", row)
}
assertCookieCleared(t, rec)
assertNoSecret(t, "response", rec.Body.String()+headerText(rec.Header()))
}
func TestReconnectReplacesTokenAndKeepsCalendar(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
if _, err := db.New(h.st.Writer).UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: []byte("old sealed token"),
CalendarID: sql.NullString{String: "family@group.calendar.google.com", Valid: true},
CalendarName: sql.NullString{String: "Family", Valid: true},
ConnectedAt: "2026-09-01T00:00:00.000Z",
LastSyncError: sql.NullString{String: "token revoked", Valid: true},
CreatedAt: "2026-09-01T00:00:00.000Z",
UpdatedAt: "2026-09-01T00:00:00.000Z",
}); err != nil {
t.Fatal(err)
}
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
row, _ := h.row(t)
if plain, err := h.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("token after reconnect opens to %q, %v", plain, err)
}
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
t.Errorf("calendar after reconnect = %v %v, want it kept", row.CalendarID, row.CalendarName)
}
if row.LastSyncError.Valid {
t.Errorf("last_sync_error = %q, want it cleared", row.LastSyncError.String)
}
if row.CreatedAt != "2026-09-01T00:00:00.000Z" || row.ConnectedAt != "2026-10-07T18:30:00.000Z" {
t.Errorf("created_at %q connected_at %q", row.CreatedAt, row.ConnectedAt)
}
}
func TestCompleteRefusesBadState(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// edit changes the connect, the callback's session or its query.
edit func(t *testing.T, h *harness, c *connect, s *auth.Session, q url.Values)
}{
{"missing state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Del("state") }},
{"wrong state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Set("state", "forged") }},
{"no connect cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) { c.cookie = nil }},
{"tampered cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) {
c.cookie = &http.Cookie{Name: c.cookie.Name, Value: "x" + c.cookie.Value}
}},
{"another session", func(_ *testing.T, _ *harness, _ *connect, s *auth.Session, _ url.Values) { *s = other }},
{"expired", func(_ *testing.T, h *harness, _ *connect, _ *auth.Session, _ url.Values) {
h.clock = h.clock.Add(10 * time.Minute)
}},
{"state of another connect", func(t *testing.T, h *harness, _ *connect, _ *auth.Session, q url.Values) {
q.Set("state", h.connect(t, session).consent.Query().Get("state"))
}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
s := session
q := h.consented(c)
tc.edit(t, h, &c, &s, q)
rec, err := h.callback(c, s, q)
if !errors.Is(err, gcal.ErrBadState) {
t.Fatalf("Complete error = %v, want ErrBadState", err)
}
if h.google.TokenCalls() != 0 {
t.Error("the code was exchanged despite the bad state")
}
if _, ok := h.row(t); ok {
t.Error("an integration was stored")
}
assertCookieCleared(t, rec)
})
}
}
// TestCompleteRefusesReplayedState presents a connect's cookie and state a
// second time, with a second valid code from the same consent URL, as an
// attacker holding a copy of the cookie could. The server refuses it,
// whatever the browser did with the cleared cookie.
func TestCompleteRefusesReplayedState(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
stored, _ := h.row(t)
_, err := h.callback(c, session, h.consented(c))
if !errors.Is(err, gcal.ErrBadState) {
t.Fatalf("replayed Complete error = %v, want ErrBadState", err)
}
if got := h.google.TokenCalls(); got != 1 {
t.Errorf("token endpoint called %d times, want once: the replayed code was exchanged", got)
}
if row, _ := h.row(t); !bytes.Equal(row.RefreshTokenCt, stored.RefreshTokenCt) {
t.Error("the replay replaced the stored token")
}
}
func TestCompleteRefusesMissingRefreshToken(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.google.OmitRefreshToken()
c := h.connect(t, session)
_, err := h.callback(c, session, h.consented(c))
if !errors.Is(err, gcal.ErrNoRefreshToken) {
t.Fatalf("Complete error = %v, want ErrNoRefreshToken", err)
}
if _, ok := h.row(t); ok {
t.Error("an integration was stored without a refresh token")
}
assertNoSecret(t, "error", err.Error())
}
func TestCompleteRefusesMissingScope(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.google.GrantScope("https://www.googleapis.com/auth/calendar.calendarlist.readonly")
c := h.connect(t, session)
_, err := h.callback(c, session, h.consented(c))
if !errors.Is(err, gcal.ErrScopes) {
t.Fatalf("Complete error = %v, want ErrScopes", err)
}
if _, ok := h.row(t); ok {
t.Error("an integration was stored without calendar.events")
}
}
func TestCompleteDenied(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
q := url.Values{"state": {c.consent.Query().Get("state")}, "error": {"access_denied"}}
if _, err := h.callback(c, session, q); !errors.Is(err, gcal.ErrDenied) {
t.Fatalf("Complete error = %v, want ErrDenied", err)
}
if h.google.TokenCalls() != 0 {
t.Error("token endpoint called after a denial")
}
}
func TestCompleteExchangeFailureIsRedacted(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.google.FailToken(http.StatusBadRequest)
c := h.connect(t, session)
_, err := h.callback(c, session, h.consented(c))
if err == nil {
t.Fatal("Complete succeeded despite the token endpoint failing")
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("error = %v, want the status", err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); ok {
t.Error("an integration was stored")
}
}
func TestDisconnectRevokesAndClears(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
if err := h.svc.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if got := h.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
t.Errorf("revoked %q, want the refresh token", got)
}
if _, ok := h.row(t); ok {
t.Error("integration still stored after disconnect")
}
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("second Disconnect = %v, want ErrNotConnected", err)
}
}
// TestDisconnectRacingReconnect drives a disconnect and a reconnect into
// each other at Google, with distinct tokens. However they interleave, the
// end state is consistent: disconnected, or connected with a token Google
// has not revoked.
func TestDisconnectRacingReconnect(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// hold stops the request the other operation must not slip past.
hold func(*gcaltest.Google) (<-chan struct{}, func())
// first is held at Google while second runs.
first, second func(h *harness, c connect, q url.Values) error
}{
{
// The disconnect has read the stored token and is revoking it
// when the reconnect would store a new one.
name: "reconnect during revoke",
hold: (*gcaltest.Google).HoldRevoke,
first: disconnect,
second: reconnect,
},
{
// The reconnect's new token is issued but not yet stored when
// the disconnect would revoke the old one.
name: "disconnect during exchange",
hold: (*gcaltest.Google).HoldToken,
first: reconnect,
second: disconnect,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
first := h.connect(t, session)
if _, err := h.callback(first, session, h.consented(first)); err != nil {
t.Fatal(err)
}
c := h.connect(t, session)
q := h.consented(c)
arrived, release := tc.hold(h.google)
defer release()
firstDone := make(chan error, 1)
go func() { firstDone <- tc.first(h, c, q) }()
<-arrived
secondDone := make(chan error, 1)
go func() { secondDone <- tc.second(h, c, q) }()
// Let the second operation run as far as it can while the first
// is held at Google: to the end when nothing serializes them.
select {
case err := <-secondDone:
secondDone <- err
case <-time.After(500 * time.Millisecond):
}
release()
for _, done := range []chan error{firstDone, secondDone} {
if err := <-done; err != nil {
t.Logf("operation: %v", err)
}
}
row, ok := h.row(t)
if !ok {
return
}
plain, err := h.box.Decrypt(row.RefreshTokenCt)
if err != nil {
t.Fatal(err)
}
if !h.google.Live(string(plain)) {
t.Errorf("stored as connected with a revoked token (Google revoked %d tokens)", len(h.google.Revoked()))
}
})
}
}
func disconnect(h *harness, _ connect, _ url.Values) error {
return h.svc.Disconnect(context.Background())
}
func reconnect(h *harness, c connect, q url.Values) error {
_, err := h.callback(c, session, q)
return err
}
func TestDisconnectTokenAlreadyRevokedAtGoogle(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
h.google.AnswerRevoke(http.StatusBadRequest, `{"error":"invalid_token","error_description":"Token expired or revoked"}`)
if err := h.svc.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if _, ok := h.row(t); ok {
t.Error("integration still stored")
}
}
func TestDisconnectRevokeFailureKeepsConnection(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} {
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
h.google.AnswerRevoke(status, "")
err := h.svc.Disconnect(context.Background())
var revoke *gcal.RevokeError
if !errors.As(err, &revoke) {
t.Fatalf("HTTP %d: Disconnect error = %v, want *RevokeError", status, err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); !ok {
t.Errorf("HTTP %d: connection cleared although Google did not revoke", status)
}
}
}
func TestDisconnectNotConnected(t *testing.T) {
t.Parallel()
h := newHarness(t)
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
t.Fatalf("Disconnect = %v, want ErrNotConnected", err)
}
if len(h.google.Revoked()) != 0 {
t.Error("revoke called with nothing connected")
}
}
func TestNewServiceRequiresConfig(t *testing.T) {
t.Parallel()
h := newHarness(t)
pub, _ := url.Parse(publicURL)
valid := gcal.Config{
ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"),
Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL, CalendarAPI: gcal.GoogleCalendarAPI,
}
if _, err := gcal.NewService(h.st, h.box, valid); err != nil {
t.Fatalf("valid config: %v", err)
}
for name, edit := range map[string]func(*gcal.Config){
"client ID": func(c *gcal.Config) { c.ClientID = "" },
"client secret": func(c *gcal.Config) { c.ClientSecret = "" },
"public URL": func(c *gcal.Config) { c.PublicURL = nil },
"secret key": func(c *gcal.Config) { c.SecretKey = nil },
"token URL": func(c *gcal.Config) { c.Endpoint.TokenURL = "" },
"revoke URL": func(c *gcal.Config) { c.RevokeURL = "" },
"Calendar API": func(c *gcal.Config) { c.CalendarAPI = "" },
} {
cfg := valid
edit(&cfg)
if _, err := gcal.NewService(h.st, h.box, cfg); err == nil {
t.Errorf("NewService accepted a config without the %s", name)
}
}
}
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == "__Host-mealquest_google" && c.MaxAge < 0 {
return
}
}
t.Error("connect cookie not cleared")
}
// assertNoSecret fails when text holds the refresh token, the access token
// or the client secret.
func assertNoSecret(t *testing.T, what, text string) {
t.Helper()
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(text, secret) {
t.Errorf("%s contains a secret: %s", what, text)
}
}
}
func headerText(h http.Header) string {
var b strings.Builder
for k, vs := range h {
b.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
}
return b.String()
}
-217
View File
@@ -1,217 +0,0 @@
package gcal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
)
// maxEventBody caps how much of an events response is read.
const maxEventBody = 64 << 10
var (
// ErrNoCalendar means Google Calendar is connected but no calendar is
// chosen to push to.
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
// ErrCalendarUnavailable means the chosen calendar was deleted or the
// connected account can no longer add events to it. Such a calendar
// cannot be chosen again; the household must choose another.
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
)
// Google's rule for a client-chosen event id: base32hex characters
// (lowercase a-v and 0-9), 5 to 1024 of them.
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
const (
minEventID = 5
maxEventID = 1024
)
// Event is one event to write to the chosen calendar.
type Event struct {
// ID is the event's id, chosen by the caller so that writing the same
// event again updates it; it must satisfy Google's id rule.
ID string
Summary string
Description string
Start, End time.Time
// TimeZone is the IANA zone the event is in, such as America/New_York.
TimeZone string
}
func (e Event) validate() error {
switch {
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
case e.Summary == "":
return fmt.Errorf("gcal: event %s has no summary", e.ID)
case !e.End.After(e.Start):
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
case e.TimeZone == "":
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
}
return nil
}
// eventBody is an event as the Calendar API takes it.
type eventBody struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start eventTime `json:"start"`
End eventTime `json:"end"`
}
type eventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// PushEvents writes events, in order, to the chosen calendar: each is
// inserted with its id, and an id Google already has (HTTP 409, which
// includes an event deleted in Calendar) is updated in place instead, so
// writing an event again never adds a second one. It stops at the first
// failure; the events before it stay written, and writing them all again
// is safe. Nothing connected is ErrNotConnected, a grant already recorded
// as refused is ErrReconnect whatever calendar is chosen, no calendar
// chosen is ErrNoCalendar, a chosen calendar that was deleted or is no longer
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
// other Google failure is a *CalendarError, retryable when Google answered
// every try with HTTP 429 or 5xx.
//
// Once every event is written, PushEvents returns record(), which the
// caller uses to store what it pushed. record runs under the same hold of
// the account as the writes, so the chosen calendar cannot change between
// them; it is not run when a write fails.
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
if len(events) == 0 {
return errors.New("gcal: no events to push")
}
for _, e := range events {
if err := e.validate(); err != nil {
return err
}
}
s.account.Lock()
defer s.account.Unlock()
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
}
// pushEvents is PushEvents once the events are valid. The caller holds
// s.account. A refused grant is reported before no calendar chosen
// (grantedConnection): choosing a calendar cannot fix it, connecting again
// can.
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
row, err := s.grantedConnection(ctx)
if err != nil {
return err
}
if !row.CalendarID.Valid || row.CalendarID.String == "" {
return ErrNoCalendar
}
access, err := s.accessToken(ctx, row)
if err != nil {
return err
}
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
for _, e := range events {
if err := s.writeEvent(ctx, access, base, e); err != nil {
return err
}
}
return record()
}
// writeEvent inserts e at base, the chosen calendar's events collection,
// and updates it when Google already has its id.
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
body, err := json.Marshal(eventBody{
ID: e.ID,
Status: "confirmed",
Summary: e.Summary,
Description: e.Description,
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
})
if err != nil {
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
}
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
if err != nil {
return err
}
if status != http.StatusConflict {
return nil
}
// The status "confirmed" in the body restores an event deleted in
// Calendar, which Google keeps as cancelled under the same id.
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
return err
}
// eventRequest sends an event insert (POST) or update (PUT), retrying a
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
// every other answer is an error. An insert Google took but answered with
// a transient failure is answered 409 when retried, and updated.
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
what := "event insert"
if method == http.MethodPut {
what = "event update"
}
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return 0, err
}
switch {
case status == http.StatusOK:
return http.StatusOK, nil
case status == http.StatusConflict && method == http.MethodPost:
return http.StatusConflict, nil
case status == http.StatusUnauthorized:
// The access token was just refreshed from the grant.
return 0, ErrReconnect
case status == http.StatusNotFound && method == http.MethodPost,
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
// An insert into a deleted calendar, or one the account no longer
// sees, is not found; a write to one it can only read is
// requiredAccessLevel. An update not found is the event missing
// from the calendar after Google reported its id taken, which
// says nothing about the calendar.
return 0, ErrCalendarUnavailable
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
}
// errorReason is the reason of the first error in a Calendar API error
// body, or "" when there is none. Only the reason is read; the rest of
// Google's text is never kept.
func errorReason(body []byte) string {
var reply struct {
Error struct {
Errors []struct {
Reason string `json:"reason"`
} `json:"errors"`
} `json:"error"`
}
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
return ""
}
return reply.Error.Errors[0].Reason
}
-189
View File
@@ -1,189 +0,0 @@
package gcal_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// chosen is a harness connected with dinners chosen as the calendar to
// push to.
func chosen(t *testing.T) *harness {
t.Helper()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
return h
}
// noRecord is a PushEvents record that stores nothing.
func noRecord() error { return nil }
// event is a valid event with id.
func event(id string) gcal.Event {
start := time.Date(2026, 10, 12, 21, 30, 0, 0, time.UTC)
return gcal.Event{ID: id, Summary: "Dinner: Beef Chili", Description: "Beef Chili",
Start: start, End: start.Add(time.Hour), TimeZone: "America/New_York"}
}
func TestPushEventsInsertsThenUpdates(t *testing.T) {
t.Parallel()
h := chosen(t)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}, noRecord); err != nil {
t.Fatalf("PushEvents: %v", err)
}
renamed := event("abcde0")
renamed.Summary = "Dinner: Pot Roast"
if err := h.svc.PushEvents(context.Background(), []gcal.Event{renamed}, noRecord); err != nil {
t.Fatalf("PushEvents again: %v", err)
}
if inserts, updates := h.google.EventWrites(); inserts != 2 || updates != 1 {
t.Errorf("%d inserts and %d updates, want 2 and 1", inserts, updates)
}
events := h.google.Events(dinners.ID)
if len(events) != 2 || events["abcde0"].Summary != "Dinner: Pot Roast" {
t.Errorf("events = %+v", events)
}
if got := events["abcde1"].Start.DateTime; got != "2026-10-12T21:30:00Z" {
t.Errorf("start = %s, want the instant as given", got)
}
}
func TestPushEventsRefusesInvalidEvents(t *testing.T) {
t.Parallel()
h := chosen(t)
before := h.google.TokenCalls()
for name, edit := range map[string]func(*gcal.Event){
"uppercase id": func(e *gcal.Event) { e.ID = "ABCDE" },
"id outside a-v": func(e *gcal.Event) { e.ID = "abcdw" },
"id too short": func(e *gcal.Event) { e.ID = "abcd" },
"id too long": func(e *gcal.Event) { e.ID = strings.Repeat("a", 1025) },
"no summary": func(e *gcal.Event) { e.Summary = "" },
"ends as it starts": func(e *gcal.Event) { e.End = e.Start },
"no time zone": func(e *gcal.Event) { e.TimeZone = "" },
} {
ev := event("abcde")
edit(&ev)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}, noRecord); err == nil {
t.Errorf("%s: PushEvents succeeded", name)
}
}
if err := h.svc.PushEvents(context.Background(), nil, noRecord); err == nil {
t.Error("PushEvents of no events succeeded")
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}, noRecord); err != nil {
t.Errorf("PushEvents of a 1024-character id: %v", err)
}
if inserts, _ := h.google.EventWrites(); inserts != 1 {
t.Errorf("%d events inserted, want only the valid one", inserts)
}
if n := h.google.TokenCalls() - before; n != 1 {
t.Errorf("token endpoint called %d times, want once, for the valid push", n)
}
}
func TestPushEventsGrantNoLongerAccepted(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.FailEventWrite(1, http.StatusUnauthorized)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
t.Error("record ran after a failed write")
return nil
})
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("PushEvents = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); !ok {
t.Error("a refused grant forgot the connection")
}
}
func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
t.Parallel()
for name, n := range map[string]int{"insert": 1, "update": 2} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := chosen(t)
if name == "update" {
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Fatal(err)
}
}
// The fake's failure body quotes the access token.
h.google.FailEventWrite(n, http.StatusBadRequest)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("PushEvents = %v, want *CalendarError", err)
}
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("error = %q, want the failed %s and its status", err, name)
}
assertNoSecret(t, "error", err.Error())
})
}
}
// TestPushEventsHoldsTheAccount holds an event write at Google while a
// disconnect starts: the disconnect waits for the push, so it never
// revokes the token the push is using.
func TestPushEventsHoldsTheAccount(t *testing.T) {
t.Parallel()
h := chosen(t)
arrived, release := h.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() { pushed <- h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord) }()
<-arrived
disconnected := make(chan error, 1)
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
if n := len(h.google.Revoked()); n != 0 {
t.Fatalf("revoked %d tokens while a push was at Google", n)
}
release()
if err := <-pushed; err != nil {
t.Errorf("PushEvents: %v", err)
}
if err := <-disconnected; err != nil {
t.Errorf("Disconnect: %v", err)
}
}
// TestPushEventsRecordsWhileTheCalendarIsChosen chooses another calendar
// while a push records: the choice waits for the record, so a push never
// records events on a calendar no longer chosen.
func TestPushEventsRecordsWhileTheCalendarIsChosen(t *testing.T) {
t.Parallel()
h := chosen(t)
selected := make(chan error, 1)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
go func() {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
selected <- err
}()
select {
case err := <-selected:
t.Errorf("SelectCalendar finished (%v) while a push was recording", err)
case <-time.After(200 * time.Millisecond):
}
return nil
})
if err != nil {
t.Fatalf("PushEvents: %v", err)
}
if err := <-selected; err != nil {
t.Errorf("SelectCalendar: %v", err)
}
}
-10
View File
@@ -1,10 +0,0 @@
package gcal
import "time"
// SetClock replaces s's clock.
func SetClock(s *Service, now func() time.Time) { s.now = now }
// SetRetryDelay replaces s's wait before the first retry of a transient
// Google failure.
func SetRetryDelay(s *Service, d time.Duration) { s.retryDelay = d }
-749
View File
@@ -1,749 +0,0 @@
// Package gcaltest provides a fake Google OAuth server and Calendar API for
// tests of the Google Calendar connection: a token endpoint that checks the
// client, the redirect URI and PKCE, issues a distinct refresh token per
// exchange and trades a live refresh token for an access token; a revoke
// endpoint that, like Google's, revokes the whole grant: every token issued
// so far; a calendarList endpoint that requires a live grant's access
// token; and events insert and update endpoints that, like Google's, keep
// each calendar's events by id, answer an insert of an id the calendar
// already has (even a cancelled event's) with HTTP 409, and refuse a
// calendar not listed (404) or not writable (403 requiredAccessLevel). It
// never touches the network beyond its own httptest server.
package gcaltest
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"testing"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// Fixed credentials and tokens the fake issues and expects. The first
// exchange issues RefreshToken; exchange n after it issues RefreshToken
// followed by "-n", so every issued refresh token contains RefreshToken. Every
// exchange and refresh issues AccessToken.
const (
ClientID = "mealquest-test.apps.googleusercontent.com"
ClientSecret = "google-client-s3cret-4f1c"
RefreshToken = "1//google-refresh-t0ken-9a8b7c"
AccessToken = "ya29.google-access-t0ken-5d6e"
)
// Google is the fake. Its exported fields script the next responses; set
// them before the request they affect.
type Google struct {
Server *httptest.Server
// RedirectURL is the redirect URI the token endpoint requires.
RedirectURL string
mu sync.Mutex
// omitRefresh leaves refresh_token out of the token response.
omitRefresh bool
// scope is the token response's granted scopes.
scope string
// tokenStatus, when not 200, fails the token endpoint with a body that
// quotes the refresh token and client secret.
tokenStatus int
// revokeStatus and revokeBody answer the revoke endpoint; revokeBody
// quotes the token when revokeStatus is not 200 or 400.
revokeStatus int
revokeBody string
codes map[string]string // code -> PKCE challenge
tokenCalls int
revoked []string
// issued is every refresh token issued, in order; dead is those a
// revoke has invalidated.
issued []string
dead map[string]bool
// tokenHold, revokeHold and calendarHold, when set, stop the next
// request to that endpoint.
tokenHold, revokeHold, calendarHold *hold
// calendars is what the calendarList endpoint lists, pageSize entries
// to a page (all of them when 0).
calendars []Calendar
pageSize int
// calendarStatus, when not 200, fails the calendarList endpoint with a
// body that quotes the access token: every request when calendarFails
// is 0, else the next calendarFails requests.
calendarStatus int
calendarFails int
calendarCalls int
// rejectAccess makes the Calendar API answer HTTP 401 to every access
// token, while the grant stays live at the token endpoint.
rejectAccess bool
// events holds each calendar's events by id.
events map[string]map[string]Event
// inserts and updates count the events created and updated.
inserts, updates int
// eventWrites counts the requests that reached the events endpoints;
// failWrite, when not 0, fails the requests that make it that count up
// to failLast with failStatus and a body that quotes the access token.
eventWrites, failWrite, failLast, failStatus int
// eventHold, when set, stops the next events request.
eventHold *hold
}
// Event is an event as the fake keeps it: what MealQuest sent.
type Event struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start EventTime `json:"start"`
End EventTime `json:"end"`
}
// EventTime is an event's start or end.
type EventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// Calendar is one calendarList entry. The fake answers each with Google's
// other fields too, so tests can check that nothing beyond id, summary,
// primary and accessRole reaches the household.
type Calendar struct {
ID string
Summary string
Primary bool
AccessRole string
Description string
SummaryOverride string
}
// hold stops one request: arrived is closed when it arrives, and it goes on
// once release is closed.
type hold struct {
arrived, release chan struct{}
}
// New starts a fake Google that requires redirectURL at its token endpoint.
// It grants every scope in gcal.Scopes with a refresh token by default.
func New(t *testing.T, redirectURL string) *Google {
t.Helper()
g := &Google{
RedirectURL: redirectURL,
scope: strings.Join(gcal.Scopes, " "),
tokenStatus: http.StatusOK,
revokeStatus: http.StatusOK,
calendarStatus: http.StatusOK,
codes: map[string]string{},
dead: map[string]bool{},
events: map[string]map[string]Event{},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /token", g.token)
mux.HandleFunc("POST /revoke", g.revoke)
mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList)
mux.HandleFunc("POST /calendar/v3/calendars/{calendar}/events", g.insertEvent)
mux.HandleFunc("PUT /calendar/v3/calendars/{calendar}/events/{event}", g.updateEvent)
g.Server = httptest.NewServer(mux)
t.Cleanup(g.Server.Close)
return g
}
// Endpoint is the fake's authorization and token endpoint, with Google's
// client authentication style.
func (g *Google) Endpoint() oauth2.Endpoint {
return oauth2.Endpoint{
AuthURL: g.Server.URL + "/auth",
TokenURL: g.Server.URL + "/token",
AuthStyle: gcal.GoogleEndpoint.AuthStyle,
}
}
// RevokeURL is the fake's revocation endpoint.
func (g *Google) RevokeURL() string { return g.Server.URL + "/revoke" }
// CalendarAPI is the fake's Calendar API base URL.
func (g *Google) CalendarAPI() string { return g.Server.URL + "/calendar/v3" }
// Authorize plays the consent screen for an authorization request with PKCE
// challenge and returns the code Google would send back.
func (g *Google) Authorize(challenge string) string {
g.mu.Lock()
defer g.mu.Unlock()
code := fmt.Sprintf("code-%d", len(g.codes)+1)
g.codes[code] = challenge
return code
}
// Connect connects svc to the fake as a household completing the consent
// screen does. svc must use the fake's endpoints and redirect URL.
func (g *Google) Connect(t *testing.T, svc *gcal.Service) {
t.Helper()
session := auth.Session{Subject: "gcaltest", CSRFToken: "gcaltest-session"}
started := httptest.NewRecorder()
raw, err := svc.Connect(started, session)
if err != nil {
t.Fatal(err)
}
consent, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
q := url.Values{
"state": {consent.Query().Get("state")},
"code": {g.Authorize(consent.Query().Get("code_challenge"))},
}
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+q.Encode(), nil)
for _, c := range started.Result().Cookies() {
req.AddCookie(c)
}
if err := svc.Complete(httptest.NewRecorder(), req, session); err != nil {
t.Fatalf("connect: %v", err)
}
}
// OmitRefreshToken makes the token endpoint answer without a refresh token,
// as Google does when it reuses an earlier grant.
func (g *Google) OmitRefreshToken() {
g.mu.Lock()
defer g.mu.Unlock()
g.omitRefresh = true
}
// GrantScope sets the scopes the token endpoint reports as granted.
func (g *Google) GrantScope(scope string) {
g.mu.Lock()
defer g.mu.Unlock()
g.scope = scope
}
// FailToken makes the token endpoint answer status with a body that quotes
// the refresh token and client secret.
func (g *Google) FailToken(status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.tokenStatus = status
}
// AnswerRevoke makes the revoke endpoint answer status with body. A body of
// "" with a status other than 200 quotes the token it was sent.
func (g *Google) AnswerRevoke(status int, body string) {
g.mu.Lock()
defer g.mu.Unlock()
g.revokeStatus, g.revokeBody = status, body
}
// RevokeGrant revokes every token issued so far, as the household removing
// MealQuest's access in their Google account does.
func (g *Google) RevokeGrant() {
g.mu.Lock()
defer g.mu.Unlock()
for _, t := range g.issued {
g.dead[t] = true
}
}
// SetCalendars makes the calendarList endpoint list cals, pageSize to a
// page (all on one page when 0).
func (g *Google) SetCalendars(pageSize int, cals ...Calendar) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendars, g.pageSize = slices.Clone(cals), pageSize
}
// FailCalendarList makes the calendarList endpoint answer status with a
// body that quotes the access token.
func (g *Google) FailCalendarList(status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarStatus, g.calendarFails = status, 0
}
// FailCalendarListNext makes the next n calendarList requests answer status
// as FailCalendarList does, and later ones answer normally.
func (g *Google) FailCalendarListNext(n, status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarStatus, g.calendarFails = status, n
}
// RejectAccessToken makes the Calendar API answer HTTP 401 to every access
// token while refreshing the grant still succeeds, as Google does when it
// refuses the access token it just issued.
func (g *Google) RejectAccessToken() {
g.mu.Lock()
defer g.mu.Unlock()
g.rejectAccess = true
}
// AcceptAccessToken undoes RejectAccessToken, as Google does for the grant
// of a later connection.
func (g *Google) AcceptAccessToken() {
g.mu.Lock()
defer g.mu.Unlock()
g.rejectAccess = false
}
// CalendarCalls is how many requests reached the calendarList endpoint.
func (g *Google) CalendarCalls() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.calendarCalls
}
// HoldCalendarList stops the next calendarList request before it is
// answered. It returns a channel closed when the request arrives and the
// function that lets it go on.
func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarHold = newHold()
return g.calendarHold.arrived, g.calendarHold.releaseFunc()
}
// Events returns the events of calendar, by id.
func (g *Google) Events(calendar string) map[string]Event {
g.mu.Lock()
defer g.mu.Unlock()
out := map[string]Event{}
for id, e := range g.events[calendar] {
out[id] = e
}
return out
}
// EventWrites returns how many events were inserted and how many updated.
func (g *Google) EventWrites() (inserts, updates int) {
g.mu.Lock()
defer g.mu.Unlock()
return g.inserts, g.updates
}
// ResetEventWrites zeroes the insert and update counts.
func (g *Google) ResetEventWrites() {
g.mu.Lock()
defer g.mu.Unlock()
g.inserts, g.updates = 0, 0
}
// CancelEvent marks event id of calendar cancelled, as deleting it in
// Google Calendar does: Google keeps the id.
func (g *Google) CancelEvent(calendar, id string) {
g.mu.Lock()
defer g.mu.Unlock()
e := g.events[calendar][id]
e.Status = "cancelled"
g.events[calendar][id] = e
}
// FailEventWrite makes the nth request to the events endpoints from now
// (1 for the next) answer status with a body that quotes the access token.
func (g *Google) FailEventWrite(n, status int) {
g.FailEventWrites(n, 1, status)
}
// FailEventWrites makes count requests to the events endpoints in a row,
// from the nth from now (1 for the next), answer status with a body that
// quotes the access token.
func (g *Google) FailEventWrites(n, count, status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.failWrite, g.failLast, g.failStatus = g.eventWrites+n, g.eventWrites+n+count-1, status
}
// EventRequests is how many requests reached the events endpoints.
func (g *Google) EventRequests() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.eventWrites
}
// HoldEvents stops the next request to the events endpoints before it is
// answered. It returns a channel closed when the request arrives and the
// function that lets it go on.
func (g *Google) HoldEvents() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.eventHold = newHold()
return g.eventHold.arrived, g.eventHold.releaseFunc()
}
// TokenCalls is how many requests, exchanges and refreshes, reached the
// token endpoint.
func (g *Google) TokenCalls() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.tokenCalls
}
// Revoked lists the tokens the revoke endpoint received, in order.
func (g *Google) Revoked() []string {
g.mu.Lock()
defer g.mu.Unlock()
return append([]string(nil), g.revoked...)
}
// Live reports whether token was issued by the fake and no revoke has
// invalidated it since.
func (g *Google) Live(token string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return slices.Contains(g.issued, token) && !g.dead[token]
}
// HoldToken stops the next token request after its refresh token is issued
// and before it is answered, so a revoke can land in between. It returns a
// channel closed when the request arrives and the function that lets it go
// on.
func (g *Google) HoldToken() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.tokenHold = newHold()
return g.tokenHold.arrived, g.tokenHold.releaseFunc()
}
// HoldRevoke stops the next revoke request before Google acts on it. It
// returns a channel closed when the request arrives and the function that
// lets it go on.
func (g *Google) HoldRevoke() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.revokeHold = newHold()
return g.revokeHold.arrived, g.revokeHold.releaseFunc()
}
func newHold() *hold {
return &hold{arrived: make(chan struct{}), release: make(chan struct{})}
}
func (h *hold) releaseFunc() func() {
var once sync.Once
return func() { once.Do(func() { close(h.release) }) }
}
// wait stops the request at h, if any, without holding g.mu.
func (g *Google) wait(h *hold) {
if h == nil {
return
}
g.mu.Unlock()
close(h.arrived)
<-h.release
g.mu.Lock()
}
func (g *Google) token(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
g.tokenCalls++
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
f := r.PostForm
if f.Get("grant_type") == "refresh_token" {
g.refresh(w, f)
return
}
challenge, known := g.codes[f.Get("code")]
delete(g.codes, f.Get("code"))
switch {
case f.Get("grant_type") != "authorization_code":
oauthError(w, http.StatusBadRequest, "unsupported_grant_type")
return
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
oauthError(w, http.StatusUnauthorized, "invalid_client")
return
case !known, f.Get("redirect_uri") != g.RedirectURL, s256(f.Get("code_verifier")) != challenge:
oauthError(w, http.StatusBadRequest, "invalid_grant")
return
}
if g.tokenStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(g.tokenStatus)
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"echo %s %s"}`, RefreshToken, ClientSecret)
return
}
refresh := ""
if !g.omitRefresh {
issue := RefreshToken
if n := len(g.issued); n > 0 {
issue = fmt.Sprintf("%s-%d", RefreshToken, n+1)
}
g.issued = append(g.issued, issue)
refresh = fmt.Sprintf(`"refresh_token":%q,`, issue)
}
h := g.tokenHold
g.tokenHold = nil
g.wait(h)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,%s"expires_in":3599,"scope":%q,"token_type":"Bearer"}`,
AccessToken, refresh, g.scope)
}
// refresh answers a refresh_token grant: an access token for a live refresh
// token, invalid_grant quoting the token for any other.
func (g *Google) refresh(w http.ResponseWriter, f url.Values) {
token := f.Get("refresh_token")
switch {
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
oauthError(w, http.StatusUnauthorized, "invalid_client")
return
case !slices.Contains(g.issued, token) || g.dead[token]:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"Token has been expired or revoked: %s"}`, token)
return
case g.tokenStatus != http.StatusOK:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(g.tokenStatus)
fmt.Fprintf(w, `{"error":"server_error","error_description":"echo %s %s"}`, token, ClientSecret)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,"expires_in":3599,"scope":%q,"token_type":"Bearer"}`, AccessToken, g.scope)
}
// calendarList answers like Google's calendarList.list: the scripted
// calendars, a page at a time, to a live grant's access token.
func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarCalls++
h := g.calendarHold
g.calendarHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":{"code":401,"message":"Invalid Credentials: %s"}}`, auth)
return
}
if status := g.calendarStatus; status != http.StatusOK {
if g.calendarFails > 0 {
g.calendarFails--
if g.calendarFails == 0 {
g.calendarStatus = http.StatusOK
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, status, auth)
return
}
start := 0
if pt := r.URL.Query().Get("pageToken"); pt != "" {
n, err := strconv.Atoi(strings.TrimPrefix(pt, "page-"))
if err != nil || n <= 0 || n > len(g.calendars) {
http.Error(w, "bad pageToken", http.StatusBadRequest)
return
}
start = n
}
end := len(g.calendars)
if g.pageSize > 0 {
end = min(start+g.pageSize, end)
}
items := []map[string]any{}
for _, c := range g.calendars[start:end] {
item := map[string]any{
"kind": "calendar#calendarListEntry",
"etag": `"1700000000000000"`,
"id": c.ID,
"summary": c.Summary,
"description": c.Description,
"timeZone": "America/New_York",
"colorId": "14",
"backgroundColor": "#9fe1e7",
"foregroundColor": "#000000",
"selected": true,
"accessRole": c.AccessRole,
"defaultReminders": []map[string]any{
{"method": "popup", "minutes": 30},
},
"conferenceProperties": map[string]any{"allowedConferenceSolutionTypes": []string{"hangoutsMeet"}},
}
if c.Primary {
item["primary"] = true
}
if c.SummaryOverride != "" {
item["summaryOverride"] = c.SummaryOverride
}
items = append(items, item)
}
page := map[string]any{"kind": "calendar#calendarList", "etag": `"p33k"`, "items": items}
if end < len(g.calendars) {
page["nextPageToken"] = fmt.Sprintf("page-%d", end)
} else {
page["nextSyncToken"] = "sync-token"
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(page); err != nil {
panic(err)
}
}
// insertEvent answers like Google's events.insert with a client-chosen id.
func (g *Google) insertEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
if _, exists := g.events[cal][e.ID]; exists {
apiError(w, http.StatusConflict, "duplicate", "The requested identifier already exists.")
return
}
if g.events[cal] == nil {
g.events[cal] = map[string]Event{}
}
g.events[cal][e.ID] = e
g.inserts++
writeEvent(w, e)
}
// updateEvent answers like Google's events.update.
func (g *Google) updateEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
id := r.PathValue("event")
if _, exists := g.events[cal][id]; !exists {
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID != id {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
g.events[cal][id] = e
g.updates++
writeEvent(w, e)
}
// eventCalendar runs the checks every events request shares: the hold, a
// live grant's access token, a scripted failure, and a listed, writable
// calendar, which it returns. When a check fails it answers and returns
// false. The caller holds g.mu.
func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string, bool) {
g.eventWrites++
h := g.eventHold
g.eventHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
return "", false
}
if g.failWrite != 0 && g.eventWrites >= g.failWrite && g.eventWrites <= g.failLast {
apiError(w, g.failStatus, "backendError", "echo "+auth)
return "", false
}
cal := r.PathValue("calendar")
i := slices.IndexFunc(g.calendars, func(c Calendar) bool { return c.ID == cal })
switch {
case i < 0:
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return "", false
case g.calendars[i].AccessRole != "writer" && g.calendars[i].AccessRole != "owner":
apiError(w, http.StatusForbidden, "requiredAccessLevel", "You need to have writer access to this calendar.")
return "", false
}
return cal, true
}
// writeEvent answers with e as Google does, with fields of its own.
func writeEvent(w http.ResponseWriter, e Event) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"kind": "calendar#event", "etag": `"3300000000000000"`, "id": e.ID, "status": e.Status,
"htmlLink": "https://www.google.com/calendar/event?eid=" + e.ID,
"summary": e.Summary, "description": e.Description, "start": e.Start, "end": e.End,
"iCalUID": e.ID + "@google.com", "sequence": 0,
}); err != nil {
panic(err)
}
}
// apiError answers a Calendar API error with reason and message.
func apiError(w http.ResponseWriter, status int, reason, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"code": status, "message": message,
"errors": []map[string]any{{"domain": "global", "reason": reason, "message": message}},
}}); err != nil {
panic(err)
}
}
// grantLive reports whether any refresh token issued is still live. The
// caller holds g.mu.
func (g *Google) grantLive() bool {
for _, t := range g.issued {
if !g.dead[t] {
return true
}
}
return false
}
func (g *Google) revoke(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
http.Error(w, "want a form", http.StatusUnsupportedMediaType)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
h := g.revokeHold
g.revokeHold = nil
g.wait(h)
token := r.PostForm.Get("token")
g.revoked = append(g.revoked, token)
if g.revokeStatus == http.StatusOK && slices.Contains(g.issued, token) {
// Google revokes the grant, not just the token: every token issued
// to it so far stops working.
for _, t := range g.issued {
g.dead[t] = true
}
}
body := g.revokeBody
if body == "" && g.revokeStatus != http.StatusOK {
body = "cannot revoke " + token
}
w.WriteHeader(g.revokeStatus)
fmt.Fprint(w, body)
}
func oauthError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":%q}`, code)
}
// s256 is the PKCE S256 challenge for verifier.
func s256(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
-179
View File
@@ -1,179 +0,0 @@
package gcal
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// ReconnectReason is the last_sync_error stored when Google refuses the
// stored grant.
const ReconnectReason = "Google access was revoked or expired — reconnect"
const (
// maxRetries is how many times a request Google answers with a
// transient failure (HTTP 429 or 5xx) is sent again.
maxRetries = 2
// firstRetryDelay is the wait before the first retry; each later retry
// waits twice as long as the one before.
firstRetryDelay = 500 * time.Millisecond
)
// The states Status reports.
const (
// StatusNotConnected: no Google account is connected.
StatusNotConnected = "not_connected"
// StatusConnected: a Google account is connected and its grant was last
// accepted.
StatusConnected = "connected"
// StatusReconnectRequired: Google refused the stored grant, so it was
// cleared; only connecting again restores Push to Calendar.
StatusReconnectRequired = "reconnect_required"
)
// Status is the Google Calendar connection as the household sees it. It
// never holds a token.
type Status struct {
State string `json:"status"`
CalendarID *string `json:"calendar_id"`
CalendarName *string `json:"calendar_name"`
ConnectedAt *string `json:"connected_at"`
LastSyncError *string `json:"last_sync_error"`
}
// Status reports the connection: not connected, connected, or reconnect
// required, with the chosen calendar, when it was connected and the last
// sync error. It is one read of one row, so it takes no lock.
func (s *Service) Status(ctx context.Context) (Status, error) {
row, err := s.connection(ctx)
if errors.Is(err, ErrNotConnected) {
return Status{State: StatusNotConnected}, nil
}
if err != nil {
return Status{}, err
}
st := Status{
State: StatusConnected,
CalendarID: nullable(row.CalendarID),
CalendarName: nullable(row.CalendarName),
ConnectedAt: &row.ConnectedAt,
LastSyncError: nullable(row.LastSyncError),
}
if row.RefreshTokenCt == nil {
st.State = StatusReconnectRequired
}
return st, nil
}
func nullable(v sql.NullString) *string {
if !v.Valid {
return nil
}
return &v.String
}
// recordRefusal returns err, first recording, when it is ErrReconnect, that
// Google refuses the stored grant: the refresh token is cleared and
// last_sync_error set to ReconnectReason, so Status reports reconnect
// required and no request offers Google the refused token again.
//
// The chosen calendar and every stored calendar event id are kept, and
// forgetEvents is deliberately not run: the events MealQuest pushed are
// still on the chosen calendar, so a stored calendar_event_id still names
// an event on the chosen calendar. Reconnecting keeps the choice (Complete),
// and the next push updates those events in place. The caller holds
// s.account, which every push and calendar choice also holds, so the
// refusal is recorded between them, never during one.
func (s *Service) recordRefusal(ctx context.Context, err error) error {
if !errors.Is(err, ErrReconnect) {
return err
}
cleared := s.st.WithTx(ctx, func(tx *sql.Tx) error {
n, err := db.New(tx).ClearIntegrationsGoogleToken(ctx, db.ClearIntegrationsGoogleTokenParams{
LastSyncError: sql.NullString{String: ReconnectReason, Valid: true},
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return err
}
if n == 0 {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
return nil
})
if cleared != nil {
return fmt.Errorf("gcal: record that Google refused the grant: %w", cleared)
}
return err
}
// transient reports whether Google's HTTP status is a failure worth
// retrying: rate limited (429) or a server error (5xx).
func transient(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
// backoff waits before retry number attempt (1 for the first), or until ctx
// ends.
func (s *Service) backoff(ctx context.Context, attempt int) error {
t := time.NewTimer(s.retryDelay << (attempt - 1))
defer t.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("gcal: wait to retry: %w", ctx.Err())
case <-t.C:
return nil
}
}
// send sends the request newRequest builds, and sends it again, up to
// maxRetries times, while Google answers it with a transient failure. It
// returns the final answer's status and up to limit bytes of its body. A
// transient failure on the last try is a retryable *CalendarError; what
// names the request in errors, which never hold the URL, a token or
// Google's response body.
func (s *Service) send(ctx context.Context, what string, limit int64, newRequest func() (*http.Request, error)) (int, []byte, error) {
for attempt := 0; ; attempt++ {
if attempt > 0 {
if err := s.backoff(ctx, attempt); err != nil {
return 0, nil, err
}
}
req, err := newRequest()
if err != nil {
return 0, nil, fmt.Errorf("gcal: build %s request: %w", what, err)
}
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
}
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
resp.Body.Close()
if err != nil {
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
}
if !transient(resp.StatusCode) {
return resp.StatusCode, body, nil
}
if attempt == maxRetries {
return 0, nil, &CalendarError{retryable: true,
msg: fmt.Sprintf("gcal: Google could not take the %s (HTTP %d) in %d tries", what, resp.StatusCode, maxRetries+1)}
}
}
}
-336
View File
@@ -1,336 +0,0 @@
package gcal_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
)
// refusals are the ways Google refuses the stored grant: invalid_grant on
// the token refresh, and HTTP 401 from the Calendar API to the access token
// just refreshed.
var refusals = map[string]func(*gcaltest.Google){
"invalid_grant": (*gcaltest.Google).RevokeGrant,
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
}
// calls are the Calendar calls that use the stored grant.
var calls = map[string]func(*harness) error{
"list calendars": func(h *harness) error {
_, err := h.svc.Calendars(context.Background())
return err
},
"choose a calendar": func(h *harness) error {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
return err
},
"push events": func(h *harness) error {
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
return errors.New("record ran after a refused grant")
})
},
}
// TestRefusedGrantRequiresReconnect refuses the grant on every Calendar
// call: the call fails with ErrReconnect, the refresh token is cleared,
// last_sync_error says why, the chosen calendar is kept and Status reports
// reconnect required.
func TestRefusedGrantRequiresReconnect(t *testing.T) {
t.Parallel()
for refusal, refuse := range refusals {
for call, do := range calls {
t.Run(refusal+"/"+call, func(t *testing.T) {
t.Parallel()
h := chosen(t)
refuse(h.google)
h.clock = h.clock.Add(time.Hour)
err := do(h)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("%s = %v, want ErrReconnect", call, err)
}
assertNoSecret(t, "error", err.Error())
row, ok := h.row(t)
switch {
case !ok:
t.Fatal("a refused grant forgot the connection")
case row.RefreshTokenCt != nil:
t.Error("refresh token kept after Google refused it")
case row.LastSyncError.String != "Google access was revoked or expired — reconnect":
t.Errorf("last_sync_error = %v", row.LastSyncError)
case row.CalendarID.String != dinners.ID || row.CalendarName.String != dinners.Summary:
t.Errorf("calendar = %v %v, want %s kept", row.CalendarID, row.CalendarName, dinners.ID)
case row.UpdatedAt != "2026-10-07T19:30:00.000Z":
t.Errorf("updated_at = %s, want the refusal's time", row.UpdatedAt)
}
st, err := h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if st.State != gcal.StatusReconnectRequired || st.LastSyncError == nil || *st.LastSyncError != gcal.ReconnectReason {
t.Errorf("Status = %+v, want reconnect_required with the reason", st)
}
// The cleared grant is never offered to Google again, by
// any call.
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
for again, do := range calls {
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
}
}
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
t.Error("a call reached Google after the grant was refused")
}
})
}
}
}
// TestRefusedGrantBeforeACalendarIsChosen refuses the grant while no
// calendar is chosen, on each call that can record it then (listing and
// choosing): every Calendar call, push included, is ErrReconnect rather than
// ErrNoCalendar, since only connecting again restores the grant. After
// connecting again and choosing a calendar, a push succeeds.
func TestRefusedGrantBeforeACalendarIsChosen(t *testing.T) {
t.Parallel()
for refusal, refuse := range refusals {
for _, call := range []string{"list calendars", "choose a calendar"} {
t.Run(refusal+"/"+call, func(t *testing.T) {
t.Parallel()
h := connected(t)
refuse(h.google)
if err := calls[call](h); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("%s = %v, want ErrReconnect", call, err)
}
if row, _ := h.row(t); row.RefreshTokenCt != nil || row.CalendarID.Valid {
t.Fatalf("row = %+v, want the grant cleared and no calendar chosen", row)
}
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
for again, do := range calls {
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
}
}
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
t.Error("a call reached Google after the grant was refused")
}
h.google.AcceptAccessToken()
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatalf("SelectCalendar after connecting again: %v", err)
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Errorf("PushEvents after connecting again: %v", err)
}
})
}
}
}
// TestReconnectAfterRefusal connects again after Google refused the grant:
// the new token is stored, the reason cleared and the calendar kept.
func TestReconnectAfterRefusal(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.RevokeGrant()
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
st, err := h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if st.State != gcal.StatusConnected || st.LastSyncError != nil || st.CalendarID == nil || *st.CalendarID != dinners.ID {
t.Errorf("Status = %+v, want connected to %s with no error", st, dinners.ID)
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Errorf("PushEvents after connecting again: %v", err)
}
}
// TestDisconnectAfterRefusal disconnects a connection whose grant Google
// refused: there is nothing to revoke, and the connection is deleted.
func TestDisconnectAfterRefusal(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.RevokeGrant()
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
if err := h.svc.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if n := len(h.google.Revoked()); n != 0 {
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
}
if _, ok := h.row(t); ok {
t.Error("connection kept after disconnecting")
}
}
// transients are the failures Google asks clients to retry.
var transients = []int{http.StatusServiceUnavailable, http.StatusInternalServerError, http.StatusTooManyRequests}
// TestTransientFailureIsRetried fails each Google request three times in a
// row: it is tried three times (two retries), then fails as retryable, and
// nothing is disconnected or cleared.
func TestTransientFailureIsRetried(t *testing.T) {
t.Parallel()
for _, status := range transients {
for name, tc := range map[string]struct {
fail func(*gcaltest.Google)
do func(*harness) error
tries func(*gcaltest.Google) int
}{
"token refresh": {
fail: func(g *gcaltest.Google) { g.FailToken(status) },
do: calls["list calendars"],
tries: (*gcaltest.Google).TokenCalls,
},
"calendar list": {
fail: func(g *gcaltest.Google) { g.FailCalendarList(status) },
do: calls["choose a calendar"],
tries: (*gcaltest.Google).CalendarCalls,
},
"event write": {
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 3, status) },
do: calls["push events"],
tries: (*gcaltest.Google).EventRequests,
},
} {
t.Run(name+"/"+http.StatusText(status), func(t *testing.T) {
t.Parallel()
h := chosen(t)
before, _ := h.row(t)
tried := tc.tries(h.google)
tc.fail(h.google)
err := tc.do(h)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) || !calendar.Retryable() {
t.Fatalf("err = %v, want a retryable *CalendarError", err)
}
assertNoSecret(t, "error", err.Error())
if n := tc.tries(h.google) - tried; n != 3 {
t.Errorf("tried %d times, want 3", n)
}
after, ok := h.row(t)
if !ok || !reflect.DeepEqual(before, after) {
t.Errorf("connection = %+v, %v; want it unchanged %+v", after, ok, before)
}
if st, err := h.svc.Status(context.Background()); err != nil || st.State != gcal.StatusConnected {
t.Errorf("Status = %+v, %v; want connected", st, err)
}
})
}
}
}
// TestTransientFailureRecovers fails each Google request twice: the second
// retry succeeds.
func TestTransientFailureRecovers(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
fail func(*gcaltest.Google)
do func(*harness) error
}{
"calendar list": {
fail: func(g *gcaltest.Google) { g.FailCalendarListNext(2, http.StatusServiceUnavailable) },
do: calls["choose a calendar"],
},
"event write": {
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 2, http.StatusServiceUnavailable) },
do: func(h *harness) error {
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
},
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := chosen(t)
tc.fail(h.google)
if err := tc.do(h); err != nil {
t.Fatalf("err = %v, want the second retry to succeed", err)
}
})
}
}
// TestTransientFailureBacksOff checks the waits between tries: the first
// retry waits the retry delay, the second twice that.
func TestTransientFailureBacksOff(t *testing.T) {
t.Parallel()
h := chosen(t)
gcal.SetRetryDelay(h.svc, 50*time.Millisecond)
h.google.FailCalendarList(http.StatusServiceUnavailable)
start := time.Now()
if _, err := h.svc.Calendars(context.Background()); err == nil {
t.Fatal("Calendars succeeded")
}
if took := time.Since(start); took < 150*time.Millisecond {
t.Errorf("three tries took %s, want at least 150ms of waits", took)
}
}
func TestStatus(t *testing.T) {
t.Parallel()
h := newHarness(t)
st, err := h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if got := statusJSON(t, st); got != `{"status":"not_connected","calendar_id":null,"calendar_name":null,"connected_at":null,"last_sync_error":null}` {
t.Errorf("not connected: Status = %s", got)
}
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
st, err = h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if got := statusJSON(t, st); got != `{"status":"connected","calendar_id":null,"calendar_name":null,"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":null}` {
t.Errorf("connected: Status = %s", got)
}
h.google.SetCalendars(0, dinners)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
h.google.RevokeGrant()
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
st, err = h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
want := `{"status":"reconnect_required","calendar_id":"dinners@group.calendar.google.com","calendar_name":"Dinners",` +
`"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":"Google access was revoked or expired — reconnect"}`
if got := statusJSON(t, st); got != want {
t.Errorf("reconnect required: Status =\n%s\nwant\n%s", got, want)
}
}
func statusJSON(t *testing.T, st gcal.Status) string {
t.Helper()
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
return string(b)
}
-6
View File
@@ -1,6 +0,0 @@
package home
import "time"
// SetClock makes now the clock s reads "now" and "tonight" on.
func SetClock(s *Service, now func() time.Time) { s.now = now }
-253
View File
@@ -1,253 +0,0 @@
// Package home reads the Home screen: tonight's dinner, the Side quests to
// swipe, the Supply run and whether there are recent updates. It owns no
// data: every value is read through the service that owns it, and "now"
// and "tonight" are in the household's zone, never UTC.
package home
import (
"context"
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// Service reads the Home screen.
type Service struct {
settings *household.SettingsService
plans *plan.Service
suggest *suggest.Service
shopping *shopping.Service
notifier *notify.Service
// now is the clock "now" and "tonight" are read on; tests replace it.
now func() time.Time
}
// NewService returns a service reading through the given services.
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
switch {
case settings == nil:
return nil, errors.New("home: nil household settings service")
case plans == nil:
return nil, errors.New("home: nil plan service")
case suggestSvc == nil:
return nil, errors.New("home: nil suggestions service")
case shop == nil:
return nil, errors.New("home: nil shopping service")
case notifier == nil:
return nil, errors.New("home: nil notifier")
}
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
}
// Home is the Home screen as the API shows it.
type Home struct {
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
DateLabel string `json:"dateLabel"`
// Timezone is the household's IANA zone, whose next local midnight
// is when Home is read again for the new day.
Timezone string `json:"timezone"`
// Tonight is the dinner planned for today on the current week's plan;
// nil when the week has no plan.
Tonight *Dinner `json:"tonight"`
// SideQuestCount is how many Side quests are pending on the deck.
SideQuestCount int `json:"sideQuestCount"`
// ShoppingList is the Supply run of the upcoming week when it has one,
// else of the current week.
ShoppingList ShoppingStatus `json:"shoppingList"`
RecentUpdates RecentUpdates `json:"recentUpdates"`
}
// Dinner is tonight's dinner.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
// Origin is regular or side_quest.
Origin string `json:"origin"`
MinutesTotal *int64 `json:"minutes_total"`
}
// ShoppingStatus is whether a week's Supply run is ready.
type ShoppingStatus struct {
// WeekStart is the Monday of the week, YYYY-MM-DD.
WeekStart string `json:"weekStart"`
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
Label string `json:"label"`
// Ready is true when the week has a Supply run.
Ready bool `json:"ready"`
// SentToChatAt is when the Supply run was sent to Google Chat; nil
// until it is, and when there is no Supply run.
SentToChatAt *string `json:"sentToChatAt"`
}
// RecentUpdates says whether there is something new for the bell.
type RecentUpdates struct {
// Unread is true when a notification was stored after the household
// last saw its updates (household_settings.updates_seen_at), or at all
// when it never has.
Unread bool `json:"unread"`
}
// Get reads the Home screen at the service's "now". It only reads: it
// writes nothing, not even the batch bookkeeping a Deck read does.
func (s *Service) Get(ctx context.Context) (Home, error) {
settings, err := s.settings.Get(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Home{}, fmt.Errorf("home: household settings: %w", err)
}
now := s.now().In(zone)
today := household.Today(now, zone)
thisWeek := household.WeekStart(today)
tonight, err := s.tonight(ctx, thisWeek, today)
if err != nil {
return Home{}, err
}
sideQuests, err := s.suggest.PendingCount(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read deck: %w", err)
}
list, err := s.shoppingStatus(ctx, thisWeek)
if err != nil {
return Home{}, err
}
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
if err != nil {
return Home{}, err
}
return Home{
DateLabel: now.Format("Monday, Jan 2"),
Timezone: settings.Timezone,
Tonight: tonight,
SideQuestCount: sideQuests,
ShoppingList: list,
RecentUpdates: RecentUpdates{Unread: unread},
}, nil
}
// tonight returns the dinner planned on today in the plan of the week
// starting thisWeek, or nil when the week has no plan.
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
p, err := s.plans.Get(ctx, thisWeek)
if errors.Is(err, plan.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("home: read plan: %w", err)
}
for _, d := range p.Days {
if d.Date == today.String() {
return &Dinner{
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
}, nil
}
}
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
}
// shoppingStatus returns the status of the upcoming week's Supply run when
// it has one, else of the current week's, ready or not.
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
y, m, d := thisWeek.Date()
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
l, err := s.shopping.Get(ctx, week)
if errors.Is(err, shopping.ErrListNotFound) {
continue
}
if err != nil {
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
}
return weekStatus(week, true, l.SentToChatAt)
}
return weekStatus(thisWeek, false, nil)
}
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
label, err := household.WeekLabel(week)
if err != nil {
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
}
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
}
// unread reports whether a notification was stored after seenAt, the
// stored updates_seen_at, or at all when it is nil.
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
var since *time.Time
if seenAt != nil {
var ts store.Timestamp
if err := ts.Scan(*seenAt); err != nil {
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
}
since = &ts.Time
}
unread, err := s.notifier.StoredSince(ctx, since)
if err != nil {
return false, fmt.Errorf("home: %w", err)
}
return unread, nil
}
// RecentLimit is how many events the Recent updates sheet lists.
const RecentLimit = 10
// Update is one event in the Recent updates sheet.
type Update struct {
// Type is the event type, e.g. plan_ready.
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
// URL is the path-absolute, same-origin path a tap on the update opens.
URL string `json:"url"`
// CreatedAt is when the event was stored, UTC RFC 3339 with
// milliseconds; it is what POST /updates/seen takes back.
CreatedAt string `json:"created_at"`
}
// Updates returns the last RecentLimit distinct notification events, newest
// first, whatever channels they went to.
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
events, err := s.notifier.Recent(ctx, RecentLimit)
if err != nil {
return nil, fmt.Errorf("home: %w", err)
}
out := make([]Update, len(events))
for i, ev := range events {
out[i] = Update{
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
}
}
return out, nil
}
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
// MarkUpdatesSeen records that the household has seen its updates through
// through, the created_at of the newest update it was shown, so
// recentUpdates.unread is false until an event is stored after it. A time
// before the one already recorded changes nothing; one after now is
// ErrSeenInFuture, since it would hide events not yet stored.
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
if through.After(s.now()) {
return ErrSeenInFuture
}
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
return fmt.Errorf("home: %w", err)
}
return nil
}
-598
View File
@@ -1,598 +0,0 @@
package home_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"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/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// zone is the household's zone in these tests: not UTC, so a late local
// evening is already the next day in UTC.
const zone = "America/Chicago"
// weekDates is the week of 2026-10-12, Monday first.
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
type env struct {
svc *home.Service
st *store.Store
settings *household.SettingsService
notifier *notify.Service
loc *time.Location
}
// newEnv returns a home service over a migrated temp database whose
// household is in zone, reading "now" from a clock set to at.
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, zone); 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)
}
notifier := notifytest.New(t, st)
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
if err != nil {
t.Fatal(err)
}
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
googleSvc, err := gcal.NewService(st, box, gcal.Config{
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
CalendarAPI: google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
if err != nil {
t.Fatal(err)
}
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
if err != nil {
t.Fatal(err)
}
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no model in this test")
}
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
settings := household.NewSettingsService(st)
svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier)
if err != nil {
t.Fatal(err)
}
loc, err := time.LoadLocation(zone)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc}
}
// at reads the Home screen with the clock at the household-local wall
// time "YYYY-MM-DD HH:MM".
func (e *env) at(t *testing.T, local string) home.Home {
t.Helper()
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
if err != nil {
t.Fatal(err)
}
home.SetClock(e.svc, func() time.Time { return now })
h, err := e.svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
return h
}
// dinner stores a dinner and returns its id.
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
t.Helper()
now := store.Now().String()
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,
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
// unknown minutes. It returns the plan's id.
func (e *env) planWeek(t *testing.T) string {
t.Helper()
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for _, date := range weekDates {
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
if date == "2026-10-14" {
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
}
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 planID
}
// Tonight's dinner and the date label follow the household's local
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
// Wednesday in UTC, and Home still shows Tuesday's dinner.
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
for _, tc := range []struct {
local, label, dinner string
}{
// 23:59 CDT is 04:59 UTC the next day.
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
} {
h := e.at(t, tc.local)
if h.DateLabel != tc.label {
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
}
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
}
}
// Late Sunday evening of the week before, the plan's week has not
// begun locally, though in UTC it has.
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
}
}
// Tonight carries the dinner's id, name, photo, origin and minutes.
func TestTonightDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
h := e.at(t, "2026-10-13 18:00")
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
}
h = e.at(t, "2026-10-14 18:00")
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
}
}
// With no plan, no suggestions, no Supply run and no notifications, Home
// says so: no dinner tonight, nothing to swipe, the current week's Supply
// run not ready and nothing unread.
func TestHomeEmpty(t *testing.T) {
t.Parallel()
e := newEnv(t)
h := e.at(t, "2026-10-13 23:59")
want := home.Home{
DateLabel: "Tuesday, Oct 13",
Timezone: zone,
ShoppingList: home.ShoppingStatus{
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
},
}
if h != want {
t.Errorf("home = %+v, want %+v", h, want)
}
}
// The Side quest count is the pending cards of the deck: a decided
// suggestion is not counted.
func TestSideQuestCount(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
t.Fatal(err)
}
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
id := e.dinner(t, name, "suggested", "side_quest", 0)
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
t.Fatal(err)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
}
}
// Reading Home writes nothing: a batch still pending whose job is gone,
// which a Deck read would fail, is left as it was, and the read succeeds.
func TestGetDoesNotWriteBatches(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
created := "2026-10-13T12:00:00.000Z"
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
if err != nil {
t.Fatal(err)
}
if batch.Status != "pending" {
t.Fatalf("new batch status = %q, want pending", batch.Status)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
}
var status, updatedAt string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "pending" || updatedAt != created {
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
}
}
// The Supply run shown is the upcoming week's when it has one, else the
// current week's; the weeks are the household's local weeks.
func TestShoppingStatus(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
planID := e.planWeek(t)
list := func(weekStart string, sent sql.NullString) {
t.Helper()
now := store.Now().String()
if _, err := e.st.Writer.ExecContext(ctx,
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
store.NewID(), weekStart, planID, sent, now, now); err != nil {
t.Fatal(err)
}
}
list("2026-10-12", sql.NullString{})
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
}
sent := "2026-10-17T13:00:00.000Z"
list("2026-10-19", sql.NullString{String: sent, Valid: true})
h := e.at(t, "2026-10-16 09:00")
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
}
// Late Sunday evening locally is Monday in UTC: the current week is
// still the week of 2026-10-12, so its upcoming week's list shows.
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
}
// On Monday the week of 2026-10-19 is current and has no upcoming
// list.
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
}
// A week later neither the current nor the upcoming week has a list.
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
}
}
// Recent updates are unread when a notification was stored after the
// household last saw its updates, or ever when it never has.
func TestRecentUpdatesUnread(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
t.Error("unread with no notifications")
}
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
t.Fatal(err)
}
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
t.Error("not unread with a notification never seen")
}
seen := func(at time.Time) {
t.Helper()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
t.Fatal(err)
}
}
seen(time.Now().Add(time.Hour))
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
t.Error("unread with every notification seen")
}
seen(time.Now().Add(-time.Hour))
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
t.Error("not unread with a notification stored after updates were seen")
}
}
func TestNewServiceRequiresServices(t *testing.T) {
t.Parallel()
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
t.Fatal("NewService accepted nil services")
}
}
// store stores event key of type typ in the outbox at instant at, for each
// of channels, as notify stores an event.
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
if err != nil {
t.Fatal(err)
}
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
for _, ch := range channels {
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
}); err != nil {
t.Fatal(err)
}
}
}
// Updates are the last ten distinct events, newest first: an event stored
// for push and Google Chat is listed once, whatever became of its delivery.
func TestUpdates(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if got == nil || len(got) != 0 {
t.Errorf("updates with no notifications = %#v, want an empty list", got)
}
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
for i := range 12 {
key := fmt.Sprintf("plan_ready:%02d", i)
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
}
// A delivered and a failed row of one event are still one event.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
t.Fatal(err)
}
got, err = e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != home.RecentLimit {
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
}
for i, u := range got {
n := 11 - i
key := fmt.Sprintf("plan_ready:%02d", n)
want := home.Update{
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
}
if u != want {
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
}
}
}
// An event emitted to push and Google Chat is one update.
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
t.Fatal(err)
}
var rows int
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 2 {
t.Fatalf("outbox rows = %d, want push and chat", rows)
}
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
t.Errorf("updates = %+v, want the one plan_ready event", got)
}
}
// Marking updates seen through the newest clears unread until a newer event
// is stored; marking them seen through an older time does not move the
// mark back, and a time after now is refused.
func TestMarkUpdatesSeen(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
unread := func() bool {
t.Helper()
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
}
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
if !unread() {
t.Fatal("not unread with an event never seen")
}
// The settings were last edited well before: marking updates seen must
// keep that time and tell no settings subscriber.
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
t.Fatal(err)
}
var notified int
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
defer unsubscribe()
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newest event seen")
}
second := first.Add(time.Millisecond)
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
if !unread() {
t.Error("not unread with an event stored after the mark")
}
// Seen again through the older event, as a sheet opened before the
// newer one arrived would: nothing moves, so it stays unread.
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if !unread() {
t.Error("an older mark hid the newer event")
}
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newer event seen")
}
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
settings, err := e.settings.Get(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
}
if settings.UpdatedBy != nil {
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
}
if settings.UpdatedAt != edited {
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
}
if notified != 0 {
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
}
// The clock reads 17:00 UTC.
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
}
}
// An event stored again for a channel switched on later is as new as that
// row: the update shows its time, and marking it seen clears unread.
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
later := first.Add(30 * time.Minute)
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
t.Fatalf("updates = %+v, want the one event at %s", got, want)
}
e.at(t, "2026-10-13 12:00")
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
t.Fatal(err)
}
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
t.Error("unread after the event was marked seen through its listed time")
}
}
-158
View File
@@ -1,158 +0,0 @@
package household
import (
"context"
"database/sql"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// NotificationPref is which channels one event's notifications go to.
type NotificationPref struct {
Event string `json:"event"`
Push bool `json:"push"`
Chat bool `json:"chat"`
}
// NotificationSettings are the household's notification preferences: one
// NotificationPref for every event, in NotificationEvents order, and the
// day-of dinner reminder's time, which is the household settings'
// reminder_time.
type NotificationSettings struct {
Prefs []NotificationPref `json:"prefs"`
ReminderTime string `json:"reminder_time"`
}
// NotificationEvents are the events notification_prefs holds a row for, in
// the order the Notifications screen lists them.
var NotificationEvents = func() []string {
events := make([]string, len(seedNotificationPrefs))
for i, p := range seedNotificationPrefs {
events[i] = p.Event
}
return events
}()
// Notifications returns the notification preferences. A missing event row is
// an error: SeedNotificationPrefs creates every one at boot.
func (s *SettingsService) Notifications(ctx context.Context) (NotificationSettings, error) {
return readNotifications(ctx, db.New(s.st.Reader))
}
// UpdateNotifications validates ns, which must name every event of
// NotificationEvents exactly once and a 24-hour HH:MM reminder time, and
// replaces the notification preferences and reminder_time with it in one
// transaction, recording updatedBy (which must not be empty). The new
// household settings are published to every subscriber, as Update does. An
// invalid ns is a *ValidationError and changes nothing.
func (s *SettingsService) UpdateNotifications(ctx context.Context, ns NotificationSettings, updatedBy string) (NotificationSettings, error) {
if updatedBy == "" {
return NotificationSettings{}, errors.New("household: update notifications: no member to record as updated_by")
}
if err := ns.validate(); err != nil {
return NotificationSettings{}, err
}
s.updateMu.Lock()
defer s.updateMu.Unlock()
var (
row db.HouseholdSetting
out NotificationSettings
)
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
now := store.Now().String()
for _, p := range ns.Prefs {
n, err := q.UpdateNotificationPref(ctx, db.UpdateNotificationPrefParams{
Push: boolInt(p.Push), Chat: boolInt(p.Chat), UpdatedAt: now, Event: p.Event,
})
if err != nil {
return fmt.Errorf("household: update notification preference %s: %w", p.Event, err)
}
if n != 1 {
return fmt.Errorf("household: no notification preference for %s", p.Event)
}
}
var err error
row, err = applyPatch(ctx, q, SettingsPatch{ReminderTime: &ns.ReminderTime}, updatedBy)
if err != nil {
return err
}
out, err = readNotifications(ctx, q)
return err
})
if err != nil {
return NotificationSettings{}, err
}
s.publish(fromRow(row))
return out, nil
}
func readNotifications(ctx context.Context, q *db.Queries) (NotificationSettings, error) {
rows, err := q.ListNotificationPrefs(ctx)
if err != nil {
return NotificationSettings{}, fmt.Errorf("household: read notification preferences: %w", err)
}
byEvent := make(map[string]db.NotificationPref, len(rows))
for _, r := range rows {
byEvent[r.Event] = r
}
prefs := make([]NotificationPref, len(NotificationEvents))
for i, event := range NotificationEvents {
r, ok := byEvent[event]
if !ok {
return NotificationSettings{}, fmt.Errorf("household: no notification preference for %s", event)
}
prefs[i] = NotificationPref{Event: event, Push: r.Push == 1, Chat: r.Chat == 1}
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return NotificationSettings{}, fmt.Errorf("household: read settings: %w", err)
}
return NotificationSettings{Prefs: prefs, ReminderTime: settings.ReminderTime}, nil
}
func (ns NotificationSettings) validate() error {
var problems []Problem
fail := func(field, reason string) {
problems = append(problems, Problem{Field: field, Reason: reason})
}
known := make(map[string]bool, len(NotificationEvents))
for _, event := range NotificationEvents {
known[event] = true
}
seen := make(map[string]bool, len(ns.Prefs))
for _, p := range ns.Prefs {
switch {
case !known[p.Event]:
fail("prefs", fmt.Sprintf("unknown event %q", p.Event))
case seen[p.Event]:
fail("prefs", fmt.Sprintf("event %q is listed more than once", p.Event))
}
seen[p.Event] = true
}
for _, event := range NotificationEvents {
if !seen[event] {
fail("prefs", fmt.Sprintf("event %q is missing", event))
}
}
if !clockTime.MatchString(ns.ReminderTime) {
fail("reminder_time", fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", ns.ReminderTime))
}
if len(problems) > 0 {
return &ValidationError{Problems: problems}
}
return nil
}
func boolInt(b bool) int64 {
if b {
return 1
}
return 0
}
+51 -74
View File
@@ -8,7 +8,6 @@ import (
"regexp"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -188,9 +187,56 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
var row db.HouseholdSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
var err error
row, err = applyPatch(ctx, db.New(tx), patch, updatedBy)
return err
q := db.New(tx)
cur, err := q.GetHouseholdSettings(ctx)
if err != nil {
return fmt.Errorf("household: read settings: %w", err)
}
p := db.UpsertHouseholdSettingsParams{
Timezone: cur.Timezone,
DinnerTime: cur.DinnerTime,
ReminderTime: cur.ReminderTime,
ReviewTime: cur.ReviewTime,
PlanDraftDow: cur.PlanDraftDow,
PlanDraftTime: cur.PlanDraftTime,
SuggestionsDow: cur.SuggestionsDow,
SuggestionsTime: cur.SuggestionsTime,
ChatAutosendDow: cur.ChatAutosendDow,
ChatAutosendTime: cur.ChatAutosendTime,
OnboardingCompletedAt: cur.OnboardingCompletedAt,
UpdatesSeenAt: cur.UpdatesSeenAt,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: cur.CreatedAt,
UpdatedAt: store.Now().String(),
}
setString(&p.Timezone, patch.Timezone)
setString(&p.DinnerTime, patch.DinnerTime)
setString(&p.ReminderTime, patch.ReminderTime)
setString(&p.ReviewTime, patch.ReviewTime)
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
setString(&p.PlanDraftTime, patch.PlanDraftTime)
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
n, err := q.CountRegularDinners(ctx)
if err != nil {
return fmt.Errorf("household: count Regulars: %w", err)
}
if n < MinRegulars {
return &ValidationError{Problems: []Problem{{
Field: "onboarding_completed",
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
}}}
}
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
}
row, err = q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return fmt.Errorf("household: update settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
@@ -201,77 +247,8 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
return settings, nil
}
// MarkUpdatesSeen records that the household has seen its Recent updates
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
// is when it is already at or after it. It is not a settings edit, so
// updated_by and updated_at are kept and subscribers are not told.
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
if seenAt.IsZero() {
return errors.New("household: mark updates seen: zero time")
}
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
if err != nil {
return fmt.Errorf("household: mark updates seen: %w", err)
}
return nil
}
// applyPatch applies a validated patch to the household settings row in q's
// transaction, recording updatedBy, and returns the new row.
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
cur, err := q.GetHouseholdSettings(ctx)
if err != nil {
return db.HouseholdSetting{}, fmt.Errorf("household: read settings: %w", err)
}
p := db.UpsertHouseholdSettingsParams{
Timezone: cur.Timezone,
DinnerTime: cur.DinnerTime,
ReminderTime: cur.ReminderTime,
ReviewTime: cur.ReviewTime,
PlanDraftDow: cur.PlanDraftDow,
PlanDraftTime: cur.PlanDraftTime,
SuggestionsDow: cur.SuggestionsDow,
SuggestionsTime: cur.SuggestionsTime,
ChatAutosendDow: cur.ChatAutosendDow,
ChatAutosendTime: cur.ChatAutosendTime,
OnboardingCompletedAt: cur.OnboardingCompletedAt,
UpdatesSeenAt: cur.UpdatesSeenAt,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: cur.CreatedAt,
UpdatedAt: store.Now().String(),
}
setString(&p.Timezone, patch.Timezone)
setString(&p.DinnerTime, patch.DinnerTime)
setString(&p.ReminderTime, patch.ReminderTime)
setString(&p.ReviewTime, patch.ReviewTime)
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
setString(&p.PlanDraftTime, patch.PlanDraftTime)
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
n, err := q.CountRegularDinners(ctx)
if err != nil {
return db.HouseholdSetting{}, fmt.Errorf("household: count Regulars: %w", err)
}
if n < MinRegulars {
return db.HouseholdSetting{}, &ValidationError{Problems: []Problem{{
Field: "onboarding_completed",
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
}}}
}
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
}
row, err := q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return db.HouseholdSetting{}, fmt.Errorf("household: update settings: %w", err)
}
return row, nil
}
// Subscribe registers fn to receive the new settings after every successful
// Update and UpdateNotifications. fn runs synchronously on the updating goroutine, so it must return
// Update. fn runs synchronously on the updating goroutine, so it must return
// promptly and must not call Update. The returned function unsubscribes fn.
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
s.subsMu.Lock()
-747
View File
@@ -1,747 +0,0 @@
// Package ideas finds dinner ideas for Cook from what I have and Quick
// dinners: from a list of ingredients on hand, ideas_from_ingredients
// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each
// is grounded with the recipe grounding pipeline (dropping those no recipe
// grounds, and Quick dinners whose grounded recipe is not of the kind), and
// the ideas kept are reported as a job's result, which fills as each idea
// grounds. A job that keeps no idea, or fails, shows no idea: nothing is
// made up instead (D-017). Ideas are not stored as dinners: the household
// acts on one later.
package ideas
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"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/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Job kinds.
const (
// KindFromIngredients is the job that finds ideas from ingredients.
KindFromIngredients = "ideas_from_ingredients"
// KindQuick is the job that finds a page of Quick dinner ideas.
KindQuick = "quick_ideas"
)
// ContextBudget is the character budget of the household context
// quick_ideas is given (see harness.BuildContext).
const ContextBudget = 16000
// QuickMinutes is the most minutes from prep to table of a Quick dinner
// not cooked in a crock pot.
const QuickMinutes = 30
// Job stages, as the job's result reports them.
const (
StageSuggesting = "suggesting"
StageGrounding = "grounding"
StageDone = "done"
)
// Reasons the job failed, stored as its failure code. Any other failure,
// such as the database's, is the runner's internal_error.
const (
// ReasonAIFailed: ideas_from_ingredients or quick_ideas, or
// normalize_recipe while grounding, failed; no idea is made up instead
// (D-017).
ReasonAIFailed = "ai_failed"
// ReasonSearchNotConfigured: recipe search is not set up.
ReasonSearchNotConfigured = "search_not_configured"
// ReasonSearchFailed: the search provider failed.
ReasonSearchFailed = "search_failed"
// ReasonNoIdeasFound: every idea was dropped, by the skill's rules,
// because no recipe grounds it, or because its grounded recipe is not
// a Quick dinner (a Quick only request) or not of the kind asked for (a
// Quick dinners request).
ReasonNoIdeasFound = "no_ideas_found"
)
// Reasons are every failure reason of both jobs.
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound}
// InvalidError is a request the household must correct, such as an empty
// ingredient list or an unknown kind of Quick dinner.
type InvalidError struct {
Message string
}
func (e *InvalidError) Error() string { return e.Message }
// Generator runs ideas_from_ingredients on in; scope is
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
// with the household's harness.Config.
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in).
// In production it is skills.QuickIdeas with the household's
// harness.Config.
type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error)
// Service starts and runs idea jobs.
type Service struct {
st *store.Store
grounder *recipes.Grounder
runner *jobs.Runner
generate Generator
quick QuickGenerator
logger *slog.Logger
job *jobs.Kind[FromIngredientsJob]
quickJob *jobs.Kind[QuickJob]
}
// NewService returns a service that reads the household context from st,
// finds ideas with generate and Quick dinner ideas with quick, grounds
// them with grounder and registers the KindFromIngredients and KindQuick
// jobs on runner, which must not have started yet. The jobs are started
// only by StartFromIngredients and StartQuick, which check the request
// first.
func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("ideas: nil store")
case grounder == nil:
return nil, errors.New("ideas: nil grounder")
case generate == nil:
return nil, errors.New("ideas: nil generator")
case quick == nil:
return nil, errors.New("ideas: nil quick generator")
case runner == nil:
return nil, errors.New("ideas: nil job runner")
case logger == nil:
return nil, errors.New("ideas: nil logger")
}
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
var err error
if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil {
return nil, err
}
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
return nil, err
}
return s, nil
}
// register registers the service-only job name, run by run, whose jobs
// show their result through show.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
kind, err := jobs.Register(runner, name, run, Reasons...)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
if err := kind.ShownResult(show); err != nil {
return nil, err
}
return kind, nil
}
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
// on hand, as skills.CleanIngredients returns them, and whether only
// Quick dinners are wanted.
type FromIngredientsJob struct {
Ingredients []string `json:"ingredients"`
QuickOnly bool `json:"quick_only"`
}
// Started is the job a request started.
type Started struct {
JobID string `json:"jobId"`
}
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
// and returns its id. An ingredient list skills.CleanIngredients refuses
// is an *InvalidError, and starts nothing.
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
ings, err := skills.CleanIngredients(ingredients)
if err != nil {
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
}
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
if err != nil {
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
}
return Started{JobID: id}, nil
}
// Idea is a grounded idea as the job's result shows it. Method,
// MinutesTotal and Difficulty are the grounded recipe's, never the
// model's estimates.
type Idea struct {
Name string `json:"name"`
// Uses are the household's ingredients the idea uses, as the household
// wrote them and in its order.
Uses []string `json:"uses"`
// Missing are the ingredients the idea needs that the household does
// not have.
Missing []string `json:"missing"`
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
// missing: 1 lime" (see skills.IdeaSummary).
Summary string `json:"summary"`
// Method is stovetop, oven, crockpot, grill or other.
Method string `json:"method"`
// MinutesTotal is prep to table, null when the recipe does not say.
MinutesTotal *int `json:"minutes_total"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Quick says whether the idea is a Quick dinner (see IsQuick).
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
// SourceURL and SiteName are the grounded recipe's source as
// dinners.SourceOf gives it: each null when it could leak a secret.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// Result is the KindFromIngredients result, published as the job goes:
// its stage, the request, the ideas grounded so far in the model's order
// (an idea that grounds sooner than one before it is shown first, and the
// earlier one is put back in its place when it grounds),
// and how many ideas were dropped, by ideas_from_ingredients' rules,
// because no recipe grounds them, or, for a Quick only request, because
// the grounded recipe is not a Quick dinner.
type Result struct {
Stage string `json:"stage"`
Ingredients []string `json:"ingredients"`
QuickOnly bool `json:"quick_only"`
Ideas []Idea `json:"ideas"`
Dropped int `json:"dropped"`
}
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// IsQuick reports whether a recipe cooked by method in minutes (nil when
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
// from prep to table.
func IsQuick(method string, minutes *int) bool {
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
}
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
// skills.IdeasRequested ideas, then grounds them in the model's order, at
// most the number still wanted at a time, until skills.IdeasKept are kept
// or none are left. An idea no recipe grounds is dropped, and so, for a
// Quick only request, is one whose grounded recipe is not a Quick dinner;
// when every idea is dropped the job fails with ReasonNoIdeasFound. Any
// other grounding failure fails the job, as does ideas_from_ingredients
// failing, with no idea made up instead. The result is published at each
// stage and again as each idea is kept, so it fills as the ideas ground.
// Before the job fails, its result is published again with no ideas (see
// finish).
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res, err := s.fill(ctx, p, publish)
return finish(ctx, s.logger, "ideas from ingredients", res, err, publish)
}
// shownResult is a job result that shows ideas.
type shownResult[R any] interface {
jobs.Public
// shown is how many ideas the result shows.
shown() int
// cleared is the result showing no ideas.
cleared() R
// withSafeSources is the result with each idea's source as
// shownSource gives it.
withSafeSources() (R, error)
}
func (r Result) shown() int { return len(r.Ideas) }
func (r Result) cleared() Result {
r.Ideas = []Idea{}
return r
}
func (r Result) withSafeSources() (Result, error) {
ideas := make([]Idea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// finish ends a job whose work returned res and err. A job that failed
// has its result published again with no ideas, to tidy the stored
// result. That write is not what keeps a failed job from showing ideas,
// since it may fail and a job interrupted by restart never makes it:
// shownJobResult, applied whenever a failed job is read, is.
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
var zero R
if err == nil {
return res, nil
}
if ctx.Err() != nil {
return zero, err
}
var failure *jobs.Failure
if errors.As(err, &failure) {
// Only the code is logged: the error may quote a model's or
// search provider's response.
logger.InfoContext(ctx, what+" failed", "reason", failure.Code)
}
if res.shown() > 0 {
if perr := publish(res.cleared()); perr != nil {
return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
}
}
return zero, err
}
// fill does run's work, returning a *jobs.Failure for a failure the
// household may see. On failure it returns the result last published with
// the error, so run can clear its ideas.
func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}}
if err := publish(res); err != nil {
return res, err
}
in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly}
out, err := s.generate(ctx, skills.IdeasScope(in), in)
if err != nil {
if ctx.Err() != nil {
return res, ctx.Err()
}
return res, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return res, err
}
reqs := make([]recipes.GroundRequest, len(out.Ideas))
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool, error) {
idea, err := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick, err
}, func() error { return publish(res) })
if err != nil {
return res, err
}
if len(res.Ideas) == 0 {
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
}
res.Stage = StageDone
return res, nil
}
// keepGrounded grounds reqs with g in their order, at most the number
// still wanted at a time, until want ideas are shown or none are left. As
// each grounds, keep makes its idea from request i's grounding and says
// whether it is kept: a kept idea is put in *shown in its place in reqs'
// order (an idea that grounds sooner than one before it is shown first,
// and the earlier one is put back in its place when it grounds) and
// publish is called. An idea no recipe grounds, or that keep refuses,
// counts in *dropped. Any other grounding failure, or keep or publish
// failing, is returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool, error), publish func() error) error {
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
// its place in reqs' order as it grounds.
var order []int
for next := 0; len(*shown) < want && next < len(reqs); {
first := next
round := reqs[first:min(first+want-len(*shown), len(reqs))]
next += len(round)
err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error {
if gr == nil {
*dropped++
return nil
}
idea, ok, err := keep(first+i, *gr)
if err != nil {
return err
}
if !ok {
*dropped++
return nil
}
at := len(order)
for at > 0 && order[at-1] > first+i {
at--
}
order = slices.Insert(order, at, first+i)
*shown = slices.Insert(*shown, at, idea)
return publish()
})
if err != nil {
return err
}
}
return nil
}
// ground grounds reqs with g and calls kept, one call at a time, as each
// request grounds, with its index in reqs and its grounding, or nil when
// no recipe grounds it. Any other grounding failure, or kept failing,
// stops the groundings still running and is returned, as a *jobs.Failure
// when the household may see it.
func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error {
groundCtx, cancel := context.WithCancel(ctx)
defer cancel()
var failed error
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
if failed != nil {
return
}
var ungroundable *recipes.ErrUngroundable
switch {
case r.Err == nil:
failed = kept(i, &r.Grounded)
case errors.As(r.Err, &ungroundable):
failed = kept(i, nil)
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
failed = &jobs.Failure{Code: ReasonSearchNotConfigured}
case errors.Is(r.Err, recipes.ErrSearchFailed):
failed = &jobs.Failure{Code: ReasonSearchFailed}
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
failed = &jobs.Failure{Code: ReasonAIFailed}
default:
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
}
if failed != nil {
cancel()
}
})
if err := ctx.Err(); err != nil {
return err
}
return failed
}
// shownJobResult is a job's stored result as the job shows it. A failed
// job's shows no ideas, however the job failed, so a failed job never
// shows ideas kept before the failure. Any other job's shows each idea's
// source as shownSource gives it, so a result stored before sources were
// made safe never shows a link or name that could leak a secret.
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
var res R
if err := json.Unmarshal(stored, &res); err != nil {
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
}
if status == jobs.StatusFailed {
return res.cleared().PublicJSON()
}
safe, err := res.withSafeSources()
if err != nil {
return nil, err
}
return safe.PublicJSON()
}
// ideaOf is idea as grounded by gr.
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
src, err := sourceOf(gr)
if err != nil {
return Idea{}, err
}
return Idea{
Name: idea.Name,
Uses: idea.Uses,
Missing: idea.Missing,
Summary: idea.Summary,
Method: gr.Method,
MinutesTotal: gr.MinutesTotal,
Difficulty: gr.Difficulty,
Quick: IsQuick(gr.Method, gr.MinutesTotal),
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// sourceOf is gr's recipe source as a client may see it (see
// dinners.SourceOf): a grounded recipe's site name is stored only when
// not empty.
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
if err != nil {
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
}
return src, nil
}
// shownSource is the source of an idea as stored in a job's result, with
// link sourceURL and site name siteName, as a client may see it (see
// dinners.SourceOf). A result stored now already holds it, and one stored
// before sources were made safe holds the grounded recipe's own, with ""
// for no site name, as sourceOf reads it; a null link, which only a result
// stored now holds, is checked as "".
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
var link string
if sourceURL != nil {
link = *sourceURL
}
var site sql.NullString
if siteName != nil {
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
}
return dinners.SourceOf(site, link)
}
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown
// to the household, as skills.CleanExclude returns them.
type QuickJob struct {
Kind string `json:"kind"`
Exclude []string `json:"exclude"`
}
// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of
// kind, none repeating a name in exclude, and returns its id. A kind
// skills.CheckQuickKind refuses, with no default, or an exclude list
// skills.CleanExclude refuses, is an *InvalidError, and starts nothing.
func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) {
if err := skills.CheckQuickKind(kind); err != nil {
return Started{}, &InvalidError{Message: "kind: " + err.Error()}
}
names, err := skills.CleanExclude(exclude)
if err != nil {
return Started{}, &InvalidError{Message: "exclude: " + err.Error()}
}
id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names})
if err != nil {
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
}
return Started{JobID: id}, nil
}
// QuickIdea is a grounded Quick dinner idea as the job's result shows it.
// Method, MinutesTotal and Difficulty are the grounded recipe's, never the
// model's estimates, and always make it of the kind asked for (see
// OfQuickKind).
type QuickIdea struct {
Name string `json:"name"`
// Method is stovetop, oven, crockpot, grill or other: crockpot for
// skills.QuickCrockpot.
Method string `json:"method"`
// MinutesTotal is prep to table, null when the recipe does not say:
// never null, and at most QuickMinutes, for skills.QuickUnder30.
MinutesTotal *int `json:"minutes_total"`
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
// SourceURL and SiteName are as in Idea.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// QuickResult is the KindQuick result, published as the job goes: its
// stage, the request, the ideas grounded so far in the model's order (as
// in Result), and how many ideas were dropped, by quick_ideas' rules
// (repeating a name already shown or an earlier idea), because no recipe
// grounds them, or because the grounded recipe is not of the kind.
type QuickResult struct {
Stage string `json:"stage"`
Kind string `json:"kind"`
Exclude []string `json:"exclude"`
Ideas []QuickIdea `json:"ideas"`
Dropped int `json:"dropped"`
}
func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
func (r QuickResult) shown() int { return len(r.Ideas) }
func (r QuickResult) cleared() QuickResult {
r.Ideas = []QuickIdea{}
return r
}
func (r QuickResult) withSafeSources() (QuickResult, error) {
ideas := make([]QuickIdea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
// cooked in a crock pot (normalize_recipe writes a slow cooker as
// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from
// prep to table. Any other kind is none.
func OfQuickKind(kind, method string, minutes *int) bool {
switch kind {
case skills.QuickCrockpot:
return method == "crockpot"
case skills.QuickUnder30:
return minutes != nil && *minutes <= QuickMinutes
}
return false
}
// runQuick runs a KindQuick job: from the household context, it asks
// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than
// a page holds, then grounds them in the model's order, at most the number
// still wanted at a time, until skills.QuickIdeasKept are kept or none are
// left. An idea no recipe grounds is dropped, and so is one whose grounded
// recipe is not of the kind (OfQuickKind): the model's estimate never
// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page
// shows only those: fewer is honest, and it is never padded with an idea
// not of the kind. When every idea is dropped the job fails with
// ReasonNoIdeasFound. Any other grounding failure fails the job, as does
// quick_ideas failing, with no idea made up instead. The result is
// published at each stage and again as each idea is kept, and a failed job
// shows no ideas, as for KindFromIngredients (see finish).
func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
res, err := s.fillQuick(ctx, p, publish)
return finish(ctx, s.logger, "quick ideas", res, err, publish)
}
// fillQuick does runQuick's work as fill does run's.
func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}}
if err := publish(res); err != nil {
return res, err
}
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return res, fmt.Errorf("ideas: %w", err)
}
in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude}
out, err := s.quick(ctx, skills.QuickIdeasScope(in), in)
if err != nil {
if ctx.Err() != nil {
return res, ctx.Err()
}
return res, &jobs.Failure{Code: ReasonAIFailed}
}
res.Stage = StageGrounding
res.Dropped = len(out.Dropped)
if err := publish(res); err != nil {
return res, err
}
reqs := make([]recipes.GroundRequest, len(out.Ideas))
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool, error) {
idea, err := quickIdeaOf(out.Ideas[i], gr)
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
}, func() error { return publish(res) })
if err != nil {
return res, err
}
if len(res.Ideas) == 0 {
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
}
res.Stage = StageDone
return res, nil
}
// quickIdeaOf is idea as grounded by gr.
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) {
src, err := sourceOf(gr)
if err != nil {
return QuickIdea{}, err
}
return QuickIdea{
Name: idea.Name,
Method: gr.Method,
MinutesTotal: gr.MinutesTotal,
Difficulty: gr.Difficulty,
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// Origins of a dinner made from an idea (the dinners.origin column).
const (
// OriginIngredients is an idea from Cook from what I have.
OriginIngredients = "ingredients"
// OriginQuick is an idea from Quick dinners.
OriginQuick = "quick"
)
// ErrIdeaNotFound is an idea the household cannot act on: an unknown job,
// a job that does not find ideas, a failed job (which shows no ideas), or
// an index past the job's ideas.
var ErrIdeaNotFound = errors.New("idea not found")
// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas
// may still move: an idea that grounds later is put back in its place
// before those shown already, so an index names an idea only once the job
// has succeeded.
var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded")
// Chosen is the idea the household acts on: its name, the recipe it was
// grounded in, and the origin of a dinner made from it.
type Chosen struct {
Name string
RecipeID string
Origin string
}
// Idea returns idea i, counted from 0, of the succeeded job jobID, as its
// stored result shows it: a dinner made from it uses the recipe it was
// grounded in, never grounding it again. A job still queued or running is
// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients
// or KindQuick job is ErrIdeaNotFound.
func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) {
job, err := s.runner.Get(ctx, jobID)
if errors.Is(err, jobs.ErrNotFound) {
return Chosen{}, ErrIdeaNotFound
}
if err != nil {
return Chosen{}, fmt.Errorf("ideas: read job: %w", err)
}
if job.Kind != KindFromIngredients && job.Kind != KindQuick {
return Chosen{}, ErrIdeaNotFound
}
switch job.Status {
case jobs.StatusQueued, jobs.StatusRunning:
return Chosen{}, ErrIdeasNotReady
case jobs.StatusSucceeded:
default:
return Chosen{}, ErrIdeaNotFound
}
if job.Kind == KindQuick {
var res QuickResult
if err := json.Unmarshal(job.Result, &res); err != nil {
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
}
if i < 0 || i >= len(res.Ideas) {
return Chosen{}, ErrIdeaNotFound
}
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil
}
var res Result
if err := json.Unmarshal(job.Result, &res); err != nil {
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
}
if i < 0 || i >= len(res.Ideas) {
return Chosen{}, ErrIdeaNotFound
}
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil
}
-575
View File
@@ -1,575 +0,0 @@
package ideas
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"reflect"
"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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// pantry is the household's ingredient list.
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
type env struct {
svc *Service
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
// model answers ideas_from_ingredients and quick_ideas; normalize
// answers normalize_recipe while grounding.
model *llm.Fake
normalize *llm.Fake
// fail, when set, fails ideas_from_ingredients and quick_ideas without
// a model call.
fail error
st *store.Store
}
// newEnv returns an ideas service over a migrated store with the
// household's settings seeded and an unstarted
// job runner. It finds ideas through the real harness and
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
// grounds with fake
// search, pages and images and the scripted e.normalize.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
dataDir := t.TempDir()
st, err := store.Open(ctx, dataDir)
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{
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
model: &llm.Fake{},
normalize: &llm.Fake{},
st: st,
}
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
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,
}
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
if e.fail != nil {
return skills.Ideas{}, e.fail
}
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
}
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
if e.fail != nil {
return skills.QuickIdeaList{}, e.fail
}
return skills.QuickIdeas(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
e.runner, err = jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// recipe makes the search for name find one page that grounds it, titled
// title, with a total time of minutes, and scripts the normalize_recipe
// reply grounding it takes. The page's total time is the grounded
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
// whatever the reply says.
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
t.Helper()
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
e.search.Results[name] = []recipes.Candidate{{URL: page}}
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
// idea renders one idea of an ideas_from_ingredients reply whose search
// query is its name.
func idea(name, method string, minutes int, uses ...string) string {
u, _ := json.Marshal(append([]string{}, uses...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, minutes, method, name)
}
// reply scripts one ideas_from_ingredients reply holding ideas.
func (e *env) reply(ideas ...string) {
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// recorder collects every result a job publishes.
type recorder struct {
mu sync.Mutex
published []Result
}
// latest returns the last result published.
func (r *recorder) latest() (Result, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.published) == 0 {
return Result{}, false
}
return r.published[len(r.published)-1], true
}
func (r *recorder) publish(res Result) error {
r.mu.Lock()
defer r.mu.Unlock()
// The result's slices are copied, as the runner stores each publish
// as JSON at once.
res.Ideas = append([]Idea(nil), res.Ideas...)
r.published = append(r.published, res)
return nil
}
// run runs the job's handler directly on ingredients, recording each
// publish.
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
t.Helper()
ings, err := skills.CleanIngredients(ingredients)
if err != nil {
t.Fatal(err)
}
var rec recorder
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
return res, rec.published, err
}
// within reports whether every name in sub appears in list, in list's
// order.
func within(sub, list []string) bool {
for _, name := range list {
if len(sub) > 0 && sub[0] == name {
sub = sub[1:]
}
}
return len(sub) == 0
}
func names(list []Idea) []string {
out := []string{}
for _, i := range list {
out = append(out, i.Name)
}
return out
}
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
e := newEnv(t)
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
e.recipe(t, name, name, 35)
}
// Eight ideas: two no recipe grounds, so the sixth and seventh are
// grounded in a second round, and the eighth never is.
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
idea("Mystery Stew", "stovetop", 35, "rice"),
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
idea("Unknown Casserole", "oven", 50, "rice"),
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
idea("Rice Pilaf", "stovetop", 30, "rice"),
idea("Bean Salad", "other", 15, "black beans"),
)
res, published, err := e.run(t, pantry, false)
if err != nil {
t.Fatal(err)
}
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
t.Fatalf("ideas = %q, want %q", got, want)
}
if res.Stage != StageDone || res.Dropped != 2 {
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
}
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
}
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
t.Errorf("searched %q, want the first seven ideas only", got)
}
// Each publish: the stage, then one more idea at a time, each idea
// grounded when it appears.
var stages []string
var counts []int
for _, p := range published {
stages = append(stages, p.Stage)
counts = append(counts, len(p.Ideas))
for _, i := range p.Ideas {
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
}
}
}
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) {
t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages)
}
// Ideas ground concurrently, so one may appear before an earlier one,
// but each publish keeps every idea already shown, in the model's
// order.
for i := 3; i < len(published); i++ {
prev, cur := names(published[i-1].Ideas), names(published[i].Ideas)
if !within(prev, cur) || !within(cur, want) {
t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want)
}
}
first := res.Ideas[0]
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
t.Errorf("first idea = %+v", first)
}
}
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
for _, quickOnly := range []bool{true, false} {
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
e := newEnv(t)
// The model estimates every idea quick; the grounded recipes
// decide.
e.recipe(t, "Fried Rice", "Fried Rice", 25)
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
e.recipe(t, "Bean Bake", "Bean Bake", 31)
e.reply(
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
idea("Bean Enchiladas", "oven", 25, "black beans"),
idea("Rice Bowls", "stovetop", 15, "rice"),
idea("Bean Bake", "oven", 30, "black beans"),
)
res, _, err := e.run(t, pantry, quickOnly)
if err != nil {
t.Fatal(err)
}
quick := map[string]bool{}
for _, i := range res.Ideas {
quick[i.Name] = i.Quick
}
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
wantDropped := 2
if !quickOnly {
want["Bean Enchiladas"], want["Bean Bake"] = false, false
wantDropped = 0
}
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
}
for _, i := range res.Ideas {
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
}
}
})
}
}
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
e := newEnv(t)
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
var reply []string
for _, name := range slow {
e.recipe(t, name, name, 90)
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
}
e.recipe(t, "Fried Rice", "Fried Rice", 20)
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
e.reply(reply...)
res, _, err := e.run(t, pantry, true)
if err != nil {
t.Fatal(err)
}
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
}
}
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
)
res, _, err := e.run(t, pantry, false)
if err != nil {
t.Fatal(err)
}
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
}
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
}
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
t.Errorf("searched %q, want only the idea kept", got)
}
}
func TestFailures(t *testing.T) {
for _, tc := range []struct {
name string
setup func(e *env)
code string
}{
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
{"search fails", func(e *env) {
e.search.Err = errors.New("boom")
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
}, ReasonSearchFailed},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
tc.setup(e)
_, published, err := e.run(t, pantry, false)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != tc.code {
t.Fatalf("err = %v, want failure %s", err, tc.code)
}
for _, p := range published {
if len(p.Ideas) != 0 {
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
}
}
})
}
}
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
e := newEnv(t)
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} {
e.recipe(t, name, name, 35)
}
// The first idea's search waits, so the two after it ground first.
gate := make(chan struct{})
e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate}
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Black Bean Soup", "stovetop", 40, "black beans"),
)
ings, err := skills.CleanIngredients(pantry)
if err != nil {
t.Fatal(err)
}
var rec recorder
type outcome struct {
res Result
err error
}
done := make(chan outcome, 1)
go func() {
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish)
done <- outcome{res, err}
}()
deadline := time.Now().Add(10 * time.Second)
for {
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) {
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
}
if latest.Stage != StageGrounding {
t.Fatalf("stage = %s, want grounding", latest.Stage)
}
break
}
if time.Now().After(deadline) {
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
}
time.Sleep(5 * time.Millisecond)
}
close(gate)
out := <-done
if out.err != nil {
t.Fatal(out.err)
}
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"}
if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
}
}
func TestNoIdeaKeptFailsTheJob(t *testing.T) {
for _, tc := range []struct {
name string
quickOnly bool
setup func(t *testing.T, e *env)
}{
{"no recipe grounds any", false, func(t *testing.T, e *env) {
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice"))
}},
{"no grounded recipe is quick", true, func(t *testing.T, e *env) {
e.recipe(t, "Roast Chicken", "Roast Chicken", 90)
e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs"))
}},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
tc.setup(t, e)
_, published, err := e.run(t, pantry, tc.quickOnly)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound {
t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound)
}
for _, p := range published {
if len(p.Ideas) != 0 || p.Stage == StageDone {
t.Fatalf("a job that kept no idea published %+v", p)
}
}
})
}
}
func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) {
e := newEnv(t)
kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"}
for _, name := range kept {
e.recipe(t, name, name, 35)
}
// The first round keeps four ideas; the second round's search fails.
e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")}
e.reply(
idea("Burrito Bowls", "stovetop", 35, "rice"),
idea("Mystery Stew", "stovetop", 35, "rice"),
idea("Fried Rice", "stovetop", 20, "rice"),
idea("Black Bean Soup", "stovetop", 40, "black beans"),
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"),
idea("Rice Pilaf", "stovetop", 30, "rice"),
)
_, published, err := e.run(t, pantry, false)
var failure *jobs.Failure
if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed {
t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed)
}
most := 0
for _, p := range published {
most = max(most, len(p.Ideas))
}
if most != len(kept) {
t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept))
}
if last := published[len(published)-1]; len(last.Ideas) != 0 {
t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas))
}
}
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
e := newEnv(t)
e.recipe(t, "Fried Rice", "Fried Rice", 20)
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
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()
})
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
t.Fatal("a repeated ingredient was accepted")
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
t.Fatalf("err = %v, want an *InvalidError", err)
}
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
var job jobs.Job
for {
job, err = e.runner.Get(t.Context(), started.JobID)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("job still %s", job.Status)
}
time.Sleep(5 * time.Millisecond)
}
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
t.Fatalf("job = %+v", job)
}
var res Result
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
t.Fatalf("result = %+v", res)
}
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
t.Errorf("idea = %+v", got)
}
}
func TestIsQuick(t *testing.T) {
m := func(n int) *int { return &n }
for _, tc := range []struct {
method string
minutes *int
want bool
}{
{"crockpot", m(480), true},
{"crockpot", nil, true},
{"stovetop", m(30), true},
{"oven", m(31), false},
{"stovetop", nil, false},
} {
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
}
}
}

Some files were not shown because too many files have changed in this diff Show More