feat: rebuild birth time rectification workflow

This commit is contained in:
Jesse_Chen
2026-07-27 09:57:14 +08:00
parent 380aba4628
commit 2ca245d643
66 changed files with 5826 additions and 699 deletions
+63 -71
View File
@@ -78,47 +78,46 @@ test("ordinary product drafts keep the public question and clear hidden routing
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
});
test("homepage birth-time card opens the v3 surface instead of ordinary consultation", () => {
test("homepage birth-time card opens the v4 evidence surface instead of ordinary consultation", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /function openBirthTimeRectification/);
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(source, /sendConversationalRectificationCommand/);
assert.match(source, /rectificationPriceCredits/);
assert.doesNotMatch(source, /\/api\/birth-rectification/);
assert.doesNotMatch(source, /\/api\/birth-time-journey/);
assert.match(component, /<RectificationV4Panel/);
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
assert.doesNotMatch(source.slice(
source.indexOf("async function openBirthTimeRectification"),
source.indexOf("function handleConversationalRectificationTurn"),
), /sendConversationalRectificationCommand/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
test("homepage birth-time card starts the first rectification turn without a second confirmation card", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
test("homepage opens the v4 panel without invoking the retired v3 start command", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/);
assert.match(
handler,
/sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"[\s\S]*?modelId:\s*rectificationSession\.modelId/,
);
assert.doesNotMatch(handler, /sendConversationalRectificationCommand/);
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(page, /<ConversationalBirthTimeRectification/);
assert.match(component, /<RectificationV4Panel/);
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
assert.match(hook, /await createRectificationV4\(\)/);
});
test("a stale start snapshot refreshes and resumes the existing unfinished case after a 409", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
test("a stale v4 mutation refreshes the same case after a 409", () => {
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
assert.match(handler, /error instanceof ConversationalRectificationRequestError/);
assert.match(handler, /error\.status !== 409/);
assert.match(handler, /const latest = await fetchAccount\(\)/);
assert.match(handler, /if \(!latest\.rectificationCase\) throw error/);
assert.match(handler, /type: "resume"[\s\S]*?latest\.rectificationCase\.caseId/);
assert.match(handler, /latest\.rectificationCase\.turnVersion/);
assert.match(hook, /caught instanceof RectificationV4RequestError && caught\.status === 409 && data/);
assert.match(hook, /await refresh\(data\.case\.id\)/);
assert.match(hook, /loadRectificationV4\(caseId\)/);
});
test("homepage birth-time card opens its dedicated session before the first turn resolves", () => {
test("homepage birth-time card opens its dedicated session before v4 data loads", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
@@ -134,36 +133,29 @@ test("homepage birth-time card opens its dedicated session before the first turn
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
assert.match(handler, /type: "start",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
assert.match(handler, /type: "resume",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
assert.doesNotMatch(handler, /onNarrativeDelta|sendConversationalRectificationCommand/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}[\s\S]*?openingAssistantText=\{rectificationOpeningAssistantText\}/);
assert.match(source, /rectificationSurfaceOpen && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
});
test("the first rectification turn becomes interactive before session persistence finishes", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
const turnVisible = handler.indexOf("setRectificationInitialTurn(turn)");
const backgroundPersist = handler.indexOf("void rectificationPersistence.current.enqueue(");
test("the v4 panel owns case recovery while the page persists only the dedicated session shell", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.ok(turnVisible >= 0);
assert.ok(backgroundPersist > turnVisible);
assert.match(handler, /void rectificationPersistence\.current\.enqueue\([\s\S]*?\(\) => persistSession\([\s\S]*?\.catch\(\(\) => \{[\s\S]*?校正已经开始,但会话关联暂时未同步到云端。/);
assert.match(handler, /persistSession\(rectificationSession, "create"\)/);
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
assert.match(hook, /existingHandoff[\s\S]*?loadRectificationV4\(existingHandoff\.caseId\)[\s\S]*?createRectificationV4\(\)/);
});
test("a direct homepage start skips the durable handoff read when no question was handed off", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
test("a direct homepage start restores any active v4 case before creating another", () => {
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
assert.match(handler, /const localHandoff = rectificationQuestionHandoff\.current\.peek\(\)/);
assert.match(
handler,
/const durable = requestedQuestion !== null \|\| localHandoff !== null\s*\? await durableRectificationQuestionHandoff\.current\.load\(\)\s*:\s*null/,
);
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
assert.match(hook, /existingHandoff\s*\? await loadRectificationV4\(existingHandoff\.caseId\)\s*:\s*await createRectificationV4\(\)/);
assert.doesNotMatch(hook, /sendConversationalRectificationCommand/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -186,32 +178,31 @@ test("selecting a rectification session resumes it without an intermediate confi
assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/);
assert.match(source, /resumeRectificationSession\.current\(activeSession\)/);
assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/);
assert.match(source, /setComposerNotice\(message\)/);
assert.match(source, /<ConversationalBirthTimeRectification/);
});
test("homepage reuses the session bound to an unfinished rectification case", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
test("homepage reuses the dedicated rectification session while v4 restores the active case", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(handler, /const accountResumeCase = action === "resume" \? account\.rectificationCase : null/);
assert.match(handler, /session\.rectificationCaseId === accountResumeCase\.caseId/);
assert.match(handler, /resumableSession \?\? createSession/);
assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
assert.match(handler, /existing \?\? createSession/);
assert.match(hook, /loadRectificationV4Handoff|createRectificationV4/);
});
test("a bound rectification session resumes its own case while a homepage restart stays dedicated", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
test("a bound rectification session and a homepage restart share the v4 active-case loader", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const start = page.indexOf("async function openBirthTimeRectification");
const end = page.indexOf("function handleConversationalRectificationTurn", start);
const handler = page.slice(start, end);
assert.match(handler, /const sourceBoundCaseId = sourceSession\.sessionType === "birth_time_rectification"/);
assert.match(handler, /const resumeTarget = sourceBoundCaseId/);
assert.match(handler, /caseId: sourceBoundCaseId/);
assert.match(handler, /if \(!resumeTarget\) \{[\s\S]*?type: "start"/);
assert.match(handler, /const rectificationSession = canReuseSourceRectificationSession[\s\S]*?: resumableSession \?\? createSession/);
assert.match(handler, /type: "resume",[\s\S]*?caseId: current\.caseId/);
assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/);
assert.match(hook, /loadRectificationV4Handoff\(\)/);
assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
});
test("historical completed rectification does not replace the account's unfinished case", () => {
@@ -244,7 +235,8 @@ test("completed handoffs return only after the user clicks and target the source
assert.doesNotMatch(source, /automaticRectificationContinuation/);
assert.match(source, /const returnSession = \(localHandoff/);
assert.match(source, /session\.sessionType === "consultation"/);
assert.match(source, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/);
assert.match(source, /onContinueOriginalQuestion=\{\(continuation\) => void continueRectificationOriginalQuestion\(continuation\)\}/);
assert.match(source, /claimRectificationV4Handoff\(\{[\s\S]*?caseId: continuation\.caseId,[\s\S]*?caseVersion: continuation\.caseVersion,[\s\S]*?question/);
assert.match(source, /sessionId: returnSession\.id/);
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
@@ -275,3 +275,51 @@ test("consult route constructs workflow input from the route service rather than
const toolInput = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse"));
assert.doesNotMatch(toolInput.slice(0, toolInput.indexOf("const workflowContext")), /\.\.\.parsed\.data/);
});
test("v4 continuation builds chart boundaries from the durable range without a reported minute", async () => {
const order: string[] = [];
const prepared = await prepareConsultationRoute({
userId: "user-v4",
mode: "general_no_birth_time",
candidateRange: { start: "05:13", end: "05:15" },
loadProfile: async () => {
order.push("profile");
return {
...profile,
reported_birth_time: null,
active_birth_time: null,
birth_time_source: "period_only",
birth_time_status: "reported",
};
},
async resolveTimezoneOffset(value, selectedTime) {
order.push(`timezone:${selectedTime}`);
return value;
},
async reserve() {
order.push("reserve");
return "reserved";
},
});
assert.deepEqual(order, ["profile", "timezone:05:13", "reserve"]);
assert.equal(prepared.consultationMode, "unverified_birth_time");
assert.equal(prepared.serverChart?.toolInput.hour, 5);
assert.equal(prepared.serverChart?.toolInput.minute, 13);
assert.equal(prepared.serverChart?.truth.selectedTimeKind, "candidate_range_boundary");
});
test("v4 continuation request has an independent schema and omits client chart minutes", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const schemaStart = route.indexOf("const v4ContinuationRequestSchema");
const schemaEnd = route.indexOf("const chartChatRequestSchema");
const schema = route.slice(schemaStart, schemaEnd);
assert.ok(schemaStart >= 0 && schemaEnd > schemaStart);
assert.match(schema, /rectificationHandoff:\s*rectificationV4HandoffSchema/);
assert.doesNotMatch(schema, /consultationInputSchema/);
assert.doesNotMatch(schema, /\bhour\b|\bminute\b|\blat\b|\blon\b|\btz\b/);
assert.match(page, /rectificationHandoff\?\.protocol === "rectification-evidence-v4" \? \{\} : \{/);
assert.match(route, /candidateRange:\s*handoffExecution\.acceptedRange/);
});
@@ -263,79 +263,48 @@ test("an active correction target remains cancellable without rendering evidence
assert.doesNotMatch(markup, /(已修订)|已记录的真实经历/);
});
test("pending markup and responsive CSS expose accessibility contracts", () => {
const pendingController = controller({
pending: true,
draft: "保留中的文字",
getSnapshot: () => ({
turn,
draft: "保留中的文字",
selectedDomain: "career",
correctionTarget: null,
pending: true,
error: "",
}),
});
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
surfaceProps(pendingController),
));
test("v4 markup and responsive CSS expose accessibility and uncertainty contracts", () => {
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const component = readFileSync(
new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
"utf8",
);
const wrapper = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(markup, /aria-busy="true"/);
assert.match(markup, /Jyotisha 正在核对经历/);
assert.doesNotMatch(markup, /正在核对这段经历|Enter 发送|已发送,2\.5 秒/);
assert.match(markup, /Jyotisha 正在分析/);
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
assert.match(markup, /aria-label="生时校正对话"/);
assert.match(markup, /role="alert"|aria-live="polite"/);
assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/);
assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/);
assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/);
assert.match(component, /conversationEnd\.current\?\.scrollIntoView/);
assert.match(component, /<div ref=\{conversationEnd\} \/>/);
assert.match(css, /\.composer:focus-within[^}]*border-color:/);
assert.match(css, /\.composer textarea[^}]*border:\s*0/);
assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/);
assert.match(page, /rectificationSurfaceOpen \? "is-rectification"/);
assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/);
assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/);
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
assert.match(component, /controller\.answer\(undefined, text\)/);
assert.match(component, /<section className="rectification-v4-panel" aria-busy=\{processing \|\| controller\.pending\}>/);
assert.match(component, /id="rectification-v4-answer"/);
assert.match(component, /disabled=\{controller\.pending\}/);
assert.match(component, /aria-label="提交这段经历"/);
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(component, /onPendingChange/);
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
assert.match(component, /onContinueOriginalQuestion\?\./);
assert.match(markup, /aria-label="赞"/);
assert.match(markup, /aria-label="踩"/);
assert.match(markup, /aria-label="复制回答"/);
assert.match(markup, /aria-label="重跑回答"/);
assert.match(component, /navigator\.clipboard\.writeText/);
assert.match(component, /setRegeneratingMessageKey\(messageKey\)/);
assert.match(component, /regeneratingMessageKey === message\.renderKey[\s\S]*?state: "thinking"/);
assert.match(component, /controller\.pending && canAnswer && regeneratingMessageKey === null/);
assert.match(component, /await controller\.regenerate\(\)/);
assert.match(component, /message\.renderKey !== latestAssistantKey/);
assert.match(css, /\.rectification-message-actions/);
assert.match(component, /候选范围,不是已确认的出生分钟/);
assert.match(component, /不会把峰值分钟当作真实出生时间/);
assert.match(component, /保存这个范围/);
assert.match(component, /protocol: "rectification-evidence-v4"[\s\S]*?caseId: caseValue\.id,[\s\S]*?caseVersion: caseValue\.version,[\s\S]*?acceptedRange: accepted/);
assert.match(wrapper, /<RectificationV4Panel[\s\S]*?onPendingChange=\{props\.onPendingChange\}/);
assert.match(css, /\.rectification-v4-panel \{[^}]*width: min\(860px, 100%\)[^}]*overflow-y: auto/);
assert.match(css, /\.rectification-v4-composer textarea \{[^}]*min-height: 112px/);
assert.match(css, /@media\s*\(max-width:\s*680px\)[\s\S]*?\.rectification-v4-ranges, \.rectification-v4-evidence-grid \{ grid-template-columns: 1fr; \}/);
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
});
test("the page persists and rehydrates the full rectification transcript", () => {
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const component = readFileSync(
test("the v4 panel rehydrates the active case and event revisions instead of session transcript state", () => {
const wrapper = readFileSync(
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
const client = readFileSync(new URL("../src/lib/rectification-v4/client.ts", import.meta.url), "utf8");
const v4Export = wrapper.slice(wrapper.indexOf("export function ConversationalBirthTimeRectification"));
assert.match(page, /firstSessionMessages[\s\S]*?role: "assistant"[\s\S]*?turn\.narrative\.trim\(\)/);
assert.match(page, /messages:\s*durableRectificationMessages\(messages\)/);
assert.match(page, /initialMessages=\{activeSession\?\.messages \?\? \[\]\}/);
assert.match(component, /initialMessages:\s*props\.initialMessages/);
assert.match(v4Export, /return \([\s\S]*?<RectificationV4Panel/);
assert.doesNotMatch(v4Export, /initialTurn|initialMessages|useConversationalRectification/);
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
assert.match(hook, /await loadRectificationV4\(existingHandoff\.caseId\)/);
assert.match(hook, /await createRectificationV4\(\)/);
assert.match(client, /rectificationV4ApiResponseSchema/);
});
type CdpResponse = Readonly<{
@@ -686,107 +655,117 @@ test("Chromium harness keeps the browser sandbox and bounds every external wait"
assert.match(source, /fetchJsonWithDeadline/);
});
test("real Chromium at 390px verifies layout, keyboard focus, streamlined controls, and live hook inputs", {
test("real Chromium at 390px verifies the v4 range surface, keyboard focus, and no horizontal overflow", {
timeout: 30_000,
}, async () => {
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
const directory = mkdtempSync(join(tmpdir(), "rectification-browser-"));
const directory = mkdtempSync(join(tmpdir(), "rectification-v4-browser-"));
const entryPath = join(directory, "fixture.tsx");
const bundlePath = join(directory, "fixture.js");
const htmlPath = join(directory, "fixture.html");
const userDataDirectory = join(directory, "chrome-profile");
const componentPath = join(frontendRoot, "src/components/conversational-birth-time-rectification.tsx");
const hookPath = join(frontendRoot, "src/hooks/use-conversational-rectification.ts");
const componentPath = join(frontendRoot, "src/components/rectification-v4-panel.tsx");
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
.replace(/^@import[^;]+;\s*/gm, "");
const fixture = `
import React, { useEffect, useState } from "react";
import React from "react";
import { createRoot } from "react-dom/client";
import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)};
import { useConversationalRectification } from ${JSON.stringify(hookPath)};
import { RectificationV4Panel } from ${JSON.stringify(componentPath)};
const caseA = "00000000-0000-4000-8000-000000000821";
const caseB = "00000000-0000-4000-8000-000000000829";
const longWord = "D9SENSITIVEREFERENCE".repeat(70);
const makeTurn = (caseId, turnVersion, status = "active") => ({
caseId,
journeyProtocol: "conversational-evidence-v3",
status,
turnVersion,
narrative: "## 当前判断\\n\\n**05:18** 只是待验证候选。" + longWord,
candidate: {
status: "pending_validation",
representativeTime: "05:18",
rangeStart: "05:10",
rangeEnd: "05:26",
const eventId = "00000000-0000-4000-8000-000000000822";
const now = "2026-07-27T00:00:00.000Z";
const data = {
case: {
id: "00000000-0000-4000-8000-000000000821",
userId: "00000000-0000-4000-8000-000000000823",
protocol: "rectification-evidence-v4",
version: 4,
status: "range_ready",
phase: "complete",
calculationSpec: {
version: "rectification-calculation-spec-v4",
birthDate: "1990-01-01",
candidateRange: { start: "05:00", end: "06:00" },
latitude: 25.03,
longitude: 121.56,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
},
calculationSpecHash: "a".repeat(64),
evidenceSetHash: "b".repeat(64),
currentQuestion: {
id: "00000000-0000-4000-8000-000000000824",
domain: "career",
targetEventId: eventId,
prompt: "如果要修订这段经历,请直接写出正确年月和发生了什么。",
recallCost: "low",
reason: "核对日期敏感性",
},
latestSnapshot: {
id: "00000000-0000-4000-8000-000000000825",
caseId: "00000000-0000-4000-8000-000000000821",
caseVersion: 4,
evidenceSetHash: "b".repeat(64),
calculationSpecHash: "a".repeat(64),
algorithmVersion: "rectification-v4-range-scoring-1",
candidates: [{
time: "05:18",
score: 9.5,
supportingEventIds: [eventId],
conflictingEventIds: [],
}],
clusters: [{
rank: 1,
startTime: "05:16",
endTime: "05:20",
representativeTime: "05:18",
widthMinutes: 5,
peakScore: 9.5,
scoreMass: 9.5,
}],
robustness: {
neighborSupportMinutes: 5,
leaveOneOutRetentionRate: 0.8,
dateSensitivityRetentionRate: 0.9,
calculationSpecHashMatched: true,
},
canConfirmExactMinute: false,
canAcceptRange: true,
gateReasons: [],
createdAt: now,
},
acceptedRange: null,
createdAt: now,
updatedAt: now,
},
technicalReceipt: {
calculationVersion: "rectification-technical-v1",
stableLayers: ["D1"],
sensitiveLayers: ["D9", "D10"],
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
},
evidenceRequest: status === "abandoned" ? null : {
domains: ["career", "education", "relocation"],
datePrecision: "month_preferred",
freeTextAllowed: true,
},
evidenceRecap: status === "abandoned" ? [] : [{
id: "00000000-0000-4000-8000-000000000822",
summary: "开始第一份长期工作",
dateLabel: "2021-07",
isCorrection: false,
job: null,
events: [{
id: "00000000-0000-4000-8000-000000000826",
eventId,
revision: 2,
domain: "career",
eventKind: "career_change",
summary: "2021 年开始第一份长期工作",
rawText: "2021 年 7 月开始第一份长期工作",
dateRange: { start: "2021-07-01", end: "2021-07-31", precision: "month", label: "2021-07" },
scoreability: "scoreable",
supersedesRevisionId: null,
createdAt: now,
}],
actions: status === "abandoned" ? [] : status === "paused"
? ["answer", "abandon"]
: ["answer", "pause", "abandon"],
pendingConsultationQuestion: null,
});
const turns = {
activeA1: makeTurn(caseA, 1),
activeA3: makeTurn(caseA, 3),
activeB1: makeTurn(caseB, 1),
abandonedB2: makeTurn(caseB, 2, "abandoned"),
};
const events = [];
function Harness() {
const [initialTurn, setInitialTurn] = useState(null);
const [transportLabel, setTransportLabel] = useState("first");
const [callbackLabel, setCallbackLabel] = useState("first");
const send = async (command) => {
events.push("send:" + transportLabel + ":" + command.type);
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
if (command.type === "pause") {
return makeTurn(command.caseId, command.turnVersion + 1, "paused");
}
if (command.type === "abandon") {
return makeTurn(command.caseId, command.turnVersion + 1, "abandoned");
}
return makeTurn(command.caseId ?? caseA, (command.turnVersion ?? 0) + 1);
};
const controller = useConversationalRectification({
initialTurn,
send,
onTurn: (next) => events.push("turn:" + callbackLabel + ":" + next.status),
});
useEffect(() => {
globalThis.__rectificationHarness = {
events,
setCallbackLabel,
setTransportLabel,
setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); },
};
globalThis.__rectificationReady = true;
});
return <ConversationalRectificationSurface
controller={controller}
models={[{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }]}
selectedModelId="deepseek-chat"
onSelectModel={() => undefined}
/>;
}
createRoot(document.getElementById("root")).render(<Harness />);
globalThis.fetch = async (input, init) => {
const path = String(input);
if (path === "/api/rectification/v4/handoff" && !init?.method) return new Response(null, { status: 204 });
if (path === "/api/rectification/v4/cases" && init?.method === "POST") {
return Response.json(data);
}
throw new Error("unexpected fetch " + path);
};
createRoot(document.getElementById("root")).render(<RectificationV4Panel />);
globalThis.__rectificationReady = true;
`;
let browser: ChildProcess | null = null;
let cdp: CdpSession | null = null;
@@ -819,73 +798,40 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
() => cdp?.evaluate<boolean>("globalThis.__rectificationReady === true") ?? Promise.resolve(false),
"React fixture readiness",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
await waitFor(
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('2021-07 · 开始第一份长期工作')
&& !document.body.textContent.includes('已记录这段经历')`) ?? Promise.resolve(false),
"streamlined async initial turn",
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('05:1605:20')
&& document.body.textContent.includes('保存这个范围')
&& document.getElementById('rectification-v4-answer') !== null`) ?? Promise.resolve(false),
"v4 range surface",
);
const layout = await cdp.evaluate<{
viewport: number;
scrollWidth: number;
surfaceWidth: number;
shortestButton: number;
selectCount: number;
domainChoiceCount: number;
panelWidth: number;
rangeColumns: string;
exactMinuteClaimVisible: boolean;
}>(`(() => {
const buttons = [...document.querySelectorAll('.rectification-chat button')]
.filter((button) => button.getBoundingClientRect().height > 0);
const panel = document.querySelector('.rectification-v4-panel');
return {
viewport: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
surfaceWidth: document.querySelector('.rectification-chat').getBoundingClientRect().width,
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
selectCount: document.querySelectorAll('.rectification-chat select').length,
domainChoiceCount: document.querySelectorAll('[data-evidence-domain]').length,
panelWidth: panel.getBoundingClientRect().width,
rangeColumns: getComputedStyle(document.querySelector('.rectification-v4-ranges')).gridTemplateColumns,
exactMinuteClaimVisible: document.body.textContent.includes('已确认出生分钟'),
};
})()`);
assert.equal(layout.viewport, 390);
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
assert.equal(layout.selectCount, 0, "language-first flow should not render date selects");
assert.equal(layout.domainChoiceCount, 0, "language-first flow should not render domain buttons");
assert.ok(layout.panelWidth <= 366, `panel overflowed padded viewport: ${layout.panelWidth}px`);
assert.equal(layout.rangeColumns.trim().split(/\s+/).length, 1);
assert.equal(layout.exactMinuteClaimVisible, false);
const mistakenAnswer = "2020年9月离职写错了";
await cdp.evaluate(`(() => {
const textarea = document.getElementById('conversational-rectification-answer');
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
setter.call(textarea, ${JSON.stringify(mistakenAnswer)});
textarea.dispatchEvent(new Event('input', { bubbles: true }));
document.querySelector('[aria-label="发送"]').click();
})()`);
await waitFor(
() => cdp?.evaluate<boolean>(`document.querySelector('[aria-label="撤回发送,本次不计入校正"]') !== null`) ?? Promise.resolve(false),
"rectification undo window",
await cdp.evaluate("document.getElementById('rectification-v4-answer').focus()");
assert.equal(
await cdp.evaluate<boolean>("document.activeElement?.id === 'rectification-v4-answer'"),
true,
);
await cdp.evaluate("document.querySelector('[aria-label=\"撤回发送,本次不计入校正\"]').click()");
await waitFor(
() => cdp?.evaluate<boolean>(`document.getElementById('conversational-rectification-answer').value === ${JSON.stringify(mistakenAnswer)}`) ?? Promise.resolve(false),
"mistaken answer restored to draft",
);
await new Promise((resolve) => setTimeout(resolve, 2_700));
assert.equal(await cdp.evaluate<boolean>("globalThis.__rectificationHarness.events.some((event) => event.endsWith(':answer'))"), false);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const text = document.body.textContent;
return !text.includes('当前候选')
&& !text.includes('候选时间')
&& !text.includes('本轮技术回执')
&& !text.includes('暂停,稍后继续')
&& !text.includes('放弃本次校正')
&& !document.querySelector('[role=alertdialog]');
})()`) ?? Promise.resolve(false),
"streamlined rectification controls",
);
} finally {
try {
cdp?.close();
@@ -26,7 +26,6 @@ import type {
LoadedConversationalRectificationCase,
StoredConversationalRectificationCase,
} from "../src/lib/conversational-rectification/store.ts";
import { createRectificationQuestionHandoffCoordinator } from "../src/lib/rectification-question-handoff.ts";
import type { ConversationalRectificationTelemetryPayload } from "../src/lib/birth-time-journey-telemetry.ts";
import { createConversationalRectificationTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
import { conversationalRectificationCreationPolicy } from "../src/lib/conversational-rectification/creation-policy.ts";
@@ -35,6 +34,8 @@ const userId = "00000000-0000-4000-8000-000000009001";
const caseId = "00000000-0000-4000-8000-000000009002";
const originalQuestion = "我下一次适合换工作的时间是什么时候?";
const deploymentSha = "0123456789abcdef0123456789abcdef01234567";
const openingNarrative = "根据你填写的出生时间信息,当前先核对 05:00–06:00。这只是待核对范围,还不能把其中某一分钟当作已确认出生时间。你可以按自己的节奏讲已经发生的人生经历,一次说一件或连续说多件都可以;记得的年月可以自然地带上,不确定也没关系。";
const intermediateFallbackNarrative = "我听到了。你愿意接着说说这件事之后发生了什么吗?";
const declaredBirthInput = {
source: "approximate" as const,
@@ -169,6 +170,7 @@ function createSyntheticBackend(options: {
legacy?: boolean;
allowNewCaseCreation?: boolean;
packetFailure?: boolean;
createFailure?: boolean;
initialReady?: boolean;
packetEvidenceCalls?: string[][];
} = {}) {
@@ -250,6 +252,7 @@ function createSyntheticBackend(options: {
return row?.userId === input.userId ? structuredClone(row) : null;
},
async createCaseWithFirstTurn(input) {
if (options.createFailure) throw new Error("synthetic create failure");
return save({
userId: input.userId,
caseId: input.caseId,
@@ -430,7 +433,7 @@ async function post(
return payload as ConversationalRectificationTurn;
}
test("authenticated synthetic flow covers soft entry, rich evidence, resume, atomic confirmation, and handoff", async () => {
test("authenticated synthetic flow covers soft entry, rich evidence, resume, and safety gates", async () => {
assert.equal(isDeclaredBirthProfileComplete(onboardingDraft), true, "onboarding may finish without rectification");
let consent = createBirthTimeConsultationConsentState();
@@ -460,16 +463,11 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
assert.equal(turn.status, "active");
assert.equal(
turn.narrative,
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
"the first visible reply must remain the narrator's authored answer rather than receive a deterministic prefix or suffix",
openingNarrative,
"the first visible reply must use the deterministic fast opening",
);
assert.doesNotMatch(turn.narrative, /\bD\d+\b/);
assert.deepEqual(
turn.evidenceRequest?.domains,
["career"],
"the narrator chooses the next useful domain instead of receiving a program-authored domain list",
);
assert.equal(turn.evidenceRequest?.freeTextAllowed, true);
assert.equal(turn.evidenceRequest, null);
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
@@ -483,7 +481,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
assert.equal(turn.status, "active");
assert.equal(
turn.narrative,
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
intermediateFallbackNarrative,
"a direction change must show the model reply instead of a program-authored redirect template",
);
assert.doesNotMatch(turn.narrative, /不沿用不符合|自由描述另一件已经发生/);
@@ -495,7 +493,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
});
assert.equal(
turn.narrative,
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
intermediateFallbackNarrative,
);
assert.doesNotMatch(turn.narrative, /我先按你的原话记下|我已保存你的原话|还差时间定位/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
@@ -507,7 +505,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
});
assert.equal(
turn.narrative,
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
intermediateFallbackNarrative,
"future evidence must remain non-scoreable without replacing the model's visible answer",
);
assert.doesNotMatch(turn.narrative, /未来事件只能作为背景|不能用于校正评分/);
@@ -551,63 +549,19 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
});
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.representativeTime, "05:18");
assert.equal(backend.activeTime(), "04:58");
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.doesNotMatch(turn.actions.join(","), /confirm/);
assert.equal(backend.activeTime(), "04:58", "evidence collection must not replace the active minute");
assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false);
assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds);
const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009010",
turnVersion: turn.turnVersion, time: "05:17",
}),
}));
assert.equal(wrong.status, 409);
assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic");
assert.equal(telemetry.at(-1)?.phase, "confirming");
assert.equal(telemetry.at(-1)?.billingState, "unchanged");
turn = await post(secondDevice, {
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011",
turnVersion: turn.turnVersion, time: "05:18",
});
assert.equal(turn.status, "completed");
assert.deepEqual(turn.actions, ["continue_original_question"]);
assert.equal(backend.activeTime(), "05:18");
assert.equal(backend.billing().chargeCount, 1);
let ordinaryReservations = 0;
let ordinaryAnswers = 0;
const handoff = createRectificationQuestionHandoffCoordinator<"timing">();
const continued = await handoff.continueOriginalQuestion(
turn.pendingConsultationQuestion ?? "",
{ sessionId: "new-device-chat", theme: "timing" },
async (context) => {
ordinaryReservations += 1;
ordinaryAnswers += 1;
assert.equal(context.question, originalQuestion);
assert.equal(backend.activeTime(), "05:18");
return true;
},
);
assert.equal(continued, true);
assert.equal(ordinaryReservations, 1);
assert.equal(ordinaryAnswers, 1);
const chats = new Set(["new-device-chat"]);
chats.delete("new-device-chat");
assert.equal(chats.size, 0);
const caseAfterChatDeletion = backend.cases.get(caseId);
assert.equal(caseAfterChatDeletion?.status, "completed", "chat deletion must not cascade to the account case");
const allowedTelemetryKeys = [
"protocol", "phase", "actionKind", "resultCategory", "latencyBucket",
"billingState", "errorCategory", "deploymentSha",
].sort();
assert.ok(telemetry.length >= 10);
assert.ok(telemetry.length >= 8);
for (const payload of telemetry) {
assert.deepEqual(Object.keys(payload).sort(), allowedTelemetryKeys);
assert.equal(payload.protocol, "conversational-evidence-v3");
@@ -664,7 +618,7 @@ test("v3 telemetry drops invalid payloads without affecting the product request"
});
test("telemetry reports released reservations and authentication rejects before service creation", async () => {
const backend = createSyntheticBackend({ packetFailure: true });
const backend = createSyntheticBackend({ createFailure: true });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
@@ -933,13 +887,9 @@ test("rollback flag stops only new cases while existing v3 resume stays readable
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009055",
turnVersion: existingTurn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
});
assert.equal(existingTurn.status, "confirming");
existingTurn = await post(handler, {
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009056",
turnVersion: existingTurn.turnVersion, time: "05:18",
});
assert.equal(existingTurn.status, "completed");
assert.equal(backend.activeTime(), "05:18");
assert.equal(existingTurn.status, "active");
assert.equal(existingTurn.caseId, caseId);
assert.equal(backend.activeTime(), "04:58");
});
test("a throwing injected telemetry sink cannot turn a committed request into failure", async () => {
@@ -85,6 +85,16 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"birth_time_rectification_question_handoffs",
"birth_time_rectification_scoring_jobs",
"birth_time_rectification_turns",
"birth_time_rectification_v4_actions",
"birth_time_rectification_v4_candidate_snapshots",
"birth_time_rectification_v4_cases",
"birth_time_rectification_v4_event_revisions",
"birth_time_rectification_v4_events",
"birth_time_rectification_v4_handoff_attach_receipts",
"birth_time_rectification_v4_handoff_settlements",
"birth_time_rectification_v4_handoffs",
"birth_time_rectification_v4_jobs",
"birth_time_rectification_v4_turns",
"chart_profiles",
"chat_sessions",
"consultation_requests",
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import test from "node:test";
import { randomUUID } from "node:crypto";
import { buildCandidateClusters } from "../src/lib/rectification-v4/candidate-clusters.ts";
import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4/date-range.ts";
import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts";
import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts";
import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts";
import { planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts";
const now = new Date("2026-07-26T00:00:00.000Z");
test("declared month, quarter and year retain real boundaries instead of invented midpoints", () => {
assert.deepEqual(dateRangeFromDeclared("2024-02", "month"), {
start: "2024-02-01", end: "2024-02-29", precision: "month", label: "2024-02",
});
assert.deepEqual(dateRangeFromDeclared("2024-Q2", "quarter"), {
start: "2024-04-01", end: "2024-06-30", precision: "quarter", label: "2024-Q2",
});
assert.deepEqual(dateRangeFromDeclared("2024", "year"), {
start: "2024-01-01", end: "2024-12-31", precision: "year", label: "2024",
});
assert.equal(sampledDates(dateRangeFromDeclared("2024-02", "month")).includes("2024-02-15"), false);
});
test("relationship start and end remain separate immutable events", () => {
const startId = randomUUID();
const endId = randomUUID();
const start = appendEventRevision([], {
eventId: startId, domain: "relationship", eventKind: "relationship_start", summary: "关系开始",
rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"),
}, { id: randomUUID(), now });
const end = appendEventRevision([start], {
eventId: endId, domain: "relationship", eventKind: "relationship_end", summary: "关系结束",
rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"),
}, { id: randomUUID(), now });
assert.notEqual(start.eventId, end.eventId);
assert.equal(start.eventKind, "relationship_start");
assert.equal(end.eventKind, "relationship_end");
});
test("family evidence is retained explicitly as context only", () => {
const revision = appendEventRevision([], {
eventId: randomUUID(), domain: "family", eventKind: "family_event", summary: "家庭变化",
rawText: "家庭发生变化", dateRange: dateRangeFromDeclared("2020", "year"),
}, { id: randomUUID(), now });
assert.equal(revision.scoreability, "context_only");
});
test("candidate minutes merge into ranked contiguous clusters", () => {
const id = randomUUID();
const clusters = buildCandidateClusters([
{ time: "05:13", score: 100, supportingEventIds: [id], conflictingEventIds: [] },
{ time: "05:14", score: 99, supportingEventIds: [id], conflictingEventIds: [] },
{ time: "05:15", score: 98, supportingEventIds: [id], conflictingEventIds: [] },
{ time: "05:16", score: 70, supportingEventIds: [], conflictingEventIds: [id] },
{ time: "05:17", score: 97, supportingEventIds: [id], conflictingEventIds: [] },
{ time: "05:18", score: 97, supportingEventIds: [id], conflictingEventIds: [] },
]);
assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [
[1, "05:13", "05:15"], [2, "05:17", "05:18"],
]);
});
test("decision gate can accept a stable range but never an exact minute", () => {
const result = evaluateDecisionGate({
clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 29 }],
robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 0.9, calculationSpecHashMatched: true },
scoreableEventCount: 10,
scoreableDomainCount: 5,
});
assert.equal(result.canAcceptRange, true);
assert.equal(result.canConfirmExactMinute, false);
});
test("question planner asks one deterministic low-recall question at a time", () => {
const question = planNextQuestion({
askedDomains: ["education"], coveredDomains: ["education"],
candidateSplitByDomain: { relocation: 0.8, relationship: 0.2 }, id: randomUUID(),
});
assert.equal(question.domain, "relocation");
assert.equal(question.targetEventId, null);
assert.equal(question.prompt.includes("搬家"), true);
});
test("question planner refines imprecise scoreable events after domain coverage", () => {
const eventId = randomUUID();
const event = appendEventRevision([], {
eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业",
rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"),
}, { id: randomUUID(), now });
const question = planNextQuestion({
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
events: [event],
attemptedRefinementEventIds: [],
id: randomUUID(),
});
assert.equal(question.targetEventId, eventId);
assert.equal(question.prompt.includes("高中毕业"), true);
const fallback = planNextQuestion({
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
events: [event],
attemptedRefinementEventIds: [eventId],
id: randomUUID(),
});
assert.equal(fallback.targetEventId, null);
assert.equal(fallback.domain, "other");
assert.equal(fallback.prompt.includes("暂停"), true);
});
test("targeted date answer appends a revision without duplicating the scoreable event", () => {
const eventId = randomUUID();
const original = appendEventRevision([], {
eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业",
rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"),
}, { id: randomUUID(), now });
const revisions = extractV4EventRevisions({
answer: "2016年6月8日",
sourceTurnId: randomUUID(),
asOfDate: "2026-07-26",
existing: [original],
targetEventId: eventId,
now,
});
assert.equal(revisions.length, 1);
assert.equal(revisions[0]?.eventId, eventId);
assert.equal(revisions[0]?.revision, 2);
assert.equal(revisions[0]?.dateRange.precision, "day");
assert.equal(revisions[0]?.dateRange.start, "2016-06-08");
assert.equal(latestEventRevisions([original, ...revisions]).length, 1);
});
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import {
createRectificationV4HandoffService,
rectificationQuestionFingerprint,
} from "../src/lib/rectification-handoff-service.ts";
import { claimRectificationV4Handoff } from "../src/lib/rectification-v4/client.ts";
const userId = "00000000-0000-4000-8000-000000004001";
const caseId = "00000000-0000-4000-8000-000000004002";
const actionId = "00000000-0000-4000-8000-000000004003";
const requestId = "00000000-0000-4000-8000-000000004004";
const question = "未来半年是否适合换工作?";
const acceptedRange = { start: "05:13", end: "05:15" } as const;
function projection(status: "pending" | "claimed" | "in_progress" | "consumed") {
return {
protocol: "rectification-evidence-v4" as const,
caseId,
caseVersion: 7,
question,
questionFingerprint: rectificationQuestionFingerprint(question),
requestId,
status,
acceptedRange,
};
}
test("v4 handoff adapter binds all RPCs to case, version, action, request, and database range", async () => {
const calls: Array<{ name: string; args: Readonly<Record<string, unknown>> }> = [];
const service = createRectificationV4HandoffService({
async rpc(name, args) {
calls.push({ name, args });
if (name === "load_birth_time_rectification_v4_handoff") return { data: projection("pending"), error: null };
if (name === "begin_birth_time_rectification_v4_handoff_execution") {
return { data: { status: "ready", requestId, billingReused: false, credits: 8, acceptedRange }, error: null };
}
if (name === "settle_birth_time_rectification_v4_handoff") {
return { data: { status: "consumed", requestId, credits: 8 }, error: null };
}
return { data: projection(name.startsWith("claim_") ? "claimed" : "pending"), error: null };
},
});
await service.attach({ userId, caseId, caseVersion: 7, actionId, question: ` ${question} ` });
await service.load({ userId, caseId });
await service.claim({ userId, caseId, caseVersion: 7, actionId, question });
const execution = await service.beginExecution({ userId, caseId, caseVersion: 7, claimActionId: actionId, requestId, question });
await service.settle({ userId, caseId, claimActionId: actionId, requestId, emitted: true });
assert.deepEqual(calls.map(({ name }) => name), [
"attach_birth_time_rectification_v4_question",
"load_birth_time_rectification_v4_handoff",
"claim_birth_time_rectification_v4_handoff",
"begin_birth_time_rectification_v4_handoff_execution",
"settle_birth_time_rectification_v4_handoff",
]);
assert.deepEqual(calls[0]?.args, {
p_user_id: userId,
p_case_id: caseId,
p_expected_version: 7,
p_action_id: actionId,
p_question: question,
p_question_fingerprint: rectificationQuestionFingerprint(question),
});
assert.deepEqual(calls[2]?.args, {
p_user_id: userId,
p_case_id: caseId,
p_expected_version: 7,
p_action_id: actionId,
p_question_fingerprint: rectificationQuestionFingerprint(question),
});
assert.deepEqual(calls[3]?.args, {
p_user_id: userId,
p_case_id: caseId,
p_expected_version: 7,
p_claim_action_id: actionId,
p_request_id: requestId,
p_question_fingerprint: rectificationQuestionFingerprint(question),
});
assert.deepEqual(calls[4]?.args, {
p_user_id: userId,
p_case_id: caseId,
p_claim_action_id: actionId,
p_request_id: requestId,
p_emitted: true,
});
assert.deepEqual(execution.acceptedRange, acceptedRange);
});
test("lost v4 claim response retries with the same action id", async () => {
const bodies: Array<Record<string, unknown>> = [];
let attempts = 0;
mock.method(globalThis, "fetch", async (_url: string | URL | Request, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
attempts += 1;
if (attempts === 1) throw new TypeError("lost response");
return Response.json(projection("claimed"));
});
try {
const claimed = await claimRectificationV4Handoff({ caseId, caseVersion: 7, question });
assert.equal(claimed.status, "claimed");
assert.equal(bodies.length, 2);
assert.equal(bodies[0]?.actionId, bodies[1]?.actionId);
assert.equal(claimed.claimActionId, bodies[1]?.actionId);
} finally {
mock.restoreAll();
}
});
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
test("v4 migration creates canonical append-only storage and leased jobs", () => {
for (const table of [
"birth_time_rectification_v4_cases", "birth_time_rectification_v4_turns",
"birth_time_rectification_v4_events", "birth_time_rectification_v4_event_revisions",
"birth_time_rectification_v4_candidate_snapshots", "birth_time_rectification_v4_jobs",
]) assert.match(sql, new RegExp(`create table public\\.${table}`));
assert.match(sql, /for update skip locked/);
assert.match(sql, /lease_expires_at/);
assert.match(sql, /stale_rectification_v4_job/);
assert.match(sql, /can_confirm_exact_minute = false/);
assert.match(sql, /question_target_event_id uuid/);
assert.match(sql, /foreign key \(case_id, question_target_event_id\)/);
assert.match(sql, /domain not in \('family', 'other'\) or scoreability = 'context_only'/);
});
test("v4 handoff SQL enforces range acceptance, leases, idempotent settlement, and no profile time write", () => {
for (const functionName of [
"attach_birth_time_rectification_v4_question",
"load_birth_time_rectification_v4_handoff",
"claim_birth_time_rectification_v4_handoff",
"begin_birth_time_rectification_v4_handoff_execution",
"settle_birth_time_rectification_v4_handoff",
]) {
assert.match(sql, new RegExp(`create function public\\.${functionName}\\(`, "i"));
assert.match(sql, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`, "i"));
}
assert.match(sql, /accepted_range_start is null[\s\S]*accepted_range_end is null[\s\S]*rectification_v4_handoff_conflict/i);
assert.match(sql, /lease_expires_at > pg_catalog\.now\(\)[\s\S]*'in_progress'/i);
assert.match(sql, /state in \('claimed', 'executing'\)[\s\S]*lease_expires_at > pg_catalog\.now\(\)/i);
assert.match(sql, /birth_time_rectification_v4_handoff_settlements/i);
assert.doesNotMatch(sql, /update\s+public\.profiles[\s\S]*active_birth_time/i);
});
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
const now = () => new Date("2026-07-26T08:00:00.000Z");
const spec: CalculationSpec = {
version: "rectification-calculation-spec-v4",
birthDate: "1997-08-08",
candidateRange: { start: "04:30", end: "05:30" },
latitude: 36.419,
longitude: 114.213,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
};
async function answerAndRun(
service: ReturnType<typeof createRectificationV4CaseService>,
worker: ReturnType<typeof createRectificationV4Worker>,
userId: string,
caseId: string,
version: number,
answer: string,
) {
const queued = await service.answer({
userId,
caseId,
actionId: randomUUID(),
expectedCaseVersion: version,
answer,
});
assert.ok(queued?.job);
assert.equal(await worker.runOnce(), true);
const loaded = await service.loadCase(userId, caseId);
assert.ok(loaded);
return loaded;
}
test("fixture replay returns ranges only and never mutates the profile birth minute", async () => {
const profile = { active_birth_time: "05:00:00" };
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now });
const worker = createRectificationV4Worker({
store,
now,
engine: {
async score({ calculationSpec, events }) {
const ids = events.map((event) => event.eventId);
return {
resultId: randomUUID(),
calculationSpecHash: calculationSpecHash(calculationSpec),
candidates: [
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: ids },
{ time: "05:17", score: 97.8, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:18", score: 97.7, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:19", score: 97.6, supportingEventIds: ids, conflictingEventIds: [] },
],
robustness: {
neighborSupportMinutes: 3,
leaveOneOutRetentionRate: 1,
dateSensitivityRetentionRate: 0.9,
},
missingLayers: [],
};
},
},
});
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
let loaded = await answerAndRun(
service,
worker,
userId,
created.case.id,
created.case.version,
"2015年高中毕业后复读一年,2016年再次高中毕业",
);
assert.deepEqual(loaded.events.map((event) => [event.dateRange.start, event.dateRange.end]), [
["2015-01-01", "2015-12-31"],
["2016-01-01", "2016-12-31"],
]);
loaded = await answerAndRun(service, worker, userId, created.case.id, loaded.case.version, "2018年8月搬家到北京");
loaded = await answerAndRun(
service,
worker,
userId,
created.case.id,
loaded.case.version,
"2020年5月开始恋爱,2022年3月分手",
);
const snapshot = loaded.case.latestSnapshot;
assert.ok(snapshot);
assert.equal(snapshot.canConfirmExactMinute, false);
assert.equal(snapshot.canAcceptRange, true);
assert.deepEqual(snapshot.clusters.map((cluster) => [cluster.startTime, cluster.endTime]), [
["05:13", "05:15"],
["05:17", "05:19"],
]);
assert.equal(snapshot.clusters[0]?.representativeTime, "05:13");
assert.equal(loaded.case.acceptedRange, null);
const accepted = await service.acceptRange({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: loaded.case.version,
startTime: "05:13",
endTime: "05:15",
});
assert.deepEqual(accepted?.case.acceptedRange, { start: "05:13", end: "05:15" });
assert.equal(accepted?.case.latestSnapshot?.canConfirmExactMinute, false);
assert.equal(profile.active_birth_time, "05:00:00");
});
@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import { randomUUID } from "node:crypto";
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
const fixedNow = () => new Date("2026-07-26T12:00:00.000Z");
const spec: CalculationSpec = {
version: "rectification-calculation-spec-v4",
birthDate: "1997-08-08",
candidateRange: { start: "04:30", end: "05:30" },
latitude: 36.419,
longitude: 114.213,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
};
test("same calculation spec resumes the unfinished case", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } });
assert.equal(resumed.case.id, first.case.id);
assert.equal(store.cases.size, 1);
});
test("changed calculation spec atomically abandons the old case and stales its job", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学",
});
assert.ok(queued?.job);
const replacement = await service.createCase({
userId,
actionId: randomUUID(),
calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } },
});
assert.notEqual(replacement.case.id, first.case.id);
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
assert.equal(store.cases.get(first.case.id)?.currentQuestion, null);
assert.equal(store.jobs.get(queued.job.id)?.status, "stale");
assert.equal((await service.loadActive(userId))?.case.id, replacement.case.id);
});
test("an accepted range closes the active lifecycle and allows a new case", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
await store.transitionCase({
userId,
caseId: first.case.id,
actionId: randomUUID(),
expectedCaseVersion: first.case.version,
status: "range_ready",
phase: "complete",
acceptedRange: { start: "05:13", end: "05:15" },
now: fixedNow().toISOString(),
});
assert.equal(await service.loadActive(userId), null);
const next = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(next.case.id, first.case.id);
assert.equal(store.cases.size, 2);
});
test("answer is durably queued and poll remains read only", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.equal(created.case.status, "awaiting_answer");
assert.equal(created.case.currentQuestion?.domain, "education");
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
});
assert.equal(queued?.case.status, "processing");
assert.equal(queued?.job?.status, "pending");
const before = JSON.stringify([...store.jobs.values()]);
const polled = await service.loadCase(userId, created.case.id);
assert.equal(polled?.job, null);
assert.equal(JSON.stringify([...store.jobs.values()]), before);
});
test("worker extracts dated events, keeps one question and never confirms an exact minute", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
});
const worker = createRectificationV4Worker({
store,
now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const done = await service.loadCase(userId, created.case.id);
assert.equal(done?.case.status, "awaiting_answer");
assert.equal(done?.events.length, 2);
assert.equal(done?.case.currentQuestion?.domain, "relocation");
assert.equal(done?.case.latestSnapshot, null);
assert.equal(queued?.job?.status, "pending");
});
test("completed job rejects stale case or calculation hashes", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学" });
const claimed = await store.claimNextJob("worker", fixedNow().toISOString());
assert.ok(claimed);
await assert.rejects(() => store.completeJob({
workerId: "worker", jobId: claimed.job.id, expectedCaseVersion: claimed.case.version,
inputEvidenceSetHash: "0".repeat(64), outputEvidenceSetHash: claimed.case.evidenceSetHash,
calculationSpecHash: claimed.case.calculationSpecHash, newEventRevisions: [], snapshot: null,
nextQuestion: claimed.case.currentQuestion, status: "awaiting_answer", phase: "collecting_evidence",
}, fixedNow().toISOString()), /stale_job/);
});
test("worker moves from domain coverage to targeted date refinement without a null-question dead state", async () => {
const store = createRectificationV4MemoryStore();
const service = createRectificationV4CaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const scoreableCounts: number[] = [];
const worker = createRectificationV4Worker({
store,
now: fixedNow,
engine: {
async score({ calculationSpec, events }) {
scoreableCounts.push(events.length);
const ids = events.map((event) => event.eventId);
return {
resultId: randomUUID(),
calculationSpecHash: calculationSpecHash(calculationSpec),
candidates: [
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
],
robustness: {
neighborSupportMinutes: 3,
leaveOneOutRetentionRate: 1,
dateSensitivityRetentionRate: 0.5,
},
missingLayers: [],
};
},
},
});
let current = created;
for (const answer of [
"2016年高中毕业",
"2018年8月搬家到北京",
"2020年5月开始恋爱",
"2021年3月入职公司",
"2022年4月收入明显变化",
"2023年5月住院",
"2024年6月家庭发生变化",
]) {
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: current.case.version,
answer,
});
assert.ok(queued?.job);
assert.equal(await worker.runOnce(), true);
current = (await service.loadCase(userId, created.case.id))!;
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
}
const targetEventId = current.case.currentQuestion?.targetEventId;
assert.ok(targetEventId);
assert.equal(current.case.status, "awaiting_answer");
assert.equal(current.case.currentQuestion?.prompt.includes("更具体的日期"), true);
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: current.case.version,
answer: "2016年6月8日",
});
assert.ok(queued?.job);
assert.equal(await worker.runOnce(), true);
current = (await service.loadCase(userId, created.case.id))!;
const targetedRevisions = current.events.filter((event) => event.eventId === targetEventId);
assert.deepEqual(targetedRevisions.map((event) => event.revision), [1, 2]);
assert.equal(targetedRevisions[1]?.dateRange.precision, "day");
assert.equal(scoreableCounts.at(-1), 6);
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
assert.notEqual(current.case.currentQuestion?.targetEventId, targetEventId);
});
@@ -30,6 +30,10 @@ const syncScript = new URL(
"../../deploy/sync-staging-tree.sh",
import.meta.url,
);
const stagingCompose = new URL(
"../../deploy/docker-compose.staging.yml",
import.meta.url,
);
function read(url: URL): string {
return readFileSync(url, "utf8");
@@ -270,7 +274,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
"if [ \"$1\" = compose ]; then",
" if [[ \" $* \" == *\" up -d --no-build --remove-orphans \"* ]]; then",
` printf '%s|%s|%s|%s\\n' \"\${API_IMAGE:-}\" \"\${WEB_IMAGE:-}\" \"\${GITHUB_SHA:-}\" \"$*\" >>'${rollbackLog}'`,
" [[ \"$*\" == *\" api web caddy\" ]] && exit 0",
" [[ \"$*\" == *\" api web rectification-v4-worker caddy\" ]] && exit 0",
" exit 42",
" fi",
" exit 0",
@@ -308,7 +312,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
attempts[1],
new RegExp(`^${previousApiId}\\|${previousWebId}\\|${previousSha}\\|`),
);
assert.match(attempts[1], /api web caddy$/);
assert.match(attempts[1], /api web rectification-v4-worker caddy$/);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -328,10 +332,19 @@ test("normal deployment checks migrations but never applies them", () => {
assert.doesNotMatch(runner, /--profile migration run --rm migrator/);
assert.doesNotMatch(runner, /npm\s+run\s+db:migrate(?!:check)/);
assert.doesNotMatch(runner, /pull api web postgres/);
assert.match(runner, /verify_container_image rectification-v4-worker \"\$WEB_IMAGE\"/);
assert.match(runner, /adminRoot\.status !== 302/);
assert.match(runner, /adminRoot\.headers\.get\("location"\) !== "\/admin\/codes"/);
});
test("staging runs the rectification V4 worker from the immutable web image", () => {
const compose = read(stagingCompose);
assert.match(compose, /rectification-v4-worker:/);
assert.match(compose, /image: \$\{WEB_IMAGE:-jyotisha-web:local\}/);
assert.match(compose, /command: \["npm", "run", "worker:rectification-v4"\]/);
assert.ok(compose.includes("JYOTISH_API_BASE: http://api:5200"));
});
test("manual migration uses only PostgreSQL and the digest-pinned migrator", () => {
const workflow = read(migrationWorkflow);
const runner = read(migrationScript);