fix: verify onboarding cache ownership end to end

This commit is contained in:
Jesse_Chen
2026-07-19 23:54:27 +08:00
parent e13d595d6b
commit 346ec02fd2
9 changed files with 802 additions and 196 deletions
@@ -150,3 +150,116 @@ modified `.superpowers/sdd/task-1-report.md` and untracked `.omo/` tree remained
- The programming skill's standalone no-excuse checker could not resolve its own `typescript`
dependency from the external skill cache. Changed-file ESLint, direct pattern audit, full tests,
and the production TypeScript build were run instead.
---
## Integration-review follow-up
### Outcome
- Status: `DONE_WITH_CONCERNS`
- Follow-up base SHA: `e13d595d6b3130ee20647ab95b0be3e0af8bdcac`
- Follow-up commit: `fix: verify onboarding cache ownership end to end`
- Resulting follow-up SHA: reported in the task handoff because a Git commit cannot embed its own
resulting hash without changing that hash.
`POST` now delegates to the injectable `createOnboardingPost` handler. Its stateful repository seam
executes load, version-and-timestamp claim CAS, deterministic generation, and pending-version
completion CAS as one observable HTTP workflow. The production adapter retains the real Supabase
PostgREST filters; the test fake models the same mutable single-row compare-and-set behavior.
Completion now calls `select("id").maybeSingle()` and observes the returned row. A lost pending
identity returns the safe provisional `pending` response, never the stale generated Agent payload.
Database errors retain the prior warning and terminal fallback/Agent response behavior.
### RED/GREEN evidence
Initial route-integration RED:
```text
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-route.test.ts
```
Result before the injectable handler existed: exit 1, `ERR_MODULE_NOT_FOUND` for
`src/lib/onboarding-post.ts`; 0 passed, 1 failed.
The first GREEN exercised two concurrent calls: A claimed and blocked in deterministic generation,
the fake profile changed to B, B claimed and completed, and A then lost its exact pending-version
completion. The handler returned B with `source=agent`, returned A with `source=pending`, and the
repository retained B's payload.
Regression-sensitivity proof temporarily removed the completion result branch and returned the
generated payload unconditionally. The focused stale test failed with actual `agent`, expected
`pending`. Restoring the ownership-result branch made the same test pass 1/1.
The stale root contract RED used the available repository environment:
```text
/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_agent_chat_contract.py
```
Result before updating the contract: exit 1; 1 failed and 1 passed. The old contract still expected
the onboarding fetch in `page.tsx` and later expected removed inline version logic. The updated
contract follows the client/factory/cache modules and pins the production version CAS, timestamp
CAS, pending-version completion CAS, completion-row observation, SHA-256 identity, and default POST
delegation. Final result: 2 passed.
### Expanded matrices
- Route integration: ready A to B, active-pending A to B, stale A completion after B wins, observed
version race, and observed timestamp race.
- Cache policy: each of the eight selected profile inputs independently changes ready and pending
identities; TTL minus one remains pending while exact TTL reclaims; null and invalid generated
timestamps reclaim.
- Candidate completion: missing owner, empty owner, and legal `candidate_saved` compatibility were
added without changing confidence/status policy.
### Follow-up verification
Focused frontend tests:
```text
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-route.test.ts tests/onboarding-cache-policy.test.ts tests/birth-time-candidate-completion.test.ts
```
Result: exit 0; 31 passed, 0 failed.
Full frontend suite:
```text
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts
```
Result: exit 0; 455 passed, 0 failed.
Changed-file ESLint: exit 0 with zero diagnostics across the route, cache/payload/handler modules,
candidate modules, stateful fake, and focused tests.
Affected Python contract: 2 passed. Changed-file Ruff initially found one extra blank line at the
existing import boundary; it was removed before final verification.
Production build:
```text
NEXT_PUBLIC_SUPABASE_URL=https://ci-placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=ci-placeholder PATH=/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH ./node_modules/.bin/next build --webpack
```
Result: exit 0; compiled in 5.9 seconds, completed TypeScript in 10.3 seconds, and generated 22/22
pages. `/api/onboarding` remains a dynamic route.
Direct `tsc --noEmit` still reports only the same eight unrelated ES2018 regexp-flag diagnostics;
no follow-up file is reported. Pure LOC is 80 (production adapter), 184 (handler), 33 (payload), 82
(cache policy), 141 (route integration test), 75 (stateful fake), 165 (policy test), 194 (candidate
test), and 95 (root contract): every touched file remains below 200 pure LOC.
### Follow-up concerns
- The same pre-work Python 3.9/pytest and remote-visibility environment blockers remain; no remote
synchronization is claimed.
- Default Turbopack still rejects the worktree's external dependency symlink. The complete webpack
production build with repository-standard CI placeholders passed.
Staged follow-up audit ran `git diff --cached --check`, `--name-status`, and `--stat`. The check
passed, and the staged set contained exactly nine follow-up paths: this report, the production
route adapter, handler, payload boundary, route fake/test, policy test, candidate test, and root CI
contract. Unrelated `.superpowers/sdd/task-1-report.md` and `.omo/` remained unstaged.
+73 -191
View File
@@ -1,10 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
createOnboardingCacheIdentity,
createOnboardingCompletionTransition,
decideOnboardingCache,
} from "@/lib/onboarding-cache-policy";
createOnboardingPost,
type OnboardingProfileRepository,
} from "@/lib/onboarding-post";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getOnboardingAgent } from "@/mastra";
@@ -13,189 +10,74 @@ import { defaultLanguageModel } from "@/mastra/model";
export const runtime = "nodejs";
export const maxDuration = 30;
const onboardingSchema = z.object({
greeting: z.string().trim().min(8).max(180),
suggestions: z.tuple([
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
]),
function createProfileRepository(
admin: ReturnType<typeof createAdminSupabaseClient>,
): OnboardingProfileRepository {
return {
async loadProfile(userId) {
return admin
.from("profiles")
.select("id,name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", userId)
.maybeSingle();
},
async claimProfile(command) {
let claim = admin
.from("profiles")
.update({
onboarding_version: command.pendingVersion,
onboarding_generated_at: command.claimedAt,
})
.eq("id", command.userId);
claim = command.expectedVersion === null
? claim.is("onboarding_version", null)
: claim.eq("onboarding_version", command.expectedVersion);
claim = command.expectedGeneratedAt === null
? claim.is("onboarding_generated_at", null)
: claim.eq("onboarding_generated_at", command.expectedGeneratedAt);
return claim.select("id").maybeSingle();
},
async completeProfile(command) {
return admin
.from("profiles")
.update({
onboarding_payload: command.payload,
onboarding_version: command.readyVersion,
onboarding_generated_at: command.generatedAt,
})
.eq("id", command.userId)
.eq("onboarding_version", command.expectedPendingVersion)
.select("id")
.maybeSingle();
},
};
}
export const POST = createOnboardingPost({
openSession: async () => {
const supabase = await createServerSupabaseClient();
const admin = createAdminSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
return {
userId: user?.id ?? null,
authError: Boolean(error),
repository: createProfileRepository(admin),
};
},
generateText: async (name) => {
const model = defaultLanguageModel();
if (!model) return null;
const result = await getOnboardingAgent(model).generate([
{
role: "user",
content: [
name ? `用户称呼:${name.slice(0, 80)}` : "用户未填写称呼。",
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
].join("\n"),
},
]);
return result.text;
},
now: () => new Date(),
warn: (message, detail) => console.warn(message, detail),
});
type OnboardingPayload = z.infer<typeof onboardingSchema>;
const fallbackPayload: OnboardingPayload = {
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
suggestions: [
{ theme: "career", text: "我的事业优势更适合怎样发挥?" },
{ theme: "marriage", text: "我在关系里容易重复什么模式?" },
{ theme: "timing", text: "未来一年有哪些阶段值得提前准备?" },
],
};
function parseJsonObject(text: string) {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
const end = normalized.lastIndexOf("}");
if (start < 0 || end <= start) throw new Error("onboarding_json_missing");
return JSON.parse(normalized.slice(start, end + 1));
}
function hasCompleteBirthProfile(profile: Record<string, unknown>) {
return Boolean(
profile.name
&& profile.birth_date
&& (profile.active_birth_time || profile.birth_time)
&& (profile.birth_time_status === "confirmed"
|| profile.birth_time_status === "candidate"
|| (!profile.birth_time_status && profile.birth_time))
&& profile.country_code
&& profile.province_code
&& profile.city_code,
);
}
export async function POST() {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let admin: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
admin = createAdminSupabaseClient();
} catch {
return NextResponse.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ status: 503 },
);
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能准备初始问题。" },
{ status: 401 },
);
}
const { data: profile, error: profileError } = await admin
.from("profiles")
.select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", user.id)
.maybeSingle();
if (profileError || !profile) {
return NextResponse.json(
{ error: "无法读取用户档案", message: profileError?.message || "请重新登录后再试。" },
{ status: 503 },
);
}
if (!hasCompleteBirthProfile(profile)) {
return NextResponse.json(
{ error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" },
{ status: 409 },
);
}
const identity = createOnboardingCacheIdentity({
name: profile.name,
birthDate: profile.birth_date,
birthTime: profile.birth_time,
activeBirthTime: profile.active_birth_time,
birthTimeStatus: profile.birth_time_status,
countryCode: profile.country_code,
provinceCode: profile.province_code,
cityCode: profile.city_code,
});
const cached = onboardingSchema.safeParse(profile.onboarding_payload);
const generatedAtMs = typeof profile.onboarding_generated_at === "string"
? Date.parse(profile.onboarding_generated_at)
: 0;
const cacheDecision = decideOnboardingCache({
identity,
observedVersion: profile.onboarding_version,
generatedAtMs,
nowMs: Date.now(),
cachedPayload: cached.success ? cached.data : null,
});
switch (cacheDecision.kind) {
case "ready":
return NextResponse.json({ ...cacheDecision.payload, source: "cache" });
case "pending":
return NextResponse.json({ ...fallbackPayload, source: "pending" });
case "claim":
break;
default: {
const exhaustiveDecision: never = cacheDecision;
throw exhaustiveDecision;
}
}
const claimTime = new Date().toISOString();
let claim = admin
.from("profiles")
.update({
onboarding_version: cacheDecision.pendingVersion,
onboarding_generated_at: claimTime,
})
.eq("id", user.id);
claim = cacheDecision.expectedVersion === null
? claim.is("onboarding_version", null)
: claim.eq("onboarding_version", cacheDecision.expectedVersion);
claim = profile.onboarding_generated_at === null
? claim.is("onboarding_generated_at", null)
: claim.eq("onboarding_generated_at", profile.onboarding_generated_at);
const { data: claimedProfile, error: claimError } = await claim.select("id").maybeSingle();
if (claimError) {
return NextResponse.json(
{ error: "暂时无法准备初始问题", message: claimError.message },
{ status: 503 },
);
}
if (!claimedProfile) {
return NextResponse.json({ ...fallbackPayload, source: "pending" });
}
let payload = fallbackPayload;
let source: "agent" | "fallback" = "fallback";
const onboardingModel = defaultLanguageModel();
if (onboardingModel) {
try {
const result = await getOnboardingAgent(onboardingModel).generate([
{
role: "user",
content: [
profile.name ? `用户称呼:${String(profile.name).slice(0, 80)}` : "用户未填写称呼。",
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
].join("\n"),
},
]);
const parsed = onboardingSchema.safeParse(parseJsonObject(result.text));
if (parsed.success) {
payload = parsed.data;
source = "agent";
}
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
console.warn("[onboarding] agent generation failed; using safe fallback", message);
}
}
const completionTransition = createOnboardingCompletionTransition(identity);
const { error: cacheError } = await admin
.from("profiles")
.update({
onboarding_payload: payload,
onboarding_version: completionTransition.readyVersion,
onboarding_generated_at: new Date().toISOString(),
})
.eq("id", user.id)
.eq("onboarding_version", completionTransition.expectedVersion);
if (cacheError) {
console.warn("[onboarding] unable to cache generated content", cacheError.message);
}
return NextResponse.json({ ...payload, source });
}
+39
View File
@@ -0,0 +1,39 @@
import { z } from "zod";
const onboardingSchema = z.object({
greeting: z.string().trim().min(8).max(180),
suggestions: z.tuple([
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
]),
});
export type OnboardingPayload = z.infer<typeof onboardingSchema>;
export const fallbackOnboardingPayload: OnboardingPayload = {
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
suggestions: [
{ theme: "career", text: "我的事业优势更适合怎样发挥?" },
{ theme: "marriage", text: "我在关系里容易重复什么模式?" },
{ theme: "timing", text: "未来一年有哪些阶段值得提前准备?" },
],
};
class OnboardingJsonError extends Error {
readonly name = "OnboardingJsonError";
}
export function parseOnboardingPayload(value: unknown): OnboardingPayload | null {
const parsed = onboardingSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function parseOnboardingText(text: string): OnboardingPayload | null {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
const end = normalized.lastIndexOf("}");
if (start < 0 || end <= start) throw new OnboardingJsonError("onboarding_json_missing");
const parsed: unknown = JSON.parse(normalized.slice(start, end + 1));
return parseOnboardingPayload(parsed);
}
+200
View File
@@ -0,0 +1,200 @@
import {
createOnboardingCacheIdentity,
createOnboardingCompletionTransition,
decideOnboardingCache,
} from "./onboarding-cache-policy.ts";
import {
fallbackOnboardingPayload,
type OnboardingPayload,
parseOnboardingPayload,
parseOnboardingText,
} from "./onboarding-payload.ts";
export type OnboardingProfileRow = {
readonly id: string;
readonly name: string | null;
readonly birth_date: string | null;
readonly birth_time: string | null;
readonly active_birth_time: string | null;
readonly birth_time_status: string | null;
readonly country_code: string | null;
readonly province_code: string | null;
readonly city_code: string | null;
readonly onboarding_payload: unknown;
readonly onboarding_version: string | null;
readonly onboarding_generated_at: string | null;
};
type RepositoryError = { readonly message: string };
type RepositoryResult<Value> = {
readonly data: Value | null;
readonly error: RepositoryError | null;
};
export type OnboardingClaimCommand = {
readonly userId: string;
readonly expectedVersion: string | null;
readonly expectedGeneratedAt: string | null;
readonly pendingVersion: string;
readonly claimedAt: string;
};
export type OnboardingCompletionCommand = {
readonly userId: string;
readonly expectedPendingVersion: string;
readonly readyVersion: string;
readonly payload: OnboardingPayload;
readonly generatedAt: string;
};
export interface OnboardingProfileRepository {
loadProfile(userId: string): Promise<RepositoryResult<OnboardingProfileRow>>;
claimProfile(command: OnboardingClaimCommand): Promise<RepositoryResult<{ readonly id: string }>>;
completeProfile(command: OnboardingCompletionCommand): Promise<RepositoryResult<{ readonly id: string }>>;
}
type OnboardingSession = {
readonly userId: string | null;
readonly authError: boolean;
readonly repository: OnboardingProfileRepository;
};
type OnboardingPostDependencies = {
readonly openSession: () => Promise<OnboardingSession>;
readonly generateText: (name: string) => Promise<string | null>;
readonly now: () => Date;
readonly warn: (message: string, detail: string) => void;
};
function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
return Boolean(
profile.name
&& profile.birth_date
&& (profile.active_birth_time || profile.birth_time)
&& (profile.birth_time_status === "confirmed"
|| profile.birth_time_status === "candidate"
|| (!profile.birth_time_status && profile.birth_time))
&& profile.country_code
&& profile.province_code
&& profile.city_code,
);
}
export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise<Response> {
return async function onboardingPost(): Promise<Response> {
let session: OnboardingSession;
try {
session = await dependencies.openSession();
} catch { // no-excuse-ok: catch -- route boundary converts missing configuration.
return Response.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ status: 503 },
);
}
if (session.authError || !session.userId) {
return Response.json(
{ error: "请先登录", message: "登录后才能准备初始问题。" },
{ status: 401 },
);
}
const loaded = await session.repository.loadProfile(session.userId);
if (loaded.error || !loaded.data) {
return Response.json(
{ error: "无法读取用户档案", message: loaded.error?.message || "请重新登录后再试。" },
{ status: 503 },
);
}
const profile = loaded.data;
if (!hasCompleteBirthProfile(profile)) {
return Response.json(
{ error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" },
{ status: 409 },
);
}
const identity = createOnboardingCacheIdentity({
name: profile.name,
birthDate: profile.birth_date,
birthTime: profile.birth_time,
activeBirthTime: profile.active_birth_time,
birthTimeStatus: profile.birth_time_status,
countryCode: profile.country_code,
provinceCode: profile.province_code,
cityCode: profile.city_code,
});
const cachedPayload = parseOnboardingPayload(profile.onboarding_payload);
const generatedAtMs = profile.onboarding_generated_at === null
? Number.NaN
: Date.parse(profile.onboarding_generated_at);
const now = dependencies.now();
const decision = decideOnboardingCache({
identity,
observedVersion: profile.onboarding_version,
generatedAtMs,
nowMs: now.getTime(),
cachedPayload,
});
switch (decision.kind) {
case "ready":
return Response.json({ ...decision.payload, source: "cache" });
case "pending":
return Response.json({ ...fallbackOnboardingPayload, source: "pending" });
case "claim":
break;
default: {
const exhaustiveDecision: never = decision;
throw exhaustiveDecision;
}
}
const claim = await session.repository.claimProfile({
userId: session.userId,
expectedVersion: decision.expectedVersion,
expectedGeneratedAt: profile.onboarding_generated_at,
pendingVersion: decision.pendingVersion,
claimedAt: now.toISOString(),
});
if (claim.error) {
return Response.json(
{ error: "暂时无法准备初始问题", message: claim.error.message },
{ status: 503 },
);
}
if (!claim.data) return Response.json({ ...fallbackOnboardingPayload, source: "pending" });
let payload = fallbackOnboardingPayload;
let source: "agent" | "fallback" = "fallback";
try {
const text = await dependencies.generateText(profile.name ?? "");
const parsed = text === null ? null : parseOnboardingText(text);
if (parsed) {
payload = parsed;
source = "agent";
}
} catch (error) { // no-excuse-ok: catch -- generation failure intentionally uses safe fallback.
dependencies.warn(
"[onboarding] agent generation failed; using safe fallback",
error instanceof Error ? error.message : "unknown error",
);
}
const completion = createOnboardingCompletionTransition(identity);
const completed = await session.repository.completeProfile({
userId: session.userId,
expectedPendingVersion: completion.expectedVersion,
readyVersion: completion.readyVersion,
payload,
generatedAt: dependencies.now().toISOString(),
});
if (completed.error) {
dependencies.warn("[onboarding] unable to cache generated content", completed.error.message);
return Response.json({ ...payload, source });
}
return completed.data
? Response.json({ ...payload, source })
: Response.json({ ...fallbackOnboardingPayload, source: "pending" });
};
}
@@ -59,6 +59,18 @@ test("accepts a matching low-confidence result from the rectifying state", () =>
}), "04:53");
});
test("accepts a persisted candidate-saved compatibility action", () => {
assert.equal(candidateWorkingTime({
...terminalCase,
turn_state: {
nextAction: {
kind: "candidate_saved",
resultId: terminalCase.candidate_result_id,
},
},
}, completionRequest), "04:53");
});
test("does not accept a medium terminal action from the rectifying state", () => {
assert.equal(candidateWorkingTime({
...lowTerminalCase,
@@ -94,6 +106,16 @@ const rejectedCompletions = [
stored: terminalCase,
request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
},
{
name: "missing case owner",
stored: { ...terminalCase, user_id: null },
request: completionRequest,
},
{
name: "empty case owner",
stored: { ...terminalCase, user_id: "" },
request: completionRequest,
},
{
name: "wrong case ID",
stored: terminalCase,
@@ -4,6 +4,7 @@ import {
createOnboardingCacheIdentity,
createOnboardingCompletionTransition,
decideOnboardingCache,
ONBOARDING_CLAIM_TTL_MS,
} from "../src/lib/onboarding-cache-policy.ts";
const profileA = {
@@ -132,3 +133,72 @@ test("invalid ready content and expired pending claims are reclaimed", () => {
});
}
});
test("every selected profile input participates in the cache identity", () => {
// Given: the exact eight profile inputs used by onboarding generation/completeness.
const baseIdentity = createOnboardingCacheIdentity(profileA);
const mutations = [
{ field: "name", profile: { ...profileA, name: "周宁" } },
{ field: "birthDate", profile: { ...profileA, birthDate: "1991-06-15" } },
{ field: "birthTime", profile: { ...profileA, birthTime: "12:31" } },
{ field: "activeBirthTime", profile: { ...profileA, activeBirthTime: "12:45" } },
{ field: "birthTimeStatus", profile: { ...profileA, birthTimeStatus: "candidate" } },
{ field: "countryCode", profile: { ...profileA, countryCode: "TW" } },
{ field: "provinceCode", profile: { ...profileA, provinceCode: "310000" } },
{ field: "cityCode", profile: { ...profileA, cityCode: "310100" } },
] as const;
// When/Then: mutating any one input changes both ready and pending identities.
for (const mutation of mutations) {
const changed = createOnboardingCacheIdentity(mutation.profile);
assert.notEqual(changed.readyVersion, baseIdentity.readyVersion, mutation.field);
assert.notEqual(changed.pendingVersion, baseIdentity.pendingVersion, mutation.field);
}
});
test("pending claim TTL is active through TTL minus one and reclaimable at TTL", () => {
// Given: the current profile owns the pending identity at a fixed time.
const identity = createOnboardingCacheIdentity(profileA);
const nowMs = Date.parse("2026-07-19T10:03:00.000Z");
// When/Then: the exact boundary preserves the existing strict-less-than policy.
assert.deepEqual(decideOnboardingCache({
identity,
observedVersion: identity.pendingVersion,
generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS + 1,
nowMs,
cachedPayload: null,
}), { kind: "pending" });
assert.deepEqual(decideOnboardingCache({
identity,
observedVersion: identity.pendingVersion,
generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS,
nowMs,
cachedPayload: null,
}), {
kind: "claim",
expectedVersion: identity.pendingVersion,
pendingVersion: identity.pendingVersion,
});
});
test("pending claims with null or invalid generation timestamps are reclaimed", () => {
// Given: null and invalid timestamps have both been normalized to non-finite milliseconds.
const identity = createOnboardingCacheIdentity(profileA);
const observations = [Number.NaN, Date.parse("not-a-timestamp")];
// When/Then: neither timestamp can keep a pending claim active.
for (const generatedAtMs of observations) {
assert.deepEqual(decideOnboardingCache({
identity,
observedVersion: identity.pendingVersion,
generatedAtMs,
nowMs: Date.parse("2026-07-19T10:03:00.000Z"),
cachedPayload: null,
}), {
kind: "claim",
expectedVersion: identity.pendingVersion,
pendingVersion: identity.pendingVersion,
});
}
});
+87
View File
@@ -0,0 +1,87 @@
import type {
OnboardingClaimCommand,
OnboardingCompletionCommand,
OnboardingProfileRepository,
OnboardingProfileRow,
} from "../src/lib/onboarding-post.ts";
type ProfilePatch = Partial<Omit<OnboardingProfileRow, "id">>;
export class StatefulOnboardingProfileRepository implements OnboardingProfileRepository {
private row: OnboardingProfileRow;
private nextClaimInterference: ProfilePatch | null = null;
constructor(row: OnboardingProfileRow) {
this.row = structuredClone(row);
}
setProfile(patch: ProfilePatch): void {
this.row = { ...this.row, ...structuredClone(patch) };
}
interfereBeforeNextClaim(patch: ProfilePatch): void {
this.nextClaimInterference = structuredClone(patch);
}
snapshot(): OnboardingProfileRow {
return structuredClone(this.row);
}
async loadProfile(userId: string) {
return this.row.id === userId
? { data: this.snapshot(), error: null }
: { data: null, error: null };
}
async claimProfile(command: OnboardingClaimCommand) {
if (this.nextClaimInterference) {
this.setProfile(this.nextClaimInterference);
this.nextClaimInterference = null;
}
const ownsObservedRow = this.row.id === command.userId
&& this.row.onboarding_version === command.expectedVersion
&& this.row.onboarding_generated_at === command.expectedGeneratedAt;
if (!ownsObservedRow) return { data: null, error: null };
this.row = {
...this.row,
onboarding_version: command.pendingVersion,
onboarding_generated_at: command.claimedAt,
};
return { data: { id: this.row.id }, error: null };
}
async completeProfile(command: OnboardingCompletionCommand) {
const ownsPendingRow = this.row.id === command.userId
&& this.row.onboarding_version === command.expectedPendingVersion;
if (!ownsPendingRow) return { data: null, error: null };
this.row = {
...this.row,
onboarding_payload: structuredClone(command.payload),
onboarding_version: command.readyVersion,
onboarding_generated_at: command.generatedAt,
};
return { data: { id: this.row.id }, error: null };
}
}
export function completeProfileRow(
patch: ProfilePatch = {},
): OnboardingProfileRow {
return {
id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
name: "林遥",
birth_date: "1990-06-15",
birth_time: "12:30",
active_birth_time: "12:30",
birth_time_status: "confirmed",
country_code: "CN",
province_code: "110000",
city_code: "110100",
onboarding_payload: null,
onboarding_version: null,
onboarding_generated_at: null,
...structuredClone(patch),
};
}
+172
View File
@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import test from "node:test";
import { z } from "zod";
import { createOnboardingCacheIdentity } from "../src/lib/onboarding-cache-policy.ts";
import { createOnboardingPost } from "../src/lib/onboarding-post.ts";
import {
completeProfileRow,
StatefulOnboardingProfileRepository,
} from "./onboarding-route-fake.ts";
const payloadA = {
greeting: "林遥,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "林遥的事业方向是什么?" },
{ theme: "marriage", text: "林遥的关系模式是什么?" },
{ theme: "timing", text: "林遥何时适合采取行动?" },
],
} as const;
const payloadB = {
greeting: "周宁,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "周宁的事业方向是什么?" },
{ theme: "marriage", text: "周宁的关系模式是什么?" },
{ theme: "timing", text: "周宁何时适合采取行动?" },
],
} as const;
function generatedText(payload: typeof payloadA | typeof payloadB): string {
return JSON.stringify(payload);
}
function deferred<Value>() {
let settle: (value: Value) => void = () => undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return { promise, resolve: settle } as const;
}
const responseBodySchema = z.object({
greeting: z.string(),
suggestions: z.array(z.object({ theme: z.string(), text: z.string() })),
source: z.enum(["agent", "cache", "fallback", "pending"]),
});
async function responseBody(response: Response): Promise<z.infer<typeof responseBodySchema>> {
return responseBodySchema.parse(await response.json());
}
function createPost(
repository: StatefulOnboardingProfileRepository,
generateText: (name: string) => Promise<string | null>,
) {
return createOnboardingPost({
openSession: async () => ({
userId: repository.snapshot().id,
authError: false,
repository,
}),
generateText,
now: () => new Date("2026-07-19T10:00:00.000Z"),
warn: () => undefined,
});
}
test("stale A generation returns pending after profile B replaces its claim", async () => {
// Given: A owns a claim whose generation remains in flight.
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
const generationA = deferred<string | null>();
const generationAStarted = deferred<void>();
const post = createPost(
repository,
async (name) => {
if (name === "林遥") {
generationAStarted.resolve();
return generationA.promise;
}
return generatedText(payloadB);
},
);
const responseA = post();
await generationAStarted.promise;
// When: the persisted profile changes to B, B claims/completes, then A finishes.
repository.setProfile({ name: "周宁" });
const bodyB = await responseBody(await post());
generationA.resolve(generatedText(payloadA));
const bodyA = await responseBody(await responseA);
// Then: B remains cached and A is provisional, never a stale terminal payload.
assert.deepEqual(bodyB, { ...payloadB, source: "agent" });
assert.equal(bodyA.source, "pending");
assert.doesNotMatch(JSON.stringify(bodyA), /林遥/);
assert.deepEqual(repository.snapshot().onboarding_payload, payloadB);
});
test("profile B replaces profile A ready cache instead of returning A content", async () => {
// Given: A has a ready cache, then the persisted profile changes to B.
const identityA = createOnboardingCacheIdentity({
name: "林遥", birthDate: "1990-06-15", birthTime: "12:30", activeBirthTime: "12:30",
birthTimeStatus: "confirmed", countryCode: "CN", provinceCode: "110000", cityCode: "110100",
});
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
onboarding_payload: payloadA,
onboarding_version: identityA.readyVersion,
onboarding_generated_at: "2026-07-19T09:59:00.000Z",
}));
repository.setProfile({ name: "周宁" });
const post = createPost(repository, async () => generatedText(payloadB));
// When: B requests onboarding through the real handler seam.
const body = await responseBody(await post());
// Then: B is generated and cached; A's ready payload is never returned.
assert.deepEqual(body, { ...payloadB, source: "agent" });
assert.deepEqual(repository.snapshot().onboarding_payload, payloadB);
});
test("profile B replaces profile A active pending claim instead of waiting on A", async () => {
// Given: A has a fresh pending claim, then B changes the active birth time.
const profile = completeProfileRow();
const identityA = createOnboardingCacheIdentity({
name: profile.name, birthDate: profile.birth_date, birthTime: profile.birth_time,
activeBirthTime: profile.active_birth_time, birthTimeStatus: profile.birth_time_status,
countryCode: profile.country_code, provinceCode: profile.province_code, cityCode: profile.city_code,
});
const repository = new StatefulOnboardingProfileRepository({
...profile,
onboarding_version: identityA.pendingVersion,
onboarding_generated_at: "2026-07-19T09:59:30.000Z",
});
repository.setProfile({ name: "周宁", active_birth_time: "12:45" });
let generatedFor = "";
const post = createPost(repository, async (name) => {
generatedFor = name;
return generatedText(payloadB);
});
// When: B requests onboarding within A's TTL.
const body = await responseBody(await post());
// Then: B claims and completes immediately rather than receiving pending for A.
assert.equal(generatedFor, "周宁");
assert.deepEqual(body, { ...payloadB, source: "agent" });
});
for (const interference of [
{ name: "observed version", patch: { onboarding_version: "concurrent-version" } },
{ name: "observed timestamp", patch: { onboarding_generated_at: "2026-07-19T09:58:00.000Z" } },
] as const) {
test(`claim loses when a concurrent writer changes the ${interference.name}`, async () => {
// Given: another writer changes one observed CAS field just before the claim.
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
onboarding_version: "legacy-ready",
onboarding_generated_at: "2026-07-19T09:59:00.000Z",
}));
repository.interfereBeforeNextClaim(interference.patch);
let generationCount = 0;
const post = createPost(repository, async () => {
generationCount += 1;
return generatedText(payloadA);
});
// When: the handler attempts its observed-row claim.
const body = await responseBody(await post());
// Then: compare-and-set loses provisionally and generation never starts.
assert.equal(body.source, "pending");
assert.equal(generationCount, 0);
});
}
+26 -5
View File
@@ -1,10 +1,14 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PAGE = ROOT / "frontend" / "src" / "app" / "page.tsx"
AGENT = ROOT / "frontend" / "src" / "mastra" / "index.ts"
ONBOARDING_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "onboarding" / "route.ts"
ONBOARDING_CLIENT = ROOT / "frontend" / "src" / "lib" / "onboarding-client.ts"
ONBOARDING_POST = ROOT / "frontend" / "src" / "lib" / "onboarding-post.ts"
ONBOARDING_CACHE_POLICY = (
ROOT / "frontend" / "src" / "lib" / "onboarding-cache-policy.ts"
)
CONSULT_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "consult" / "route.ts"
MODELS_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "models" / "route.ts"
MODEL_SELECTION = ROOT / "frontend" / "src" / "lib" / "consultation-model-selection.ts"
@@ -24,6 +28,9 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
page = PAGE.read_text(encoding="utf-8")
agent = AGENT.read_text(encoding="utf-8")
route = ONBOARDING_ROUTE.read_text(encoding="utf-8")
onboarding_client = ONBOARDING_CLIENT.read_text(encoding="utf-8")
onboarding_post = ONBOARDING_POST.read_text(encoding="utf-8")
cache_policy = ONBOARDING_CACHE_POLICY.read_text(encoding="utf-8")
consult_route = CONSULT_ROUTE.read_text(encoding="utf-8")
migration = ONBOARDING_MIGRATION.read_text(encoding="utf-8")
@@ -39,7 +46,7 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
assert "<BirthTimeIntakeFields value={value}" in page
assert "<BirthLocationFields value={profileDraft}" in page
assert "Enter 确认称呼" in page
assert 'fetch("/api/onboarding"' in page
assert 'fetch("/api/onboarding"' in onboarding_client
assert "onboarding?.suggestions" in page
assert "parseAgentReply(answer, theme)" in page
assert "suggestions: reply.suggestions" in page
@@ -56,9 +63,23 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
assert "function currentTimeContext(now = new Date())" in consult_route
assert "currentTimeContext(requestTime)," in consult_route
assert "中国标准时间(UTC+8)" in consult_route
assert 'profile.onboarding_version === ONBOARDING_VERSION' in route
assert "getOnboardingAgent(onboardingModel).generate" in route
assert 'source: "cache"' in route
assert "export const POST = createOnboardingPost" in route
assert 'claim.is("onboarding_version", null)' in route
assert '.eq("onboarding_version", command.expectedVersion)' in route
assert 'claim.is("onboarding_generated_at", null)' in route
assert '.eq("onboarding_generated_at", command.expectedGeneratedAt)' in route
assert (
'.eq("onboarding_version", command.expectedPendingVersion)\n'
' .select("id")\n'
" .maybeSingle();"
) in route
assert "createOnboardingCacheIdentity" in onboarding_post
assert "expectedGeneratedAt: profile.onboarding_generated_at" in onboarding_post
assert "completed.data" in onboarding_post
assert 'source: "cache"' in onboarding_post
assert 'source: "pending"' in onboarding_post
assert 'createHash("sha256")' in cache_policy
assert "getOnboardingAgent(model).generate" in route
assert "onboarding_payload" in migration
assert "to service_role" in migration
assert "to authenticated" not in migration