fix: confirm dynamic birth-time candidates
This commit is contained in:
@@ -184,6 +184,15 @@ export async function POST(request: Request) {
|
||||
resultId: parsed.data.resultId,
|
||||
time: parsed.data.time,
|
||||
}), "turn_advanced");
|
||||
case "confirm_dynamic_candidate":
|
||||
return responseWithJourneyMetric(service.confirmDynamicCandidate({
|
||||
userId: user.id,
|
||||
caseId: parsed.data.caseId,
|
||||
actionId: parsed.data.actionId,
|
||||
expectedVersion: parsed.data.turnVersion,
|
||||
resultId: parsed.data.resultId,
|
||||
time: parsed.data.time,
|
||||
}), "turn_advanced");
|
||||
default: {
|
||||
const exhaustive: never = parsed.data;
|
||||
return exhaustive;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
answerDynamicBirthTimeChoice,
|
||||
confirmDynamicBirthTimeCandidate,
|
||||
confirmBirthTimeEvidenceDraft,
|
||||
draftBirthTimeEvidence,
|
||||
finishBirthTimeRectification,
|
||||
@@ -225,8 +226,10 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
|
||||
(actionId) => saveGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId }),
|
||||
));
|
||||
const confirmCandidate = (resultId: string, time: string) => operate((turn) => preview ? Promise.resolve(turn) : stableAction(
|
||||
turn, "confirm_guided_candidate", [resultId, time],
|
||||
(actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
|
||||
turn, turn.journeyProtocol === "dynamic-choice-v2" ? "confirm_dynamic_candidate" : "confirm_guided_candidate", [resultId, time],
|
||||
(actionId) => turn.journeyProtocol === "dynamic-choice-v2"
|
||||
? confirmDynamicBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time })
|
||||
: confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }),
|
||||
));
|
||||
const selectOption = (optionId: string) => operate((turn) => {
|
||||
if (turn.journeyProtocol !== "dynamic-choice-v2"
|
||||
|
||||
@@ -20,6 +20,7 @@ export type DynamicActionReceipt = ReceiptBase & (
|
||||
| { readonly kind: "pause" }
|
||||
| { readonly kind: "finish" }
|
||||
| { readonly kind: "resume" }
|
||||
| { readonly kind: "confirm_candidate"; readonly resultId: string; readonly time: string }
|
||||
);
|
||||
|
||||
const receiptBase = {
|
||||
@@ -64,4 +65,10 @@ export const dynamicActionReceiptSchema: z.ZodType<DynamicActionReceipt> = z.uni
|
||||
z.object({ ...receiptBase, kind: z.literal("pause") }).strict(),
|
||||
z.object({ ...receiptBase, kind: z.literal("finish") }).strict(),
|
||||
z.object({ ...receiptBase, kind: z.literal("resume") }).strict(),
|
||||
z.object({
|
||||
...receiptBase,
|
||||
kind: z.literal("confirm_candidate"),
|
||||
resultId: z.string().uuid(),
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
}).strict(),
|
||||
]).readonly();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { withConfirmedCandidate } from "./birth-time-evidence.ts";
|
||||
import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts";
|
||||
import { withDynamicAction } from "./birth-time-dynamic-transitions.ts";
|
||||
import { BirthTimeDynamicActionError } from "./birth-time-dynamic-actions.ts";
|
||||
import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts";
|
||||
import type {
|
||||
BirthTimeJourneyPorts,
|
||||
DynamicCandidateConfirmationCommand,
|
||||
DynamicStoredRectificationCase,
|
||||
} from "./birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
|
||||
|
||||
function stale(stored: DynamicStoredRectificationCase, expectedVersion: number) {
|
||||
return new StaleJourneyTurnError(stored.id, expectedVersion, stored.turnVersion);
|
||||
}
|
||||
|
||||
export function createDynamicCandidateConfirmation(ports: BirthTimeJourneyPorts) {
|
||||
return {
|
||||
async confirm(input: DynamicCandidateConfirmationCommand) {
|
||||
const stored = await ports.store.loadCase(input.userId, input.caseId);
|
||||
if (!stored) throw new BirthTimeDynamicActionError("case_not_found");
|
||||
if (stored.journeyProtocol !== "dynamic-choice-v2") {
|
||||
throw new BirthTimeDynamicActionError("invalid_turn");
|
||||
}
|
||||
const receipt = stored.dynamicControl.lastActionReceipt;
|
||||
if (replayedDynamicAction(stored, input.actionId, input.expectedVersion, () => (
|
||||
stored.dynamicTurnState.nextAction.kind === "ready"
|
||||
&& stored.dynamicTurnState.nextAction.activeTime === input.time
|
||||
&& stored.snapshot.activeTime === input.time
|
||||
&& receipt?.kind === "confirm_candidate"
|
||||
&& receipt.actionId === input.actionId.toLowerCase()
|
||||
&& receipt.turnVersion === input.expectedVersion
|
||||
&& receipt.resultId === input.resultId
|
||||
&& receipt.time === input.time
|
||||
))) return storedDynamicJourneyResponse(stored);
|
||||
|
||||
const action = stored.dynamicTurnState.nextAction;
|
||||
const candidate = stored.candidateResult;
|
||||
if (
|
||||
stored.turnVersion !== input.expectedVersion
|
||||
|| action.kind !== "request_candidate_confirmation"
|
||||
|| action.resultId !== input.resultId
|
||||
|| !stored.dynamicTurnState.permissions.canConfirmCandidate
|
||||
|| candidate?.resultId !== input.resultId
|
||||
|| candidate.canApply !== true
|
||||
|| candidate.winningSegment?.representativeTime !== input.time
|
||||
) throw stale(stored, input.expectedVersion);
|
||||
|
||||
const transitioned = withDynamicAction(
|
||||
stored,
|
||||
{ kind: "ready", activeTime: input.time },
|
||||
input.expectedVersion + 1,
|
||||
);
|
||||
const updated = {
|
||||
...transitioned,
|
||||
snapshot: withConfirmedCandidate(stored.snapshot, candidate, input.time),
|
||||
currentChoiceQuestion: null,
|
||||
dynamicTurnState: {
|
||||
...transitioned.dynamicTurnState,
|
||||
permissions: { canConfirmCandidate: false },
|
||||
},
|
||||
dynamicControl: {
|
||||
...stored.dynamicControl,
|
||||
lastActionReceipt: {
|
||||
actionId: input.actionId.toLowerCase(),
|
||||
kind: "confirm_candidate" as const,
|
||||
turnVersion: input.expectedVersion,
|
||||
resultId: input.resultId,
|
||||
time: input.time,
|
||||
},
|
||||
},
|
||||
} satisfies DynamicStoredRectificationCase;
|
||||
return storedDynamicJourneyResponse(await ports.store.confirmDynamicCandidate(updated, input));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -161,6 +161,16 @@ export function confirmBirthTimeCandidate(
|
||||
return sendJourneyEvent({ type: "confirm_candidate", caseId, resultId, time });
|
||||
}
|
||||
|
||||
export function confirmDynamicBirthTimeCandidate(input: {
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly turnVersion: number;
|
||||
readonly resultId: string;
|
||||
readonly time: string;
|
||||
}) {
|
||||
return sendJourneyEvent({ type: "confirm_dynamic_candidate", ...input });
|
||||
}
|
||||
|
||||
export function confirmBirthTimeEvidenceDraft(
|
||||
caseId: string,
|
||||
actionId: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol
|
||||
import type {
|
||||
DynamicScoringJobCommand,
|
||||
DynamicScoringJobFailureCommand,
|
||||
DynamicCandidateConfirmationCommand,
|
||||
DynamicStoredRectificationCase,
|
||||
LegacyStoredRectificationCase,
|
||||
StoredRectificationCase,
|
||||
@@ -72,6 +73,8 @@ function privateState(value: DynamicStoredRectificationCase): DynamicPrivateJour
|
||||
function isStaleRpc(error: RpcError): boolean {
|
||||
return error.message.includes("stale_birth_time_dynamic_turn")
|
||||
|| error.message.includes("stale_birth_time_dynamic_scoring_job")
|
||||
|| error.message.includes("stale_birth_time_dynamic_candidate")
|
||||
|| error.message.includes("birth_time_dynamic_candidate_invalid")
|
||||
|| error.message.includes("stale_birth_time_legacy_upgrade");
|
||||
}
|
||||
|
||||
@@ -126,6 +129,45 @@ export function createDynamicTurnPersistence(
|
||||
}
|
||||
|
||||
return {
|
||||
async confirmDynamicCandidate(
|
||||
value: DynamicStoredRectificationCase,
|
||||
command: DynamicCandidateConfirmationCommand,
|
||||
): Promise<DynamicStoredRectificationCase> {
|
||||
const receipt = actionIdSchema.parse(command.actionId).toLowerCase();
|
||||
const result = await client.rpc("confirm_birth_time_dynamic_candidate", {
|
||||
p_user_id: value.userId,
|
||||
p_case_id: value.id,
|
||||
p_result_id: command.resultId,
|
||||
p_time: command.time,
|
||||
p_action_id: receipt,
|
||||
p_expected_version: command.expectedVersion,
|
||||
p_snapshot: value.snapshot,
|
||||
p_turn_state: publicTurn(value, command.expectedVersion + 1),
|
||||
});
|
||||
const current = await loadCase(value.userId, value.id);
|
||||
if (result.error) {
|
||||
if (current?.journeyProtocol === "dynamic-choice-v2"
|
||||
&& current.processedActionIds.includes(receipt)
|
||||
&& samePersistedDynamicReceipt(value, current, receipt, command.expectedVersion)) {
|
||||
return current;
|
||||
}
|
||||
if (isStaleRpc(result.error)) {
|
||||
throw new StaleJourneyTurnError(value.id, command.expectedVersion, current?.turnVersion ?? 0);
|
||||
}
|
||||
throw new BirthTimeJourneyStoreError("update_case");
|
||||
}
|
||||
const version = rpcVersionSchema.safeParse(result.data);
|
||||
if (!current || current.journeyProtocol !== "dynamic-choice-v2") {
|
||||
throw new BirthTimeJourneyStoreError("load_case");
|
||||
}
|
||||
if (!version.success || version.data !== command.expectedVersion + 1
|
||||
|| !current.processedActionIds.includes(receipt)
|
||||
|| !samePersistedDynamicReceipt(value, current, receipt, command.expectedVersion)) {
|
||||
throw new StaleJourneyTurnError(value.id, command.expectedVersion, current.turnVersion);
|
||||
}
|
||||
return current;
|
||||
},
|
||||
|
||||
async saveDynamicTurn(
|
||||
value: DynamicStoredRectificationCase,
|
||||
expectedVersion: number,
|
||||
|
||||
@@ -72,6 +72,12 @@ export const birthTimeJourneyRequestSchema = z.discriminatedUnion("type", [
|
||||
resultId: z.string().uuid(),
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal("confirm_dynamic_candidate"),
|
||||
...mutationFields,
|
||||
resultId: z.string().uuid(),
|
||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||
}).strict(),
|
||||
]).superRefine((value, context) => {
|
||||
if (value.type === "revise_evidence_draft" && !lifeEventSchema.safeParse({
|
||||
id: revisionValidationId,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { TimeRange } from "./birth-time-dynamic-choice.ts";
|
||||
import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts";
|
||||
import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts";
|
||||
import { createDynamicJourneyMethods } from "./birth-time-dynamic-service-methods.ts";
|
||||
import { createDynamicCandidateConfirmation } from "./birth-time-dynamic-candidate-confirmation.ts";
|
||||
|
||||
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
|
||||
|
||||
@@ -139,12 +140,22 @@ export type DynamicScoringJobCommand = {
|
||||
|
||||
export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand & { readonly failureCode: string };
|
||||
|
||||
export type DynamicCandidateConfirmationCommand = {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly actionId: string;
|
||||
readonly expectedVersion: number;
|
||||
readonly resultId: string;
|
||||
readonly time: string;
|
||||
};
|
||||
|
||||
export interface BirthTimeJourneyStore {
|
||||
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
|
||||
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
|
||||
saveScoring(value: LegacyStoredRectificationCase): Promise<void>;
|
||||
saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
|
||||
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
|
||||
confirmDynamicCandidate(value: DynamicStoredRectificationCase, command: DynamicCandidateConfirmationCommand): Promise<DynamicStoredRectificationCase>;
|
||||
completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise<DynamicStoredRectificationCase>;
|
||||
failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise<DynamicStoredRectificationCase>;
|
||||
createDynamicScoringJob?(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string, questionId: string, job: DynamicScoringJobSpec): Promise<DynamicStoredRectificationCase>;
|
||||
@@ -193,6 +204,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
|
||||
const guidedCandidates = createGuidedCandidateActions(ports);
|
||||
const draftRevisions = createGuidedDraftRevisionActions(ports, turnActions.proposeEvidenceDraft);
|
||||
const dynamicMethods = createDynamicJourneyMethods(ports);
|
||||
const dynamicCandidates = createDynamicCandidateConfirmation(ports);
|
||||
return {
|
||||
async assess(userId: string, assessment: BirthTimeAssessment): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
|
||||
const scan = await scanAssessment(ports.engine, assessment);
|
||||
@@ -276,6 +288,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
|
||||
reviseEvidenceDraft: draftRevisions.revise,
|
||||
saveGuidedCandidate: guidedCandidates.save,
|
||||
confirmGuidedCandidate: guidedCandidates.confirm,
|
||||
confirmDynamicCandidate: dynamicCandidates.confirm,
|
||||
pollScoringJob: scoringActions.pollScoringJob,
|
||||
...dynamicMethods,
|
||||
};
|
||||
|
||||
+125
@@ -5,3 +5,128 @@ create policy chat_sessions_delete_own
|
||||
using ((select auth.uid()) = user_id);
|
||||
grant delete on table public.chat_sessions to authenticated;
|
||||
commit;
|
||||
|
||||
begin;
|
||||
|
||||
create function public.confirm_birth_time_dynamic_candidate(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_result_id uuid,
|
||||
p_time time without time zone,
|
||||
p_action_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_snapshot jsonb,
|
||||
p_turn_state jsonb
|
||||
)
|
||||
returns bigint
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_private public.birth_time_rectification_dynamic_state%rowtype;
|
||||
v_new_version bigint;
|
||||
v_time text := to_char(p_time, 'HH24:MI');
|
||||
v_receipt jsonb := jsonb_build_object(
|
||||
'actionId', p_action_id::text,
|
||||
'kind', 'confirm_candidate',
|
||||
'turnVersion', p_expected_version,
|
||||
'resultId', p_result_id::text,
|
||||
'time', to_char(p_time, 'HH24:MI')
|
||||
);
|
||||
begin
|
||||
select c.* into v_case
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
for update;
|
||||
if not found or v_case.journey_protocol is distinct from 'dynamic-choice-v2' then
|
||||
raise exception 'birth_time_dynamic_case_not_found';
|
||||
end if;
|
||||
|
||||
select s.* into v_private
|
||||
from public.birth_time_rectification_dynamic_state s
|
||||
where s.case_id = p_case_id and s.user_id = p_user_id
|
||||
for update;
|
||||
if not found then raise exception 'birth_time_dynamic_private_state_missing'; end if;
|
||||
|
||||
if p_action_id = any(v_case.processed_action_ids) then
|
||||
if v_case.turn_version is distinct from p_expected_version + 1
|
||||
or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'ready'
|
||||
or v_case.turn_state #>> '{nextAction,activeTime}' is distinct from v_time
|
||||
or v_case.journey_snapshot ->> 'activeTime' is distinct from v_time
|
||||
or v_private.dynamic_control -> 'lastActionReceipt' is distinct from v_receipt then
|
||||
raise exception 'stale_birth_time_dynamic_candidate';
|
||||
end if;
|
||||
return v_case.turn_version;
|
||||
end if;
|
||||
|
||||
if v_case.turn_version is distinct from p_expected_version
|
||||
or v_case.status is distinct from 'confirming'
|
||||
or v_case.journey_snapshot ->> 'state' is distinct from 'confirming'
|
||||
or v_case.journey_snapshot ->> 'input' is distinct from 'candidate_confirmation'
|
||||
or v_case.journey_snapshot ->> 'activeTime' is not null
|
||||
or (v_case.journey_snapshot ->> 'canApply')::boolean is not true
|
||||
or v_case.candidate_result_id is distinct from p_result_id
|
||||
or v_case.candidate_result ->> 'confidence' is distinct from 'high'
|
||||
or (v_case.candidate_result ->> 'canApply')::boolean is not true
|
||||
or v_case.candidate_result #>> '{winningSegment,representativeTime}' is distinct from v_time
|
||||
or v_case.turn_state #>> '{journeyProtocol}' is distinct from 'dynamic-choice-v2'
|
||||
or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'request_candidate_confirmation'
|
||||
or v_case.turn_state #>> '{nextAction,resultId}' is distinct from p_result_id::text
|
||||
or (v_case.turn_state #>> '{permissions,canConfirmCandidate}')::boolean is not true
|
||||
or p_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
|
||||
or (p_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1
|
||||
or p_turn_state #>> '{nextAction,kind}' is distinct from 'ready'
|
||||
or p_turn_state #>> '{nextAction,activeTime}' is distinct from v_time
|
||||
or p_turn_state #>> '{progress,phase}' is distinct from 'ready'
|
||||
or (p_turn_state #>> '{permissions,canConfirmCandidate}')::boolean is not false
|
||||
or p_snapshot ->> 'state' is distinct from 'ready'
|
||||
or p_snapshot ->> 'route' is distinct from 'direct_chart'
|
||||
or p_snapshot ->> 'activeTime' is distinct from v_time
|
||||
or (p_snapshot ->> 'canApply')::boolean is not false then
|
||||
raise exception 'birth_time_dynamic_candidate_invalid';
|
||||
end if;
|
||||
|
||||
update public.birth_time_rectification_cases
|
||||
set status = 'confirmed',
|
||||
journey_snapshot = p_snapshot,
|
||||
confirmed_time = p_time,
|
||||
confirmed_at = now(),
|
||||
turn_version = p_expected_version + 1,
|
||||
turn_state = p_turn_state,
|
||||
evidence_draft = null,
|
||||
processed_action_ids = case when cardinality(processed_action_ids) >= 100
|
||||
then processed_action_ids[2:100] || p_action_id
|
||||
else processed_action_ids || p_action_id end,
|
||||
updated_at = now()
|
||||
where id = p_case_id and user_id = p_user_id and turn_version = p_expected_version
|
||||
returning turn_version into v_new_version;
|
||||
if v_new_version is null then raise exception 'stale_birth_time_dynamic_candidate'; end if;
|
||||
|
||||
update public.birth_time_rectification_dynamic_state
|
||||
set dynamic_control = jsonb_set(dynamic_control, '{lastActionReceipt}', v_receipt, true),
|
||||
updated_at = now()
|
||||
where case_id = p_case_id and user_id = p_user_id;
|
||||
if not found then raise exception 'birth_time_dynamic_private_state_missing'; end if;
|
||||
|
||||
update public.profiles
|
||||
set active_birth_time = p_time,
|
||||
birth_time = p_time,
|
||||
birth_time_status = 'confirmed',
|
||||
rectification_case_id = p_case_id
|
||||
where id = p_user_id;
|
||||
if not found then raise exception 'birth_time_profile_not_found'; end if;
|
||||
|
||||
return v_new_version;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.confirm_birth_time_dynamic_candidate(
|
||||
uuid, uuid, uuid, time without time zone, uuid, bigint, jsonb, jsonb
|
||||
) from public, anon, authenticated;
|
||||
grant execute on function public.confirm_birth_time_dynamic_candidate(
|
||||
uuid, uuid, uuid, time without time zone, uuid, bigint, jsonb, jsonb
|
||||
) to service_role;
|
||||
|
||||
commit;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createJourneyTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import {
|
||||
actionIds,
|
||||
@@ -12,6 +13,57 @@ import {
|
||||
journeyCaseId,
|
||||
reachScoring,
|
||||
} from "./birth-time-agent-flow-test-support.ts";
|
||||
import { actionId as dynamicActionId, dynamicCase, ownerId } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
|
||||
test("dynamic candidate confirmation remains ready after resume", async () => {
|
||||
const result = candidate("high", "90000000-0000-4000-8000-000000000001");
|
||||
const current = dynamicCase();
|
||||
const memory = memoryStore({
|
||||
...current,
|
||||
candidateResult: result,
|
||||
snapshot: {
|
||||
...current.snapshot,
|
||||
state: "confirming",
|
||||
assistantIntent: "confirm_candidate_time",
|
||||
input: "candidate_confirmation",
|
||||
confidence: "high",
|
||||
canApply: true,
|
||||
},
|
||||
currentChoiceQuestion: null,
|
||||
dynamicTurnState: {
|
||||
...current.dynamicTurnState,
|
||||
nextAction: { kind: "request_candidate_confirmation", resultId: result.resultId },
|
||||
progress: { ...current.dynamicTurnState.progress, phase: "result" },
|
||||
permissions: { canConfirmCandidate: true },
|
||||
},
|
||||
});
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memory.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { throw new Error("unexpected packet"); },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
|
||||
const confirmation = await service.confirmDynamicCandidate({
|
||||
userId: ownerId,
|
||||
caseId: current.id,
|
||||
actionId: dynamicActionId,
|
||||
expectedVersion: current.turnVersion,
|
||||
resultId: result.resultId,
|
||||
time: result.winningSegment?.representativeTime ?? "",
|
||||
});
|
||||
const confirmationResume = await service.resume(ownerId, current.id);
|
||||
|
||||
assert.equal(confirmation.nextAction.kind, "ready");
|
||||
assert.equal(confirmationResume.nextAction.kind, "ready");
|
||||
assert.equal(confirmation.snapshot.activeTime, "14:24");
|
||||
assert.equal(confirmationResume.snapshot.activeTime, "14:24");
|
||||
});
|
||||
|
||||
test("fake Agent and engine complete baseline, adaptive, low, and no-apply flow", async () => {
|
||||
const harness = createHarness({ initial: guidedCase(), result: candidate("low", "10000000-0000-4000-8000-000000000001") });
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { candidateResultSchema } from "../src/lib/birth-time-evidence.ts";
|
||||
import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
||||
import { actionId, dynamicCase, ownerId } from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
|
||||
const highCandidate = candidateResultSchema.parse({
|
||||
resultId: "f8eb3bc5-80eb-40fc-b937-e62ea37c3236",
|
||||
confidence: "high",
|
||||
canApply: true,
|
||||
winningSegment: {
|
||||
startTime: "17:13",
|
||||
endTime: "17:17",
|
||||
representativeTime: "17:15",
|
||||
widthMinutes: 5,
|
||||
},
|
||||
eventCount: 4,
|
||||
domainCount: 3,
|
||||
topScore: 18,
|
||||
secondScore: 8,
|
||||
marginPercent: 55,
|
||||
reasons: ["One segment has consistent evidence."],
|
||||
evidence: [],
|
||||
algorithmVersion: "birth-time-event-scoring-v1",
|
||||
});
|
||||
|
||||
const migration = readFileSync(new URL(
|
||||
"../supabase/migrations/20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql",
|
||||
import.meta.url,
|
||||
), "utf8");
|
||||
|
||||
function highConfidenceDynamicCase() {
|
||||
const current = dynamicCase();
|
||||
return {
|
||||
...current,
|
||||
candidateResult: highCandidate,
|
||||
snapshot: {
|
||||
...current.snapshot,
|
||||
state: "confirming" as const,
|
||||
assistantIntent: "confirm_candidate_time" as const,
|
||||
input: "candidate_confirmation" as const,
|
||||
confidence: "high" as const,
|
||||
canApply: true,
|
||||
},
|
||||
currentChoiceQuestion: null,
|
||||
dynamicTurnState: {
|
||||
...current.dynamicTurnState,
|
||||
nextAction: {
|
||||
kind: "request_candidate_confirmation" as const,
|
||||
resultId: highCandidate.resultId,
|
||||
},
|
||||
progress: { ...current.dynamicTurnState.progress, phase: "result" as const },
|
||||
permissions: { canConfirmCandidate: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("dynamic confirmation atomically reaches ready", async () => {
|
||||
const current = highConfidenceDynamicCase();
|
||||
const memory = memoryStore(current);
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memory.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { throw new Error("unexpected packet"); },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.confirmDynamicCandidate({
|
||||
userId: ownerId,
|
||||
caseId: current.id,
|
||||
actionId,
|
||||
expectedVersion: current.dynamicTurnState.turnVersion,
|
||||
resultId: current.candidateResult.resultId,
|
||||
time: current.candidateResult.winningSegment?.representativeTime ?? "",
|
||||
});
|
||||
|
||||
assert.equal(result.nextAction.kind, "ready");
|
||||
assert.equal(result.snapshot.activeTime, "17:15");
|
||||
});
|
||||
|
||||
test("dynamic confirmation replays only its exact receipt and rejects stale versions", async () => {
|
||||
const current = highConfidenceDynamicCase();
|
||||
const memory = memoryStore(current);
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memory.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { throw new Error("unexpected packet"); },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
const command = {
|
||||
userId: ownerId,
|
||||
caseId: current.id,
|
||||
actionId,
|
||||
expectedVersion: current.turnVersion,
|
||||
resultId: highCandidate.resultId,
|
||||
time: "17:15",
|
||||
};
|
||||
|
||||
const first = await service.confirmDynamicCandidate(command);
|
||||
const replay = await service.confirmDynamicCandidate(command);
|
||||
|
||||
assert.deepEqual(replay, first);
|
||||
assert.equal(memory.committedTurnWrites(), 1);
|
||||
await assert.rejects(service.confirmDynamicCandidate({
|
||||
...command,
|
||||
actionId: "f3a64be6-65d3-498c-a86d-847cf104e594",
|
||||
}), StaleJourneyTurnError);
|
||||
});
|
||||
|
||||
test("dynamic confirmation rejects a non-representative minute before writing", async () => {
|
||||
const current = highConfidenceDynamicCase();
|
||||
const memory = memoryStore(current);
|
||||
const service = createBirthTimeJourneyService({
|
||||
store: memory.store,
|
||||
engine: {
|
||||
async scan() { throw new Error("unexpected scan"); },
|
||||
async score() { throw new Error("unexpected score"); },
|
||||
async scoreEvents() { throw new Error("unexpected event score"); },
|
||||
async buildDifferencePacket() { throw new Error("unexpected packet"); },
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(service.confirmDynamicCandidate({
|
||||
userId: ownerId,
|
||||
caseId: current.id,
|
||||
actionId,
|
||||
expectedVersion: current.turnVersion,
|
||||
resultId: highCandidate.resultId,
|
||||
time: "17:14",
|
||||
}), StaleJourneyTurnError);
|
||||
assert.equal(memory.committedTurnWrites(), 0);
|
||||
});
|
||||
|
||||
test("dynamic confirmation request accepts only its strict receipt, version, result, and time", () => {
|
||||
const valid = {
|
||||
type: "confirm_dynamic_candidate",
|
||||
caseId: dynamicCase().id,
|
||||
actionId,
|
||||
turnVersion: 7,
|
||||
resultId: highCandidate.resultId,
|
||||
time: "17:15",
|
||||
} as const;
|
||||
|
||||
assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true);
|
||||
assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, time: "17:15:00" }).success, false);
|
||||
assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, turnVersion: -1 }).success, false);
|
||||
assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, candidateResult: highCandidate }).success, false);
|
||||
});
|
||||
|
||||
test("dynamic confirmation RPC locks the v2 case and is service-role only", () => {
|
||||
assert.match(migration, /create function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?returns bigint/i);
|
||||
assert.match(migration, /for update[\s\S]*journey_protocol is distinct from 'dynamic-choice-v2'/i);
|
||||
assert.match(migration, /p_action_id = any\(v_case\.processed_action_ids\)/i);
|
||||
assert.match(migration, /candidate_result_id is distinct from p_result_id[\s\S]*representativeTime/i);
|
||||
assert.match(migration, /turn_version = p_expected_version \+ 1[\s\S]*turn_state = p_turn_state/i);
|
||||
assert.match(migration, /set active_birth_time = p_time,[\s\S]*birth_time_status = 'confirmed',[\s\S]*rectification_case_id = p_case_id/i);
|
||||
assert.match(migration, /revoke all on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?from public, anon, authenticated;[\s\S]*?grant execute on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?to service_role;/i);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
reviseBirthTimeEvidenceDraft,
|
||||
saveGuidedBirthTimeCandidate,
|
||||
} from "../src/lib/birth-time-guided-client.ts";
|
||||
import { confirmDynamicBirthTimeCandidate } from "../src/lib/birth-time-journey-client.ts";
|
||||
import { birthTimeJourneyRequestSchema } from "../src/lib/birth-time-journey-request.ts";
|
||||
import { highConfirmationTurn } from "./birth-time-journey-client-test-support.ts";
|
||||
|
||||
@@ -60,11 +61,13 @@ test("guided client emits only deterministic mutation fields", async (context) =
|
||||
await reviseBirthTimeEvidenceDraft({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, precision: "month", date: "2019-07" });
|
||||
await saveGuidedBirthTimeCandidate({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId });
|
||||
await confirmGuidedBirthTimeCandidate({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" });
|
||||
await confirmDynamicBirthTimeCandidate({ caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" });
|
||||
|
||||
assert.deepEqual(payloads, [
|
||||
{ type: "revise_evidence_draft", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, precision: "month", date: "2019-07" },
|
||||
{ type: "save_guided_candidate", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId },
|
||||
{ type: "confirm_guided_candidate", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" },
|
||||
{ type: "confirm_dynamic_candidate", caseId: highConfirmationTurn.caseId, actionId, turnVersion: 2, resultId: highConfirmationTurn.candidateResult.resultId, time: "14:24" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -150,6 +150,29 @@ export function memoryStore(
|
||||
committedTurnWrites += 1;
|
||||
return savedDynamic;
|
||||
},
|
||||
async confirmDynamicCandidate(value, command) {
|
||||
if (!savedCase || !savedDynamicCase) throw new MissingTestCaseError();
|
||||
const receipt = command.actionId.toLowerCase();
|
||||
if ((savedCase.processedActionIds ?? []).includes(receipt)) {
|
||||
if (samePersistedDynamicReceipt(value, savedDynamicCase, receipt, command.expectedVersion)) {
|
||||
return savedDynamicCase;
|
||||
}
|
||||
throw new StaleJourneyTurnError(value.id, command.expectedVersion, savedDynamicCase.turnVersion);
|
||||
}
|
||||
if (savedCase.journeyProtocol !== "dynamic-choice-v2" || savedCase.turnVersion !== command.expectedVersion) {
|
||||
throw new StaleJourneyTurnError(savedCase.id, command.expectedVersion, savedCase.turnVersion ?? 0);
|
||||
}
|
||||
const saved = {
|
||||
...value,
|
||||
turnVersion: command.expectedVersion + 1,
|
||||
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: command.expectedVersion + 1 },
|
||||
processedActionIds: [...(savedCase.processedActionIds ?? []), receipt],
|
||||
};
|
||||
savedCase = saved;
|
||||
savedDynamicCase = saved;
|
||||
committedTurnWrites += 1;
|
||||
return saved;
|
||||
},
|
||||
async completeDynamicScoringJob(value, command) {
|
||||
return persistDynamicScoring(value, { kind: "complete", command, result: value.candidateResult });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user