feat(workouts): generate the per-workout insight on arrival, never on view

The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.

The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:56:50 +02:00
1 parent e53a8ce8a0
commit 1a469301b3
9 files changed
+556 -29

No files matched your search

+31 -6
View File
@@ -31,6 +31,7 @@ import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
import {
computeZones,
@@ -79,6 +80,9 @@ export const GET = apiHandler(
sampleCount: true,
},
},
insight: {
select: { paragraphEncrypted: true, generatedAt: true },
},
},
});
@@ -209,6 +213,26 @@ export const GET = apiHandler(
}
: null;
// Decrypt the stored paragraph. Fail-SOFT here and only here: `decrypt` is
// fail-closed by design, so a rotated-away key would otherwise turn the
// whole workout-detail page into a 500 over a garnish field. An
// undecryptable paragraph degrades to no card, exactly like a workout that
// never had one.
let aiInsight: { paragraph: string; generatedAt: string } | null = null;
if (row.insight) {
try {
aiInsight = {
paragraph: decryptFromBytes(row.insight.paragraphEncrypted),
generatedAt: row.insight.generatedAt.toISOString(),
};
} catch {
annotate({
action: { name: "workouts.detail.insight_undecryptable" },
meta: { workoutId: row.id },
});
}
}
return apiSuccess({
id: row.id,
sportType: row.sportType,
@@ -233,12 +257,13 @@ export const GET = apiHandler(
zones,
splits,
sportContext,
// Reserved Activity-Insight seam (strategic-concept Wave C, bet 5):
// always null today. The Phase-2 pg-boss job writes a cached
// paragraph onto the workout row and maps it here — the detail page
// already composes `{aiInsight ? <card/> : null}`, so the card
// mounts with zero layout rework.
aiInsight: null,
// The per-workout Activity Insight. A pure READ of a row the
// `workout-insight-generate` worker wrote when this workout landed —
// this route never generates, never enqueues, and never falls back to
// a provider. A workout with no row (every historical one, every
// re-synced one, every one on a provider-less install) serves null and
// the page's `{aiInsight ? <card/> : null}` renders nothing.
aiInsight,
// v1.4.32 — when the requested id is a non-canonical twin the
// caller can redirect to `canonicalId` to land on the cluster
// winner. `canonicalId === id` when the requested row already