114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
import { isDeepStrictEqual } from "node:util";
|
|
import type {
|
|
BirthTimeJourneyStore,
|
|
DynamicStoredRectificationCase,
|
|
} from "../src/lib/birth-time-journey-service.ts";
|
|
import { samePersistedDynamicReceipt } from "../src/lib/birth-time-dynamic-action-replay.ts";
|
|
import type { DynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
|
|
import { BirthTimeScoringJobError, scoringJobDurationMs, scoringProcessingLeaseMs } from "../src/lib/birth-time-scoring-job.ts";
|
|
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
|
|
|
|
type DynamicMemoryJob = DynamicScoringJobSpec & {
|
|
readonly caseId: string;
|
|
readonly userId: string;
|
|
readonly status: "pending" | "processing" | "completed" | "failed";
|
|
readonly updatedAt: string;
|
|
readonly result: NonNullable<DynamicStoredRectificationCase["candidateResult"]> | null;
|
|
};
|
|
|
|
function completedMatches(
|
|
job: DynamicMemoryJob,
|
|
current: DynamicStoredRectificationCase,
|
|
): boolean {
|
|
const result = job.result;
|
|
const action = current.dynamicTurnState.nextAction;
|
|
if (result === null || !isDeepStrictEqual(current.candidateResult, result)) return false;
|
|
if (result.confidence === "high") {
|
|
return action.kind === "request_candidate_confirmation" && action.resultId === result.resultId;
|
|
}
|
|
if (action.kind === "generate_dynamic_question") return true;
|
|
return result.confidence === "medium"
|
|
? action.kind === "present_medium_result" && action.resultId === result.resultId
|
|
: action.kind === "present_low_result" && action.resultId === result.resultId;
|
|
}
|
|
|
|
export function dynamicJobStore(
|
|
base: BirthTimeJourneyStore,
|
|
read: () => DynamicStoredRectificationCase | null,
|
|
) {
|
|
const jobs = new Map<string, DynamicMemoryJob>();
|
|
const store: BirthTimeJourneyStore = {
|
|
...base,
|
|
async createDynamicScoringJob(value, expectedVersion, actionId, _questionId, spec) {
|
|
const current = read();
|
|
if (!current) throw new BirthTimeScoringJobError("unavailable");
|
|
const receipt = actionId.toLowerCase();
|
|
if (current.processedActionIds.includes(receipt)) {
|
|
if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current;
|
|
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
|
|
}
|
|
if (current.turnVersion !== expectedVersion) {
|
|
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
|
|
}
|
|
jobs.set(spec.jobId, {
|
|
...spec,
|
|
caseId: value.id,
|
|
userId: value.userId,
|
|
status: "pending",
|
|
updatedAt: new Date(Date.parse(spec.expiresAt) - scoringJobDurationMs).toISOString(),
|
|
result: null,
|
|
});
|
|
return base.saveDynamicTurn(value, expectedVersion, receipt);
|
|
},
|
|
async claimDynamicScoringJob(identity) {
|
|
const job = jobs.get(identity.jobId);
|
|
if (!job || job.caseId !== identity.caseId || job.userId !== identity.userId
|
|
|| job.evidenceFingerprint !== identity.evidenceFingerprint) {
|
|
throw new BirthTimeScoringJobError("unavailable");
|
|
}
|
|
if (job.algorithmVersion !== identity.algorithmVersion) {
|
|
throw new BirthTimeScoringJobError("algorithm_mismatch");
|
|
}
|
|
const current = read();
|
|
if (!current) throw new BirthTimeScoringJobError("unavailable");
|
|
if (job.status === "completed") {
|
|
if (!completedMatches(job, current)) throw new BirthTimeScoringJobError("invalid_turn");
|
|
return { kind: "completed", algorithmVersion: job.algorithmVersion };
|
|
}
|
|
const action = current.dynamicTurnState.nextAction;
|
|
if ((action.kind !== "score_pending" && action.kind !== "retry_scoring")
|
|
|| action.jobId !== identity.jobId) throw new BirthTimeScoringJobError("invalid_turn");
|
|
if (job.status === "processing") {
|
|
const leaseEnds = Date.parse(job.updatedAt) + scoringProcessingLeaseMs;
|
|
if (Date.parse(identity.now) < leaseEnds) {
|
|
return { kind: "processing", algorithmVersion: job.algorithmVersion };
|
|
}
|
|
}
|
|
jobs.set(job.jobId, { ...job, status: "processing", updatedAt: identity.now });
|
|
return { kind: "claimed", algorithmVersion: job.algorithmVersion };
|
|
},
|
|
async completeDynamicScoringJob(value, command) {
|
|
const job = jobs.get(command.jobId);
|
|
if (!job || job.status !== "processing") {
|
|
throw new BirthTimeScoringJobError("invalid_turn");
|
|
}
|
|
const saved = await base.completeDynamicScoringJob(value, command);
|
|
jobs.set(job.jobId, { ...job, status: "completed", result: value.candidateResult ?? null });
|
|
return saved;
|
|
},
|
|
async failDynamicScoringJob(value, command) {
|
|
const job = jobs.get(command.jobId);
|
|
if (!job || job.status !== "processing") {
|
|
throw new BirthTimeScoringJobError("invalid_turn");
|
|
}
|
|
const saved = await base.failDynamicScoringJob(value, command);
|
|
jobs.set(job.jobId, { ...job, status: "failed" });
|
|
return saved;
|
|
},
|
|
};
|
|
return {
|
|
store,
|
|
count: () => jobs.size,
|
|
};
|
|
}
|