Keep Pass history immutable and replenish atomic with decisions
Fixes the three review findings on 50399b6 (pull request #102):
- A batch now rereads every declined name inside its storing transaction,
so a Pass committed after the batch read its input but before it stores
can no longer let the passed dish into the deck (SQ-01).
- A decision that leaves the deck under five creates the replenish batch
and its job in the decision's own transaction, so a decision is never
recorded without the replenish it calls for; when they cannot be
written, neither is the decision, and a retry decides again (SQ-02).
StartBatch commits the batch with its job the same way, replacing the
mutex and the fail-on-enqueue cleanup. jobs gains Kind.EnqueueTx and
Kind.Wake for this.
- Migration 0012 gives each suggestion the name its dish was suggested
under; declined names for batches and the household context's passed
list read it instead of the dinner's mutable name, so renaming a passed
dinner no longer erases it from dedupe (SQ-03).
Task: b4f2558c-227f-4627-852b-be5aa190242a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+14
-7
@@ -127,7 +127,7 @@ suggested (deck) ──Try it──▶ quest_log ──planned date passes──
|
|||||||
│ └──────── explicit decision ─────┤
|
│ └──────── explicit decision ─────┤
|
||||||
│ ├──▶ regular
|
│ ├──▶ regular
|
||||||
│ └──▶ archived
|
│ └──▶ archived
|
||||||
└──Pass──▶ archived (name kept for dedupe)
|
└──Pass──▶ archived (suggested name kept for dedupe)
|
||||||
```
|
```
|
||||||
|
|
||||||
From `tried`, an explicit decision moves the dinner to `regular`, to
|
From `tried`, an explicit decision moves the dinner to `regular`, to
|
||||||
@@ -265,7 +265,7 @@ automatic** (D-021).
|
|||||||
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
|
`suggestion_batch` job, answering 202 `{batch_id, job_id, trigger,
|
||||||
status}` without waiting; while a batch is `pending` or `running` it
|
status}` without waiting; while a batch is `pending` or `running` it
|
||||||
answers 202 with that batch instead, so at most one runs at a time (the
|
answers 202 with that batch instead, so at most one runs at a time (the
|
||||||
check and insert share one write transaction). The job reads the
|
check, the batch and its job share one write transaction). The job reads the
|
||||||
household context, the deck's names and every declined name, runs
|
household context, the deck's names and every declined name, runs
|
||||||
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
|
||||||
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
grounds the survivors in the model's order with `Grounder.GroundMany`,
|
||||||
@@ -274,9 +274,11 @@ automatic** (D-021).
|
|||||||
ones are stored before the next — a dinner with status `suggested`,
|
ones are stored before the next — a dinner with status `suggested`,
|
||||||
origin `side_quest`, grounding_status `grounded` with the recipe's
|
origin `side_quest`, grounding_status `grounded` with the recipe's
|
||||||
recipe_id, photo_id, method, minutes_total and difficulty and the
|
recipe_id, photo_id, method, minutes_total and difficulty and the
|
||||||
model's cuisine, and a `pending` suggestion with the reason and
|
model's cuisine, and a `pending` suggestion with the name it is
|
||||||
`cited_regular_ids` — unless a dinner it repeats was added, or a Regular
|
suggested under, the reason and `cited_regular_ids` — unless, since the
|
||||||
it cites renamed or archived, while it was grounding. The job publishes
|
job read its input, a dinner it repeats was added, a suggestion it
|
||||||
|
repeats passed, or a Regular it cites renamed or archived (checked in
|
||||||
|
the storing transaction). The job publishes
|
||||||
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||||
@@ -292,7 +294,9 @@ automatic** (D-021).
|
|||||||
first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
|
first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
|
||||||
suggestion `accepted` and moves its dinner to `quest_log`; `POST
|
suggestion `accepted` and moves its dinner to `quest_log`; `POST
|
||||||
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
|
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
|
||||||
the dinner, whose name every later batch reads as declined. Both record
|
the dinner; every later batch, and the household context's passed list,
|
||||||
|
reads the suggestion's own name as declined, so renaming the archived
|
||||||
|
dinner changes nothing. Both record
|
||||||
the session's subject as the dinner's `updated_by` and answer 200
|
the session's subject as the dinner's `updated_by` and answer 200
|
||||||
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
|
`{suggestion, replenish_job_id}`. Repeating a decision changes nothing
|
||||||
and answers 200; the opposite decision is 409 `suggestion_decided`, a
|
and answers 200; the opposite decision is 409 `suggestion_decided`, a
|
||||||
@@ -300,7 +304,10 @@ automatic** (D-021).
|
|||||||
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
|
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
|
||||||
fewer than 5 in the deck starts a `replenish` batch, whose job id it
|
fewer than 5 in the deck starts a `replenish` batch, whose job id it
|
||||||
returns, unless a batch is already pending or running
|
returns, unless a batch is already pending or running
|
||||||
(`replenish_job_id` is then null).
|
(`replenish_job_id` is then null). The batch and its job are written in
|
||||||
|
the decision's own transaction, so a decision is never recorded without
|
||||||
|
the replenish it calls for; when they cannot be written, neither is the
|
||||||
|
decision, and a retry decides again.
|
||||||
- `/health` — liveness.
|
- `/health` — liveness.
|
||||||
- Everything else serves the embedded PWA with SPA fallback.
|
- Everything else serves the embedded PWA with SPA fallback.
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) {
|
|||||||
batch := s.id("batch")
|
batch := s.id("batch")
|
||||||
s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||||
VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt)
|
VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt)
|
||||||
s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status,
|
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', ?, ?, ?)`,
|
decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`,
|
||||||
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt)
|
s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// plan inserts the week plan starting weekStart with one dinner per date;
|
// plan inserts the week plan starting weekStart with one dinner per date;
|
||||||
|
|||||||
@@ -54,16 +54,16 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
|
|||||||
|
|
||||||
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
|
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
|
||||||
|
|
||||||
SELECT dinners.name
|
SELECT name
|
||||||
FROM suggestions
|
FROM suggestions
|
||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
WHERE status = 'declined'
|
||||||
WHERE suggestions.status = 'declined'
|
ORDER BY decided_at DESC, id DESC
|
||||||
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
|
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
|
|
||||||
// Reads for the AI household context block (internal/ai/harness).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The names of the most recently declined suggestions, newest first.
|
// The names the most recently declined suggestions were suggested under,
|
||||||
|
// newest first; renaming their dinners does not change them.
|
||||||
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
|
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
|
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ type Suggestion struct {
|
|||||||
ID string
|
ID string
|
||||||
DinnerID string
|
DinnerID string
|
||||||
BatchID string
|
BatchID string
|
||||||
|
Name string
|
||||||
Reason string
|
Reason string
|
||||||
CitedRegularIds string
|
CitedRegularIds string
|
||||||
Status string
|
Status string
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type Querier interface {
|
|||||||
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
|
||||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||||
|
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||||
|
// later renamed.
|
||||||
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
||||||
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
|
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
|
||||||
// Records the household's decision on a pending suggestion; one already
|
// Records the household's decision on a pending suggestion; one already
|
||||||
@@ -50,9 +52,9 @@ type Querier interface {
|
|||||||
// boot, after the job runner fails the jobs a previous process left running.
|
// boot, after the job runner fails the jobs a previous process left running.
|
||||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||||
// Fails every batch still pending or running whose job is no longer queued
|
// Fails every batch still pending or running whose job is no longer queued
|
||||||
// or running: the process stopped before enqueuing the job or before its
|
// or running: the process stopped before its handler recorded a result.
|
||||||
// handler recorded a result. Run at boot, after the job runner fails the
|
// Run at boot, after the job runner fails the jobs a previous process left
|
||||||
// jobs a previous process left running.
|
// running.
|
||||||
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
|
FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error)
|
||||||
// Records why the background grounding of an unarchived dinner still
|
// Records why the background grounding of an unarchived dinner still
|
||||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||||
@@ -107,14 +109,16 @@ type Querier interface {
|
|||||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||||
// with their dinners.
|
// with their dinners.
|
||||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||||
// The name of every declined suggestion's dinner, oldest decision first.
|
// The name every declined suggestion was suggested under, oldest decision
|
||||||
|
// first; renaming its dinner does not change it.
|
||||||
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
|
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
|
||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
// to_date, oldest first. Days with no dinner are left out.
|
// to_date, oldest first. Days with no dinner are left out.
|
||||||
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
||||||
// Reads for the AI household context block (internal/ai/harness).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The names of the most recently declined suggestions, newest first.
|
// The names the most recently declined suggestions were suggested under,
|
||||||
|
// newest first; renaming their dinners does not change them.
|
||||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ func (q *Queries) CountDeck(ctx context.Context) (int64, error) {
|
|||||||
|
|
||||||
const createSuggestion = `-- name: CreateSuggestion :exec
|
const createSuggestion = `-- name: CreateSuggestion :exec
|
||||||
INSERT INTO suggestions (
|
INSERT INTO suggestions (
|
||||||
id, dinner_id, batch_id, reason, cited_regular_ids, status, created_at, updated_at
|
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, 'pending', ?, ?
|
?, ?, ?, ?, ?, ?, 'pending', ?, ?
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -37,17 +37,21 @@ type CreateSuggestionParams struct {
|
|||||||
ID string
|
ID string
|
||||||
DinnerID string
|
DinnerID string
|
||||||
BatchID string
|
BatchID string
|
||||||
|
Name string
|
||||||
Reason string
|
Reason string
|
||||||
CitedRegularIds string
|
CitedRegularIds string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt 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 {
|
func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, createSuggestion,
|
_, err := q.db.ExecContext(ctx, createSuggestion,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.DinnerID,
|
arg.DinnerID,
|
||||||
arg.BatchID,
|
arg.BatchID,
|
||||||
|
arg.Name,
|
||||||
arg.Reason,
|
arg.Reason,
|
||||||
arg.CitedRegularIds,
|
arg.CitedRegularIds,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
@@ -128,9 +132,9 @@ type FailOrphanedSuggestionBatchesParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fails every batch still pending or running whose job is no longer queued
|
// Fails every batch still pending or running whose job is no longer queued
|
||||||
// or running: the process stopped before enqueuing the job or before its
|
// or running: the process stopped before its handler recorded a result.
|
||||||
// handler recorded a result. Run at boot, after the job runner fails the
|
// Run at boot, after the job runner fails the jobs a previous process left
|
||||||
// jobs a previous process left running.
|
// running.
|
||||||
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
|
func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
|
result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,7 +189,7 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSuggestion = `-- name: GetSuggestion :one
|
const getSuggestion = `-- name: GetSuggestion :one
|
||||||
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, 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
|
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
|
FROM suggestions
|
||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||||
WHERE suggestions.id = ?
|
WHERE suggestions.id = ?
|
||||||
@@ -204,6 +208,7 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo
|
|||||||
&i.Suggestion.ID,
|
&i.Suggestion.ID,
|
||||||
&i.Suggestion.DinnerID,
|
&i.Suggestion.DinnerID,
|
||||||
&i.Suggestion.BatchID,
|
&i.Suggestion.BatchID,
|
||||||
|
&i.Suggestion.Name,
|
||||||
&i.Suggestion.Reason,
|
&i.Suggestion.Reason,
|
||||||
&i.Suggestion.CitedRegularIds,
|
&i.Suggestion.CitedRegularIds,
|
||||||
&i.Suggestion.Status,
|
&i.Suggestion.Status,
|
||||||
@@ -255,7 +260,7 @@ func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestion
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDeck = `-- name: ListDeck :many
|
const listDeck = `-- name: ListDeck :many
|
||||||
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, 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
|
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
|
FROM suggestions
|
||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
JOIN dinners ON dinners.id = suggestions.dinner_id
|
||||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'
|
||||||
@@ -282,6 +287,7 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
|
|||||||
&i.Suggestion.ID,
|
&i.Suggestion.ID,
|
||||||
&i.Suggestion.DinnerID,
|
&i.Suggestion.DinnerID,
|
||||||
&i.Suggestion.BatchID,
|
&i.Suggestion.BatchID,
|
||||||
|
&i.Suggestion.Name,
|
||||||
&i.Suggestion.Reason,
|
&i.Suggestion.Reason,
|
||||||
&i.Suggestion.CitedRegularIds,
|
&i.Suggestion.CitedRegularIds,
|
||||||
&i.Suggestion.Status,
|
&i.Suggestion.Status,
|
||||||
@@ -322,14 +328,14 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
|
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
|
||||||
SELECT dinners.name
|
SELECT name
|
||||||
FROM suggestions
|
FROM suggestions
|
||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
WHERE status = 'declined'
|
||||||
WHERE suggestions.status = 'declined'
|
ORDER BY decided_at, id
|
||||||
ORDER BY suggestions.decided_at, suggestions.id
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// The name of every declined suggestion's dinner, oldest decision first.
|
// The name every declined suggestion was suggested under, oldest decision
|
||||||
|
// first; renaming its dinner does not change it.
|
||||||
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
|
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
|
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+32
-2
@@ -259,6 +259,21 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
|||||||
return k.r.enqueue(ctx, k.name, data)
|
return k.r.enqueue(ctx, k.name, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnqueueTx stores a queued job with payload in tx and returns its id, so
|
||||||
|
// the job exists exactly when what else tx writes does. Once tx has
|
||||||
|
// committed, call Wake so an idle worker runs it.
|
||||||
|
func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||||
|
}
|
||||||
|
return createJob(ctx, db.New(tx), k.name, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||||
|
// call it after committing the transaction a job was enqueued in.
|
||||||
|
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||||
|
|
||||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||||
// ErrUnknownKind for a kind with no registered handler and
|
// ErrUnknownKind for a kind with no registered handler and
|
||||||
@@ -280,9 +295,20 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
|
|||||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||||
// worker.
|
// worker.
|
||||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||||
|
id, err := createJob(ctx, db.New(r.st.Writer), kind, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
r.wakeOne()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJob stores a queued job of kind with its encoded payload through q
|
||||||
|
// and returns its id.
|
||||||
|
func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) {
|
||||||
id := store.NewID()
|
id := store.NewID()
|
||||||
now := store.Now().String()
|
now := store.Now().String()
|
||||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
if err := q.CreateJob(ctx, db.CreateJobParams{
|
||||||
ID: id,
|
ID: id,
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
Payload: string(payload),
|
Payload: string(payload),
|
||||||
@@ -291,11 +317,15 @@ func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (stri
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||||
}
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wakeOne gives an idle worker a token to look for a queued job.
|
||||||
|
func (r *Runner) wakeOne() {
|
||||||
select {
|
select {
|
||||||
case r.wake <- struct{}{}:
|
case r.wake <- struct{}{}:
|
||||||
default: // every worker already has a token and will look again
|
default: // every worker already has a token and will look again
|
||||||
}
|
}
|
||||||
return id, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start fails every job a previous process left running with
|
// Start fails every job a previous process left running with
|
||||||
|
|||||||
@@ -187,6 +187,46 @@ func TestJobSucceeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTxStoresJobOnlyWithItsTransaction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st := migratedStore(t)
|
||||||
|
r := newRunner(t, st, 1)
|
||||||
|
count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||||
|
return countResult{Done: p.Steps}, nil
|
||||||
|
})
|
||||||
|
start(t, r)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var rolledBack string
|
||||||
|
refused := errors.New("refused")
|
||||||
|
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var err error
|
||||||
|
rolledBack, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 1})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return refused
|
||||||
|
}); !errors.Is(err, refused) {
|
||||||
|
t.Fatalf("WithTx = %v, want the refusal", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Get(ctx, rolledBack); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("job of a rolled-back transaction: Get = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
if err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var err error
|
||||||
|
id, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 2})
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count.Wake()
|
||||||
|
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded || string(job.Result) != `{"done":2}` {
|
||||||
|
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":2}", job, job.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestJobFailsWithFailureCode(t *testing.T) {
|
func TestJobFailsWithFailureCode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
st := migratedStore(t)
|
st := migratedStore(t)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
|||||||
}
|
}
|
||||||
id := store.NewID()
|
id := store.NewID()
|
||||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||||
ID: id, DinnerID: d.ID, BatchID: batch.ID, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
|
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
|
||||||
CreatedAt: store.Now().String(), UpdatedAt: now,
|
CreatedAt: store.Now().String(), UpdatedAt: now,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -175,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
|
||||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
@@ -557,3 +558,78 @@ func TestMigrateAIRunsStatus(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMigrateSuggestionName upgrades a database at 0011 holding a pending
|
||||||
|
// and a declined suggestion and checks 0012 keeps both, gives each its
|
||||||
|
// dinner's name, and refuses a suggestion with no name.
|
||||||
|
func TestMigrateSuggestionName(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := openTestStore(t)
|
||||||
|
before := fstest.MapFS{}
|
||||||
|
for _, name := range []string{
|
||||||
|
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
|
||||||
|
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
|
||||||
|
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
|
||||||
|
"0011_ground_dinner_job_revision.sql",
|
||||||
|
} {
|
||||||
|
body, err := fs.ReadFile(migrations.FS, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before[name] = &fstest.MapFile{Data: body}
|
||||||
|
}
|
||||||
|
if err := Migrate(ctx, s.Writer, before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||||
|
VALUES ('dinner-1', 'Pho', 'pho', 'suggested', 'side_quest', 0, 'grounded', 'c', 'u'),
|
||||||
|
('dinner-2', 'Ramen', 'ramen', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
|
||||||
|
`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at)
|
||||||
|
VALUES ('batch-1', 'manual', 'done', 'c', 'u')`,
|
||||||
|
`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, decided_at, created_at, updated_at)
|
||||||
|
VALUES ('sugg-1', 'dinner-1', 'batch-1', 'why 1', '[]', 'pending', NULL, 'c1', 'u1'),
|
||||||
|
('sugg-2', 'dinner-2', 'batch-1', 'why 2', '["dinner-1"]', 'declined', 'd2', 'c2', 'u2')`,
|
||||||
|
} {
|
||||||
|
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.Reader.Query(`SELECT id, dinner_id, batch_id, name, reason, cited_regular_ids, status, COALESCE(decided_at, ''), created_at, updated_at
|
||||||
|
FROM suggestions ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got [][]string
|
||||||
|
for rows.Next() {
|
||||||
|
r := make([]string, 10)
|
||||||
|
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got = append(got, r)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := [][]string{
|
||||||
|
{"sugg-1", "dinner-1", "batch-1", "Pho", "why 1", "[]", "pending", "", "c1", "u1"},
|
||||||
|
{"sugg-2", "dinner-2", "batch-1", "Ramen", "why 2", `["dinner-1"]`, "declined", "d2", "c2", "u2"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("suggestions after 0012 =\n %v\nwant\n %v", got, want)
|
||||||
|
}
|
||||||
|
if _, err := s.Writer.Exec(`UPDATE suggestions SET name = '' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||||
|
t.Errorf("empty suggestion name: error %v, want CHECK constraint failure", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Writer.Exec(`UPDATE suggestions SET dinner_id = 'nope' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") {
|
||||||
|
t.Errorf("unknown dinner: error %v, want FOREIGN KEY constraint failure", err)
|
||||||
|
}
|
||||||
|
if tableExists(t, s, "suggestions_copy") {
|
||||||
|
t.Error("suggestions_copy left behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
|
|||||||
parents: []string{"dinners", "suggestion_batches"},
|
parents: []string{"dinners", "suggestion_batches"},
|
||||||
row: repoRow{"suggestions", map[string]any{
|
row: repoRow{"suggestions", map[string]any{
|
||||||
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
|
"id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1",
|
||||||
"reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
"name": "White Chicken Chili", "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`,
|
||||||
"status": "pending", "decided_at": nil,
|
"status": "pending", "decided_at": nil,
|
||||||
"created_at": repoTime, "updated_at": repoTime,
|
"created_at": repoTime, "updated_at": repoTime,
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func testRow(table string, i int) map[string]any {
|
|||||||
case "suggestions":
|
case "suggestions":
|
||||||
r["dinner_id"] = rowID("dinners", 0)
|
r["dinner_id"] = rowID("dinners", 0)
|
||||||
r["batch_id"] = rowID("suggestion_batches", 0)
|
r["batch_id"] = rowID("suggestion_batches", 0)
|
||||||
|
r["name"] = fmt.Sprintf("Side quest %d", i)
|
||||||
r["reason"] = "Like your chili."
|
r["reason"] = "Like your chili."
|
||||||
r["cited_regular_ids"] = "[]"
|
r["cited_regular_ids"] = "[]"
|
||||||
r["status"] = "pending"
|
r["status"] = "pending"
|
||||||
|
|||||||
@@ -196,8 +196,10 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
|||||||
// suggested and origin side_quest, grounded in its recipe, and a pending
|
// suggested and origin side_quest, grounded in its recipe, and a pending
|
||||||
// suggestion citing its Regulars by id, all in one transaction, and
|
// suggestion citing its Regulars by id, all in one transaction, and
|
||||||
// returns the suggestion ids. A Side quest is left out when, since
|
// returns the suggestion ids. A Side quest is left out when, since
|
||||||
// suggest_side_quests ran, a dinner it repeats was added or a Regular it
|
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
||||||
// cites renamed or archived.
|
// repeats passed, or a Regular it cites renamed or archived. Declined
|
||||||
|
// names are read in the same transaction as the store, so a Pass committed
|
||||||
|
// before it is never missed.
|
||||||
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
||||||
ids := []string{}
|
ids := []string{}
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -206,7 +208,10 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||||
}
|
}
|
||||||
var names []string
|
names, err := q.ListDeclinedSuggestionNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
|
||||||
|
}
|
||||||
regulars := map[string][]string{}
|
regulars := map[string][]string{}
|
||||||
for _, row := range existing {
|
for _, row := range existing {
|
||||||
names = append(names, row.Dinner.Name)
|
names = append(names, row.Dinner.Name)
|
||||||
@@ -250,6 +255,7 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
|
|||||||
ID: id,
|
ID: id,
|
||||||
DinnerID: d.ID,
|
DinnerID: d.ID,
|
||||||
BatchID: batchID,
|
BatchID: batchID,
|
||||||
|
Name: d.Name,
|
||||||
Reason: gq.quest.Reason,
|
Reason: gq.quest.Reason,
|
||||||
CitedRegularIds: string(citedJSON),
|
CitedRegularIds: string(citedJSON),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
|||||||
+64
-67
@@ -15,7 +15,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
@@ -112,10 +111,6 @@ type Service struct {
|
|||||||
suggest Suggester
|
suggest Suggester
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
batchJob *jobs.Kind[BatchJob]
|
batchJob *jobs.Kind[BatchJob]
|
||||||
|
|
||||||
// batchMu makes finding or creating the active batch and enqueuing its
|
|
||||||
// job one step, so a batch is never seen without its job.
|
|
||||||
batchMu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns a service over st that suggests with suggest, grounds
|
// NewService returns a service over st that suggests with suggest, grounds
|
||||||
@@ -161,53 +156,54 @@ func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error)
|
|||||||
if !slices.Contains(triggers, trigger) {
|
if !slices.Contains(triggers, trigger) {
|
||||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||||
}
|
}
|
||||||
s.batchMu.Lock()
|
var b Batch
|
||||||
defer s.batchMu.Unlock()
|
|
||||||
var row db.SuggestionBatch
|
|
||||||
created := false
|
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
var err error
|
||||||
cur, err := q.GetActiveSuggestionBatch(ctx)
|
b, err = s.startBatch(ctx, tx, trigger)
|
||||||
if err == nil {
|
return err
|
||||||
row = cur
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return fmt.Errorf("suggest: read active batch: %w", err)
|
|
||||||
}
|
|
||||||
now := store.Now().String()
|
|
||||||
row, err = q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
|
|
||||||
ID: store.NewID(),
|
|
||||||
Trigger: trigger,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("suggest: create batch: %w", err)
|
|
||||||
}
|
|
||||||
created = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Batch{}, err
|
return Batch{}, err
|
||||||
}
|
}
|
||||||
b := Batch{ID: row.ID, Trigger: row.Trigger, Status: row.Status, Created: created}
|
if b.Created {
|
||||||
if !created {
|
s.batchJob.Wake()
|
||||||
b.JobID, err = db.New(s.st.Reader).GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: row.ID})
|
|
||||||
if err != nil {
|
|
||||||
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", row.ID, err)
|
|
||||||
}
|
|
||||||
return b, nil
|
|
||||||
}
|
|
||||||
b.JobID, err = s.batchJob.Enqueue(ctx, BatchJob{BatchID: row.ID})
|
|
||||||
if err != nil {
|
|
||||||
// The batch has no job to run it, so it must not block the next.
|
|
||||||
s.finishBatch(context.WithoutCancel(ctx), row.ID, BatchFailed)
|
|
||||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||||
|
// it are committed together, so a batch is never seen without its job. The
|
||||||
|
// caller wakes the job runner once tx has committed and Created is true.
|
||||||
|
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
|
||||||
|
q := db.New(tx)
|
||||||
|
cur, err := q.GetActiveSuggestionBatch(ctx)
|
||||||
|
if err == nil {
|
||||||
|
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
|
||||||
|
if err != nil {
|
||||||
|
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
|
||||||
|
}
|
||||||
|
return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
|
||||||
|
}
|
||||||
|
now := store.Now().String()
|
||||||
|
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
|
||||||
|
ID: store.NewID(),
|
||||||
|
Trigger: trigger,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||||
|
}
|
||||||
|
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||||
|
if err != nil {
|
||||||
|
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// finishBatch records batch id as status, logging a failure to do so.
|
// finishBatch records batch id as status, logging a failure to do so.
|
||||||
func (s *Service) finishBatch(ctx context.Context, id, status string) {
|
func (s *Service) finishBatch(ctx context.Context, id, status string) {
|
||||||
if _, err := db.New(s.st.Writer).FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{
|
if _, err := db.New(s.st.Writer).FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{
|
||||||
@@ -289,9 +285,9 @@ func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decline records Pass on suggestion id, archiving its dinner and
|
// Decline records Pass on suggestion id, archiving its dinner and
|
||||||
// recording updatedBy on it. The archived dinner keeps its name, and the
|
// recording updatedBy on it. The suggestion keeps the name its dish was
|
||||||
// declined suggestion is read into every later batch, so the dish is never
|
// suggested under, whatever the dinner is later renamed, and every later
|
||||||
// suggested again.
|
// batch reads it as declined, so the dish is never suggested again.
|
||||||
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
|
func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) {
|
||||||
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
|
return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy)
|
||||||
}
|
}
|
||||||
@@ -300,15 +296,15 @@ func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision,
|
|||||||
// dinnerStatus. Repeating a decision changes nothing and returns the
|
// dinnerStatus. Repeating a decision changes nothing and returns the
|
||||||
// suggestion as it is; the opposite decision is a *DecidedError. A
|
// suggestion as it is; the opposite decision is a *DecidedError. A
|
||||||
// decision that leaves the deck under ReplenishBelow starts a replenish
|
// decision that leaves the deck under ReplenishBelow starts a replenish
|
||||||
// batch unless one is already pending or running. The decision is
|
// batch unless one is already pending or running, in the decision's own
|
||||||
// committed before the batch starts, so a failure to start it is returned
|
// transaction: the decision is never recorded without the replenish it
|
||||||
// with the decision already made.
|
// calls for, and a failure to start the batch records neither.
|
||||||
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
|
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
|
return Decision{}, errors.New("suggest: decide: no member to record as updated_by")
|
||||||
}
|
}
|
||||||
var row db.GetSuggestionRow
|
var row db.GetSuggestionRow
|
||||||
changed := false
|
var replenish *Batch
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
cur, err := q.GetSuggestion(ctx, id)
|
cur, err := q.GetSuggestion(ctx, id)
|
||||||
@@ -349,33 +345,34 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
|
return fmt.Errorf("suggest: read suggestion %s: %w", id, err)
|
||||||
}
|
}
|
||||||
changed = true
|
left, err := q.CountDeck(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("suggest: count deck: %w", err)
|
||||||
|
}
|
||||||
|
if left >= ReplenishBelow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
replenish = &b
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Decision{}, err
|
return Decision{}, err
|
||||||
}
|
}
|
||||||
|
created := replenish != nil && replenish.Created
|
||||||
|
if created {
|
||||||
|
s.batchJob.Wake()
|
||||||
|
}
|
||||||
sug, err := fromRow(row.Suggestion, row.Dinner)
|
sug, err := fromRow(row.Suggestion, row.Dinner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Decision{}, err
|
return Decision{}, err
|
||||||
}
|
}
|
||||||
d := Decision{Suggestion: sug}
|
d := Decision{Suggestion: sug}
|
||||||
if !changed {
|
if created {
|
||||||
return d, nil
|
d.ReplenishJobID = &replenish.JobID
|
||||||
}
|
|
||||||
left, err := db.New(s.st.Reader).CountDeck(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Decision{}, fmt.Errorf("suggest: count deck: %w", err)
|
|
||||||
}
|
|
||||||
if left >= ReplenishBelow {
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
b, err := s.StartBatch(ctx, TriggerReplenish)
|
|
||||||
if err != nil {
|
|
||||||
return Decision{}, err
|
|
||||||
}
|
|
||||||
if b.Created {
|
|
||||||
d.ReplenishJobID = &b.JobID
|
|
||||||
}
|
}
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
"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/ai/skills"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"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"
|
||||||
@@ -41,9 +42,12 @@ type env struct {
|
|||||||
side *llm.Fake
|
side *llm.Fake
|
||||||
// regulars are the Regulars' ids by name.
|
// regulars are the Regulars' ids by name.
|
||||||
regulars map[string]string
|
regulars map[string]string
|
||||||
|
grounder *recipes.Grounder
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
inputs []skills.SideQuestsRequest
|
inputs []skills.SideQuestsRequest
|
||||||
|
// stub, when set, answers in place of suggest_side_quests.
|
||||||
|
stub func(in skills.SideQuestsRequest) skills.SideQuests
|
||||||
}
|
}
|
||||||
|
|
||||||
// newEnv returns a suggestions service over a migrated store with the
|
// newEnv returns a suggestions service over a migrated store with the
|
||||||
@@ -83,6 +87,7 @@ func newEnv(t *testing.T) *env {
|
|||||||
e.regulars[name] = e.dinner(t, name, "regular")
|
e.regulars[name] = e.dinner(t, name, "regular")
|
||||||
}
|
}
|
||||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
|
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
|
||||||
|
e.grounder = g
|
||||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -96,7 +101,11 @@ func newEnv(t *testing.T) *env {
|
|||||||
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
e.inputs = append(e.inputs, in)
|
e.inputs = append(e.inputs, in)
|
||||||
|
stub := e.stub
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
if stub != nil {
|
||||||
|
return stub(in), nil
|
||||||
|
}
|
||||||
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
@@ -169,6 +178,7 @@ func (e *env) seedDeck(t *testing.T, names ...string) []string {
|
|||||||
ID: id,
|
ID: id,
|
||||||
DinnerID: e.dinner(t, name, "suggested"),
|
DinnerID: e.dinner(t, name, "suggested"),
|
||||||
BatchID: b.ID,
|
BatchID: b.ID,
|
||||||
|
Name: name,
|
||||||
Reason: "Like your Beef Chili.",
|
Reason: "Like your Beef Chili.",
|
||||||
CitedRegularIds: fmt.Sprintf("[%q]", e.regulars["Beef Chili"]),
|
CitedRegularIds: fmt.Sprintf("[%q]", e.regulars["Beef Chili"]),
|
||||||
CreatedAt: store.Now().String(),
|
CreatedAt: store.Now().String(),
|
||||||
@@ -672,6 +682,146 @@ func TestLaterBatchNeverResuggestsDeclined(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBatchNeverStoresDishPassedWhileRunning passes a Side quest after a
|
||||||
|
// batch has read its input but before it stores, with suggest_side_quests
|
||||||
|
// answering from that input: the batch must not store the passed dish.
|
||||||
|
func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.start(t)
|
||||||
|
ids := e.seedDeck(t, "Chicken Shawarma Bowls")
|
||||||
|
e.recipe(t, "Chicken Shawarma Bowls")
|
||||||
|
e.recipe(t, "Beef Ragu")
|
||||||
|
held, release := make(chan struct{}), make(chan struct{})
|
||||||
|
stub := func(skills.SideQuestsRequest) skills.SideQuests {
|
||||||
|
close(held)
|
||||||
|
<-release
|
||||||
|
quest := func(name, cited string) skills.SideQuest {
|
||||||
|
return skills.SideQuest{Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy",
|
||||||
|
Reason: "Like your " + cited + ".", CitedRegulars: []string{cited}, SearchQuery: name}
|
||||||
|
}
|
||||||
|
return skills.SideQuests{Suggestions: []skills.SideQuest{
|
||||||
|
quest("Chicken Shawarma Bowls", "Chicken Fajitas"),
|
||||||
|
quest("Beef Ragu", "Beef Chili"),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.stub = stub
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
<-held
|
||||||
|
if _, err := e.svc.Decline(t.Context(), ids[0], member); err != nil {
|
||||||
|
close(release)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
job := e.waitJob(t, b.JobID)
|
||||||
|
if job.Status != jobs.StatusSucceeded {
|
||||||
|
t.Fatalf("batch job %s: %v", job.Status, *job.Error)
|
||||||
|
}
|
||||||
|
var res batchResult
|
||||||
|
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.SuggestionIDs) != 1 || res.Dropped != 1 {
|
||||||
|
t.Errorf("batch result = %+v, want 1 kept and the passed dish dropped", res)
|
||||||
|
}
|
||||||
|
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
|
||||||
|
t.Errorf("deck = %v, want [Beef Ragu]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLaterBatchNeverResuggestsRenamedPass passes a Side quest, renames
|
||||||
|
// its archived dinner, and checks the next batch still refuses the name it
|
||||||
|
// was suggested under.
|
||||||
|
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
||||||
|
e := newEnv(t) // runner not started: the replenish batch waits for the rename
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids := e.seedDeck(t, "Chicken Shawarma Bowls")
|
||||||
|
d, err := e.svc.Decline(t.Context(), ids[0], member)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.ReplenishJobID == nil {
|
||||||
|
t.Fatal("passing the last Side quest started no replenish batch")
|
||||||
|
}
|
||||||
|
renamed := "Leftover Wrap"
|
||||||
|
if _, err := dinnerSvc.Update(t.Context(), d.Suggestion.DinnerID, dinners.Patch{Name: &renamed}, member); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The respelled passed dish would ground, so only the declined names
|
||||||
|
// keep it out of the deck.
|
||||||
|
e.recipe(t, "chicken shawarma bowls!")
|
||||||
|
e.recipe(t, "Beef Ragu")
|
||||||
|
e.reply(
|
||||||
|
sideQuest("chicken shawarma bowls!", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||||
|
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
|
||||||
|
)
|
||||||
|
e.start(t)
|
||||||
|
job := e.waitJob(t, *d.ReplenishJobID)
|
||||||
|
if job.Status != jobs.StatusSucceeded {
|
||||||
|
t.Fatalf("replenish job %s: %v", job.Status, *job.Error)
|
||||||
|
}
|
||||||
|
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
|
||||||
|
t.Errorf("deck = %v, want [Beef Ragu]", got)
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
in := e.inputs[0]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if !slices.Equal(in.Declined, []string{"Chicken Shawarma Bowls"}) || !slices.Equal(in.Context.Passed, []string{"Chicken Shawarma Bowls"}) {
|
||||||
|
t.Errorf("replenish batch declined %v, passed %v; want the name it was suggested under in both", in.Declined, in.Context.Passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
|
||||||
|
// job fail to store: the decision must not be recorded either, so the
|
||||||
|
// retry decides again and starts the replenish.
|
||||||
|
func TestDecisionNeverCommitsWithoutItsReplenish(t *testing.T) {
|
||||||
|
e := newEnv(t) // runner not started: the replenish batch stays pending
|
||||||
|
ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu")
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'jobs refused'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := e.svc.Decline(t.Context(), ids[0], member); err == nil {
|
||||||
|
t.Fatal("decline succeeded with no replenish job stored")
|
||||||
|
}
|
||||||
|
if got := e.deckNames(t); len(got) != 5 {
|
||||||
|
t.Errorf("deck after the failed decline = %v, want all 5 still pending", got)
|
||||||
|
}
|
||||||
|
if n := e.activeBatches(t); n != 0 {
|
||||||
|
t.Errorf("%d active batches after the failed decline, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := e.st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_jobs`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d, err := e.svc.Decline(t.Context(), ids[0], member)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Suggestion.Status != suggest.StatusDeclined || d.ReplenishJobID == nil {
|
||||||
|
t.Fatalf("retried decline = %+v, want declined with a replenish job", d)
|
||||||
|
}
|
||||||
|
job, err := e.runner.Get(t.Context(), *d.ReplenishJobID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if job.Kind != suggest.KindSuggestionBatch || job.Status != jobs.StatusQueued {
|
||||||
|
t.Errorf("replenish job = %+v, want a queued %s", job, suggest.KindSuggestionBatch)
|
||||||
|
}
|
||||||
|
if n := e.activeBatches(t); n != 1 {
|
||||||
|
t.Errorf("%d active batches, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBatchFailsWhenAIFails(t *testing.T) {
|
func TestBatchFailsWhenAIFails(t *testing.T) {
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
e.start(t)
|
e.start(t)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- 0012_suggestion_name: a suggestion keeps the name its dish was suggested
|
||||||
|
-- under. Its dinner can be renamed, even once archived by a Pass, so the
|
||||||
|
-- declined names every later batch refuses are read from here, never from
|
||||||
|
-- the dinner. A suggestion stored before this takes its dinner's name now.
|
||||||
|
-- SQLite cannot add a NOT NULL column without a default, so the table is
|
||||||
|
-- rebuilt as 0005 does: copy the rows aside, drop the table, create it
|
||||||
|
-- again, copy the rows back and drop the copy. Nothing references
|
||||||
|
-- suggestions.
|
||||||
|
|
||||||
|
CREATE TABLE suggestions_copy AS SELECT * FROM suggestions;
|
||||||
|
|
||||||
|
DROP TABLE suggestions;
|
||||||
|
|
||||||
|
CREATE TABLE suggestions (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||||
|
batch_id TEXT NOT NULL REFERENCES suggestion_batches (id),
|
||||||
|
name TEXT NOT NULL CHECK (name <> ''),
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||||
|
decided_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO suggestions (
|
||||||
|
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, decided_at, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
suggestions_copy.id, suggestions_copy.dinner_id, suggestions_copy.batch_id, dinners.name,
|
||||||
|
suggestions_copy.reason, suggestions_copy.cited_regular_ids, suggestions_copy.status,
|
||||||
|
suggestions_copy.decided_at, suggestions_copy.created_at, suggestions_copy.updated_at
|
||||||
|
FROM suggestions_copy
|
||||||
|
JOIN dinners ON dinners.id = suggestions_copy.dinner_id;
|
||||||
|
|
||||||
|
DROP TABLE suggestions_copy;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
-- Reads for the AI household context block (internal/ai/harness).
|
-- Reads for the AI household context block (internal/ai/harness).
|
||||||
|
|
||||||
-- name: ListRecentDeclinedNames :many
|
-- name: ListRecentDeclinedNames :many
|
||||||
-- The names of the most recently declined suggestions, newest first.
|
-- The names the most recently declined suggestions were suggested under,
|
||||||
SELECT dinners.name
|
-- newest first; renaming their dinners does not change them.
|
||||||
|
SELECT name
|
||||||
FROM suggestions
|
FROM suggestions
|
||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
WHERE status = 'declined'
|
||||||
WHERE suggestions.status = 'declined'
|
ORDER BY decided_at DESC, id DESC
|
||||||
ORDER BY suggestions.decided_at DESC, suggestions.id DESC
|
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|
||||||
-- name: ListPlannedDinners :many
|
-- name: ListPlannedDinners :many
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ WHERE id = ? AND status IN ('pending', 'running');
|
|||||||
|
|
||||||
-- name: FailOrphanedSuggestionBatches :execrows
|
-- name: FailOrphanedSuggestionBatches :execrows
|
||||||
-- Fails every batch still pending or running whose job is no longer queued
|
-- Fails every batch still pending or running whose job is no longer queued
|
||||||
-- or running: the process stopped before enqueuing the job or before its
|
-- or running: the process stopped before its handler recorded a result.
|
||||||
-- handler recorded a result. Run at boot, after the job runner fails the
|
-- Run at boot, after the job runner fails the jobs a previous process left
|
||||||
-- jobs a previous process left running.
|
-- running.
|
||||||
UPDATE suggestion_batches
|
UPDATE suggestion_batches
|
||||||
SET status = 'failed', updated_at = sqlc.arg(updated_at)
|
SET status = 'failed', updated_at = sqlc.arg(updated_at)
|
||||||
WHERE status IN ('pending', 'running')
|
WHERE status IN ('pending', 'running')
|
||||||
@@ -49,10 +49,12 @@ WHERE status IN ('pending', 'running')
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: CreateSuggestion :exec
|
-- name: CreateSuggestion :exec
|
||||||
|
-- name is the name the dish is suggested under, kept whatever its dinner is
|
||||||
|
-- later renamed.
|
||||||
INSERT INTO suggestions (
|
INSERT INTO suggestions (
|
||||||
id, dinner_id, batch_id, reason, cited_regular_ids, status, created_at, updated_at
|
id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?, 'pending', ?, ?
|
?, ?, ?, ?, ?, ?, 'pending', ?, ?
|
||||||
);
|
);
|
||||||
|
|
||||||
-- name: GetSuggestion :one
|
-- name: GetSuggestion :one
|
||||||
@@ -79,12 +81,12 @@ JOIN dinners ON dinners.id = suggestions.dinner_id
|
|||||||
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
|
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
|
||||||
|
|
||||||
-- name: ListDeclinedSuggestionNames :many
|
-- name: ListDeclinedSuggestionNames :many
|
||||||
-- The name of every declined suggestion's dinner, oldest decision first.
|
-- The name every declined suggestion was suggested under, oldest decision
|
||||||
SELECT dinners.name
|
-- first; renaming its dinner does not change it.
|
||||||
|
SELECT name
|
||||||
FROM suggestions
|
FROM suggestions
|
||||||
JOIN dinners ON dinners.id = suggestions.dinner_id
|
WHERE status = 'declined'
|
||||||
WHERE suggestions.status = 'declined'
|
ORDER BY decided_at, id;
|
||||||
ORDER BY suggestions.decided_at, suggestions.id;
|
|
||||||
|
|
||||||
-- name: DecideSuggestion :execrows
|
-- name: DecideSuggestion :execrows
|
||||||
-- Records the household's decision on a pending suggestion; one already
|
-- Records the household's decision on a pending suggestion; one already
|
||||||
|
|||||||
Reference in New Issue
Block a user