release() already clears the worker handlers, so the released checks inside them could never run. The lifetime test now states what it checks: no handler is left for late messages. Refs #277 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1014 lines
30 KiB
TypeScript
1014 lines
30 KiB
TypeScript
import type {
|
|
CounterfactualAnalysis,
|
|
CounterfactualSelection,
|
|
InvalidInputOutcome,
|
|
LeaveBalanceInput,
|
|
LeavePlacement,
|
|
PlanOutcome,
|
|
PlanningArtifacts,
|
|
PlanningExplanation,
|
|
PlanningIncompleteOutcome,
|
|
PlanningOutcome,
|
|
PlanningRequest,
|
|
PlanningSnapshot,
|
|
PlanningArtifactTimings,
|
|
PlanningReason,
|
|
PlanningStateSnapshot,
|
|
StaleCounterfactualAnalysis,
|
|
} from './leave-planner.core';
|
|
import { planningDateInParis } from './planning-date';
|
|
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
|
|
|
|
export type {
|
|
IfThenPlanningRule,
|
|
IfThenContribution,
|
|
ConflictExplanation,
|
|
CounterfactualAnalysis,
|
|
CounterfactualSelection,
|
|
CounterfactualDateResult,
|
|
CounterfactualReasonCode,
|
|
InvalidInputOutcome,
|
|
IncompleteExplanation,
|
|
LeaveBalanceInput,
|
|
LeavePlacement,
|
|
LeaveSelector,
|
|
LeaveType,
|
|
DeclaredNonWorkingDay,
|
|
PlanOutcome,
|
|
PlanExplanation,
|
|
PlacementMode,
|
|
PlanningConflictOutcome,
|
|
PlanningDate,
|
|
PlanningHorizon,
|
|
PlanningIncompleteOutcome,
|
|
PlanningExplanation,
|
|
PlanningMetrics,
|
|
PlanningObjective,
|
|
PlanningObjectiveComparison,
|
|
PlanningObjectiveDelta,
|
|
PlanningObjectiveId,
|
|
PlanningObjectiveVector,
|
|
PlanningOutcome,
|
|
PlanningReason,
|
|
PlanningRequest,
|
|
PlanningRule,
|
|
PlanningSnapshot,
|
|
PlanningArtifactTimings,
|
|
PlanningStateSnapshot,
|
|
PreferenceResult,
|
|
RemainingBalance,
|
|
RemainingClassification,
|
|
RuleForce,
|
|
TimeOffPeriod,
|
|
TimeWindowContribution,
|
|
TimeWindowPlanningRule,
|
|
OptimizationPlanningRule,
|
|
} from './leave-planner.core';
|
|
|
|
// Responsive workers renew this inactivity timer regardless of calculation duration.
|
|
const WORKER_LIVENESS_TIMEOUT_MS = 30_000;
|
|
|
|
interface UntrustedRecord {
|
|
horizon?: unknown;
|
|
start?: unknown;
|
|
end?: unknown;
|
|
balances?: unknown;
|
|
declaredDays?: unknown;
|
|
rules?: unknown;
|
|
identities?: unknown;
|
|
labels?: unknown;
|
|
deadlineMs?: unknown;
|
|
progress?: unknown;
|
|
id?: unknown;
|
|
name?: unknown;
|
|
type?: unknown;
|
|
quantity?: unknown;
|
|
label?: unknown;
|
|
availableFrom?: unknown;
|
|
expiryDate?: unknown;
|
|
enabled?: unknown;
|
|
force?: unknown;
|
|
startDate?: unknown;
|
|
endDate?: unknown;
|
|
placeDays?: unknown;
|
|
placeType?: unknown;
|
|
dayOfWeek?: unknown;
|
|
placement?: unknown;
|
|
rewardDays?: unknown;
|
|
rewardType?: unknown;
|
|
configuration?: unknown;
|
|
}
|
|
|
|
interface TransportSnapshot {
|
|
calculationDate?: string;
|
|
horizon?: { start?: unknown; end?: unknown };
|
|
balances?: unknown;
|
|
declaredDays?: unknown;
|
|
rules?: unknown;
|
|
identities?: unknown;
|
|
labels?: unknown;
|
|
deadlineMs?: unknown;
|
|
progress?: unknown;
|
|
}
|
|
|
|
function asRecord(value: unknown): UntrustedRecord | undefined {
|
|
return typeof value === 'object' && value !== null ? (value as UntrustedRecord) : undefined;
|
|
}
|
|
|
|
function copyDate(value: unknown): unknown {
|
|
return value instanceof Date ? new Date(value.getTime()) : value;
|
|
}
|
|
|
|
function cloneUnknown(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
|
|
if (value instanceof Date) return new Date(value.getTime());
|
|
if (typeof value !== 'object' || value === null) return value;
|
|
const previous = seen.get(value);
|
|
if (previous) return previous;
|
|
if (Array.isArray(value)) {
|
|
const result: unknown[] = [];
|
|
seen.set(value, result);
|
|
for (const item of value) result.push(cloneUnknown(item, seen));
|
|
return result;
|
|
}
|
|
const result: Record<string, unknown> = {};
|
|
seen.set(value, result);
|
|
for (const [key, item] of Object.entries(value)) result[key] = cloneUnknown(item, seen);
|
|
return result;
|
|
}
|
|
|
|
function snapshotValue(value: unknown): unknown {
|
|
if (value instanceof Date)
|
|
return Number.isNaN(value.getTime()) ? 'Invalid Date' : planningDateInParis(value);
|
|
if (Array.isArray(value)) return value.map(snapshotValue);
|
|
if (typeof value !== 'object' || value === null) return value;
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, item] of Object.entries(value)) result[key] = snapshotValue(item);
|
|
return result;
|
|
}
|
|
|
|
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
|
|
if (typeof value !== 'object' || value === null || seen.has(value)) return value;
|
|
seen.add(value);
|
|
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child, seen);
|
|
return Object.freeze(value);
|
|
}
|
|
|
|
function stringMap(value: unknown): Readonly<Record<string, string>> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, item]) => [
|
|
key,
|
|
typeof item === 'string' ? item : String(item),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
|
|
const horizon = request.horizon ?? {};
|
|
const balances = Array.isArray(request.balances)
|
|
? request.balances.map((value) => snapshotValue(value) as LeaveBalanceInput)
|
|
: [];
|
|
const rules = Array.isArray(request.rules)
|
|
? request.rules.map((value) => snapshotValue(value) as PlanningRequest['rules'][number])
|
|
: [];
|
|
const labels = stringMap(request.labels);
|
|
return deepFreeze({
|
|
calculationDate: request.calculationDate,
|
|
horizon: {
|
|
start: String(snapshotValue(horizon.start) ?? ''),
|
|
end: String(snapshotValue(horizon.end) ?? ''),
|
|
},
|
|
balances,
|
|
...(request.declaredDays === undefined
|
|
? {}
|
|
: { declaredDays: snapshotValue(request.declaredDays) }),
|
|
rules,
|
|
identities: stringMap(request.identities),
|
|
labels,
|
|
}) as PlanningSnapshot;
|
|
}
|
|
|
|
function snapshotRequest(request: unknown): TransportSnapshot {
|
|
const source = asRecord(request) ?? {};
|
|
const horizon = asRecord(source.horizon);
|
|
const balances = Array.isArray(source.balances)
|
|
? source.balances.map((balance) => {
|
|
const value = asRecord(balance);
|
|
return {
|
|
id: value?.id,
|
|
type: value?.type,
|
|
quantity: value?.quantity,
|
|
name: value?.name,
|
|
label: value?.label,
|
|
availableFrom:
|
|
value?.availableFrom === undefined
|
|
? undefined
|
|
: copyDate(value.availableFrom),
|
|
expiryDate: copyDate(value?.expiryDate),
|
|
};
|
|
})
|
|
: source.balances;
|
|
const rules = Array.isArray(source.rules)
|
|
? source.rules.map((rule) => {
|
|
const value = asRecord(rule);
|
|
const base = {
|
|
id: value?.id,
|
|
name: value?.name,
|
|
type: value?.type,
|
|
enabled: value?.enabled,
|
|
force: value?.force,
|
|
};
|
|
if (
|
|
typeof value?.type === 'string' &&
|
|
value.type.toLowerCase() === 'optimization'
|
|
) {
|
|
return { ...base, configuration: cloneUnknown(value.configuration) };
|
|
}
|
|
if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') {
|
|
return {
|
|
...base,
|
|
startDate: copyDate(value.startDate),
|
|
endDate: copyDate(value.endDate),
|
|
placeDays: value.placeDays,
|
|
placeType: value.placeType,
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
dayOfWeek: value?.dayOfWeek,
|
|
placeDays: value?.placeDays,
|
|
placeType: value?.placeType,
|
|
placement: value?.placement,
|
|
rewardDays: value?.rewardDays,
|
|
rewardType: value?.rewardType,
|
|
};
|
|
})
|
|
: source.rules;
|
|
return {
|
|
horizon: horizon
|
|
? {
|
|
start: copyDate(horizon.start),
|
|
end: copyDate(horizon.end),
|
|
}
|
|
: undefined,
|
|
balances,
|
|
declaredDays: cloneUnknown(source.declaredDays),
|
|
rules,
|
|
identities: cloneUnknown(source.identities),
|
|
labels: cloneUnknown(source.labels),
|
|
deadlineMs: source.deadlineMs,
|
|
progress: typeof source.progress === 'function' ? source.progress : undefined,
|
|
};
|
|
}
|
|
|
|
function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined {
|
|
const deadline = request.deadlineMs;
|
|
if (
|
|
deadline !== undefined &&
|
|
(typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline < 0)
|
|
)
|
|
return {
|
|
kind: 'InvalidInput',
|
|
reasons: [
|
|
{
|
|
code: 'deadline',
|
|
message: 'The planning deadline must be a finite non-negative number.',
|
|
},
|
|
],
|
|
};
|
|
return undefined;
|
|
}
|
|
|
|
interface PlanningRun {
|
|
outcome: Promise<PlanningOutcome>;
|
|
explanation: Promise<PlanningExplanation | undefined>;
|
|
explanationError: Promise<PlanningReason | undefined>;
|
|
artifacts: Promise<PlanningArtifacts>;
|
|
timings: Promise<PlanningCalculationTimings>;
|
|
cancel?: () => void;
|
|
}
|
|
|
|
export type PlanningCalculationTimings = Partial<PlanningArtifactTimings>;
|
|
|
|
function interruptedOutcome(
|
|
checkpoint: PlanOutcome | undefined,
|
|
reasonCode: string,
|
|
message: string,
|
|
): PlanningIncompleteOutcome {
|
|
return {
|
|
kind: 'PlanningIncomplete',
|
|
incumbent: checkpoint,
|
|
completedStages: checkpoint?.completedStages ?? [],
|
|
lastCompletedStage: checkpoint?.lastCompletedStage,
|
|
reasons: [{ code: reasonCode, message }],
|
|
};
|
|
}
|
|
|
|
const latestWorkerCancels = new Set<() => void>();
|
|
let latestCounterfactualCancel: (() => void) | undefined;
|
|
|
|
interface WorkerJob {
|
|
post(message: WorkerRequest, onFailure: (error: unknown) => void): void;
|
|
release(): void;
|
|
}
|
|
|
|
// Single owner of a worker's lifetime: creation, handlers, cancellation registration and an idempotent release.
|
|
function startWorkerJob(
|
|
supersede: () => void,
|
|
onMessage: (message: WorkerResponse) => void,
|
|
onError: () => void,
|
|
): WorkerJob {
|
|
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
type: 'module',
|
|
});
|
|
let released = false;
|
|
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
|
if (event.data) onMessage(event.data);
|
|
};
|
|
worker.onerror = () => {
|
|
onError();
|
|
};
|
|
latestWorkerCancels.add(supersede);
|
|
return {
|
|
post(message, onFailure) {
|
|
try {
|
|
worker.postMessage(message);
|
|
} catch (error) {
|
|
onFailure(error);
|
|
}
|
|
},
|
|
release() {
|
|
if (released) return;
|
|
released = true;
|
|
worker.onmessage = null;
|
|
worker.onerror = null;
|
|
worker.terminate();
|
|
latestWorkerCancels.delete(supersede);
|
|
},
|
|
};
|
|
}
|
|
|
|
function cancelLatestWorkers(): void {
|
|
for (const cancel of [...latestWorkerCancels]) cancel();
|
|
latestWorkerCancels.clear();
|
|
latestCounterfactualCancel = undefined;
|
|
}
|
|
|
|
function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|
let resolveOutcome!: (outcome: PlanningOutcome) => void;
|
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
|
let resolveTimings!: (timings: PlanningCalculationTimings) => void;
|
|
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
|
resolveOutcome = resolve;
|
|
});
|
|
const explanation = new Promise<PlanningExplanation | undefined>((resolve) => {
|
|
resolveExplanation = resolve;
|
|
});
|
|
const explanationError = new Promise<PlanningReason | undefined>((resolve) => {
|
|
resolveExplanationError = resolve;
|
|
});
|
|
const artifacts = new Promise<PlanningArtifacts>((resolve) => {
|
|
resolveArtifacts = resolve;
|
|
});
|
|
const timings = new Promise<PlanningCalculationTimings>((resolve) => {
|
|
resolveTimings = resolve;
|
|
});
|
|
let cancel: (() => void) | undefined;
|
|
try {
|
|
const job = startWorkerJob(
|
|
() => supersede(),
|
|
(message) => handleMessage(message),
|
|
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
|
|
);
|
|
let outcomeSettled = false;
|
|
let cancellationRequested = false;
|
|
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
|
let explanationSettled = false;
|
|
let artifactsSettled = false;
|
|
let checkpoint: PlanOutcome | undefined;
|
|
let checkpointState: PlanningStateSnapshot | undefined;
|
|
let receivedExplanation: PlanningExplanation | undefined;
|
|
let receivedExplanationError: PlanningReason | undefined;
|
|
let receivedFinalState: PlanningStateSnapshot | undefined;
|
|
let livenessWatchdog: ReturnType<typeof setTimeout>;
|
|
const terminate = () => {
|
|
job.release();
|
|
clearTimeout(livenessWatchdog);
|
|
};
|
|
const finishOutcome = (result: PlanningOutcome) => {
|
|
if (outcomeSettled) return;
|
|
if (
|
|
cancellationRequested &&
|
|
(result.kind === 'Plan' || result.kind === 'PlanningConflict')
|
|
) {
|
|
cancelledRace = interruptedOutcome(
|
|
result.kind === 'Plan' ? result : checkpoint,
|
|
'user-cancelled',
|
|
'The calculation was cancelled by the user.',
|
|
);
|
|
result = cancelledRace;
|
|
}
|
|
outcomeSettled = true;
|
|
clearTimeout(livenessWatchdog);
|
|
resolveOutcome(result);
|
|
};
|
|
const cancelledExplanation = (
|
|
result: PlanningExplanation | undefined,
|
|
): PlanningExplanation | undefined => {
|
|
if (!cancelledRace) return result;
|
|
const incomplete = {
|
|
kind: 'IncompleteExplanation' as const,
|
|
reason: cancelledRace.reasons[0],
|
|
completedStages: cancelledRace.completedStages,
|
|
uncompletedStages: [],
|
|
};
|
|
return cancelledRace.incumbent && result?.kind === 'PlanExplanation'
|
|
? { ...result, incomplete }
|
|
: incomplete;
|
|
};
|
|
const finishExplanation = (
|
|
result: PlanningExplanation | undefined,
|
|
error: PlanningReason | undefined,
|
|
finalState?: PlanningStateSnapshot,
|
|
) => {
|
|
if (explanationSettled) return;
|
|
if (!error) result = cancelledExplanation(result);
|
|
explanationSettled = true;
|
|
receivedExplanation = result;
|
|
receivedExplanationError = error;
|
|
receivedFinalState = finalState;
|
|
resolveExplanation(result);
|
|
resolveExplanationError(error);
|
|
};
|
|
const finishArtifacts = (result: PlanningArtifacts, finishExplanationPromise = true) => {
|
|
if (artifactsSettled) return;
|
|
artifactsSettled = true;
|
|
const complete = {
|
|
...result,
|
|
explanation: result.explanation ?? receivedExplanation,
|
|
explanationError: result.explanationError ?? receivedExplanationError,
|
|
finalState: result.finalState ?? receivedFinalState,
|
|
};
|
|
if (cancelledRace && !complete.explanationError)
|
|
complete.explanation = cancelledExplanation(complete.explanation);
|
|
if (finishExplanationPromise)
|
|
finishExplanation(
|
|
complete.explanation,
|
|
complete.explanationError,
|
|
complete.finalState,
|
|
);
|
|
resolveArtifacts(complete);
|
|
resolveTimings(complete.timings ?? {});
|
|
terminate();
|
|
};
|
|
const supersede = () => {
|
|
if (artifactsSettled) return;
|
|
const hadOutcome = outcomeSettled;
|
|
const reason = {
|
|
code: 'stale-calculation',
|
|
message: 'Planning stopped because a newer calculation started.',
|
|
};
|
|
if (!hadOutcome)
|
|
finishOutcome(interruptedOutcome(checkpoint, reason.code, reason.message));
|
|
finishExplanation(
|
|
receivedExplanation,
|
|
hadOutcome ? receivedExplanationError : reason,
|
|
receivedFinalState ?? checkpointState,
|
|
);
|
|
finishArtifacts({
|
|
explanation: receivedExplanation,
|
|
explanationError: hadOutcome ? receivedExplanationError : reason,
|
|
finalState: receivedFinalState ?? checkpointState,
|
|
});
|
|
};
|
|
const fail = (code: string, message: string) => {
|
|
const reason = { code, message };
|
|
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
|
if (!explanationSettled && checkpoint && checkpointState) {
|
|
finishArtifacts({ explanationError: reason, finalState: checkpointState }, false);
|
|
void explainCheckpointInWorker(
|
|
publicSnapshot(snapshot),
|
|
checkpoint,
|
|
checkpointState,
|
|
reason,
|
|
)
|
|
.then((result) =>
|
|
finishExplanation(
|
|
result.explanation,
|
|
result.explanationError ?? reason,
|
|
result.finalState ?? checkpointState,
|
|
),
|
|
)
|
|
.catch((error: unknown) =>
|
|
finishExplanation(
|
|
undefined,
|
|
{
|
|
code: 'explanation-error',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
},
|
|
checkpointState,
|
|
),
|
|
);
|
|
} else
|
|
finishArtifacts({
|
|
explanation: receivedExplanation,
|
|
explanationError: receivedExplanationError ?? reason,
|
|
finalState: receivedFinalState ?? checkpointState,
|
|
});
|
|
};
|
|
const scheduleLivenessWatchdog = () => {
|
|
if (outcomeSettled || artifactsSettled) return;
|
|
clearTimeout(livenessWatchdog);
|
|
livenessWatchdog = setTimeout(
|
|
() =>
|
|
fail(
|
|
'worker-error',
|
|
'Planning worker stopped responding before returning its outcome.',
|
|
),
|
|
WORKER_LIVENESS_TIMEOUT_MS,
|
|
);
|
|
};
|
|
scheduleLivenessWatchdog();
|
|
cancel = () => {
|
|
if (outcomeSettled || cancellationRequested) return;
|
|
cancellationRequested = true;
|
|
job.post({ type: 'cancel' }, () =>
|
|
fail('worker-error', 'The cancellation request could not reach the worker.'),
|
|
);
|
|
};
|
|
const handleMessage = (message: WorkerResponse) => {
|
|
if (message.type === 'heartbeat') {
|
|
scheduleLivenessWatchdog();
|
|
return;
|
|
}
|
|
if (message.type === 'progress') {
|
|
scheduleLivenessWatchdog();
|
|
if (typeof snapshot.progress === 'function') snapshot.progress(message.stage);
|
|
return;
|
|
}
|
|
if (message.type === 'checkpoint') {
|
|
scheduleLivenessWatchdog();
|
|
checkpoint = message.plan;
|
|
checkpointState = message.state;
|
|
return;
|
|
}
|
|
if (message.type === 'result') {
|
|
finishOutcome(message.outcome);
|
|
if (message.outcome.kind === 'InvalidInput') finishArtifacts({});
|
|
return;
|
|
}
|
|
if (message.type === 'explanation') {
|
|
finishExplanation(
|
|
message.explanation,
|
|
message.explanationError,
|
|
message.finalState,
|
|
);
|
|
return;
|
|
}
|
|
if (message.type === 'artifacts') {
|
|
finishArtifacts(message.artifacts);
|
|
return;
|
|
}
|
|
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
|
|
else fail('worker-error', 'Planning worker returned an unexpected response.');
|
|
};
|
|
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
|
|
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
|
|
);
|
|
} catch {
|
|
const result = interruptedOutcome(
|
|
undefined,
|
|
'worker-unavailable',
|
|
'Planning could not start because isolated execution is unavailable.',
|
|
);
|
|
resolveOutcome(result);
|
|
resolveArtifacts({ explanationError: result.reasons[0] });
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(result.reasons[0]);
|
|
resolveTimings({});
|
|
}
|
|
return { outcome, explanation, explanationError, artifacts, timings, cancel };
|
|
}
|
|
|
|
let calculationSequence = 0;
|
|
let latestCalculationId = '';
|
|
|
|
function selectionFor(
|
|
plan: PlanOutcome,
|
|
selection: CounterfactualSelection,
|
|
): LeavePlacement | undefined {
|
|
if (typeof selection !== 'string')
|
|
return plan.placements.find(
|
|
(candidate) =>
|
|
candidate.date === selection.date && candidate.balanceId === selection.balanceId,
|
|
);
|
|
return plan.placements.find(
|
|
(candidate) => candidate.date === selection || candidate.balanceId === selection,
|
|
);
|
|
}
|
|
|
|
async function runCounterfactual(
|
|
context: PlanningCalculationContext,
|
|
selection: CounterfactualSelection,
|
|
artifacts: Promise<PlanningArtifacts>,
|
|
isCurrent: () => boolean,
|
|
): Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis> {
|
|
const stale = (placement: LeavePlacement): StaleCounterfactualAnalysis => ({
|
|
kind: 'CounterfactualStale',
|
|
calculationId: context.id,
|
|
placement,
|
|
hypothetical: true,
|
|
results: [],
|
|
});
|
|
if (context.id !== latestCalculationId) {
|
|
const plan = await context.outcome;
|
|
const finalPlan =
|
|
plan.kind === 'Plan'
|
|
? plan
|
|
: plan.kind === 'PlanningIncomplete'
|
|
? plan.incumbent
|
|
: undefined;
|
|
const placement = finalPlan ? selectionFor(finalPlan, selection) : undefined;
|
|
if (!placement)
|
|
throw new Error('The selected placement is not present in the calculation.');
|
|
return stale(placement);
|
|
}
|
|
const outcome = await context.outcome;
|
|
if (context.id !== latestCalculationId || !isCurrent()) {
|
|
const plan =
|
|
outcome.kind === 'Plan'
|
|
? outcome
|
|
: outcome.kind === 'PlanningIncomplete'
|
|
? outcome.incumbent
|
|
: undefined;
|
|
const placement = plan ? selectionFor(plan, selection) : undefined;
|
|
if (!placement)
|
|
throw new Error('The selected placement is not present in the calculation.');
|
|
return stale(placement);
|
|
}
|
|
const plan =
|
|
outcome.kind === 'Plan'
|
|
? outcome
|
|
: outcome.kind === 'PlanningIncomplete'
|
|
? outcome.incumbent
|
|
: undefined;
|
|
if (!plan) throw new Error('Counterfactual analysis requires a plan outcome.');
|
|
const placement = selectionFor(plan, selection);
|
|
if (!placement) throw new Error('The selected placement is not present in the calculation.');
|
|
const completedArtifacts = await artifacts;
|
|
if (context.id !== latestCalculationId || !isCurrent()) return stale(placement);
|
|
if (typeof window !== 'undefined' && typeof Worker !== 'undefined') {
|
|
let result: CounterfactualAnalysis;
|
|
try {
|
|
result = await counterfactualInWorker(
|
|
context.snapshot,
|
|
plan,
|
|
placement,
|
|
completedArtifacts.finalState,
|
|
);
|
|
} catch (error) {
|
|
if (context.id !== latestCalculationId || !isCurrent()) return stale(placement);
|
|
throw error;
|
|
}
|
|
if (context.id !== latestCalculationId || !isCurrent()) return stale(placement);
|
|
return { ...result, calculationId: context.id };
|
|
}
|
|
const core = await import('./leave-planner.core');
|
|
const result = core.analyzeCounterfactualInternal(
|
|
context.snapshot,
|
|
plan,
|
|
placement,
|
|
completedArtifacts.finalState,
|
|
);
|
|
return context.id === latestCalculationId && isCurrent()
|
|
? { ...result, calculationId: context.id }
|
|
: stale(placement);
|
|
}
|
|
|
|
function counterfactualInWorker(
|
|
snapshot: PlanningSnapshot,
|
|
plan: PlanOutcome,
|
|
placement: LeavePlacement,
|
|
state?: PlanningStateSnapshot,
|
|
): Promise<CounterfactualAnalysis> {
|
|
return new Promise((resolve, reject) => {
|
|
latestCounterfactualCancel?.();
|
|
let job: WorkerJob;
|
|
const finish = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
job.release();
|
|
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
|
|
callback();
|
|
};
|
|
let settled = false;
|
|
const cancel = () =>
|
|
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
|
|
try {
|
|
job = startWorkerJob(
|
|
cancel,
|
|
(message) => {
|
|
if (message.type === 'counterfactual-result' && message.analysis)
|
|
finish(() => resolve(message.analysis));
|
|
else if (message.type === 'error')
|
|
finish(() =>
|
|
reject(
|
|
new Error(
|
|
message.message ??
|
|
`Counterfactual analysis failed (${message.code ?? 'worker-error'}).`,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
() => finish(() => reject(new Error('Counterfactual worker failed.'))),
|
|
);
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
latestCounterfactualCancel = cancel;
|
|
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
|
|
finish(() => reject(error)),
|
|
);
|
|
});
|
|
}
|
|
|
|
function explainCheckpointInWorker(
|
|
snapshot: PlanningSnapshot,
|
|
plan: PlanOutcome,
|
|
state: PlanningStateSnapshot,
|
|
interruption: PlanningReason,
|
|
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
|
|
return new Promise((resolve, reject) => {
|
|
let job: WorkerJob;
|
|
let settled = false;
|
|
const finish = (callback: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
job.release();
|
|
callback();
|
|
};
|
|
try {
|
|
job = startWorkerJob(
|
|
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
|
|
(response) => {
|
|
if (response.type === 'explanation')
|
|
finish(() =>
|
|
resolve({
|
|
explanation: response.explanation,
|
|
explanationError: response.explanationError,
|
|
finalState: response.finalState,
|
|
}),
|
|
);
|
|
else if (response.type === 'error')
|
|
finish(() => reject(new Error(response.message)));
|
|
},
|
|
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
|
|
);
|
|
} catch (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
job.post(
|
|
{ type: 'explain-checkpoint', request: snapshot, plan, state, interruption },
|
|
(error) => finish(() => reject(error)),
|
|
);
|
|
});
|
|
}
|
|
|
|
export interface PlanningCalculationContext {
|
|
readonly cancel: () => void;
|
|
readonly id: string;
|
|
readonly snapshot: PlanningSnapshot;
|
|
readonly outcome: Promise<PlanningOutcome>;
|
|
readonly explanation: Promise<PlanningExplanation | undefined>;
|
|
readonly explanationError: Promise<PlanningReason | undefined>;
|
|
readonly timings?: Promise<PlanningCalculationTimings>;
|
|
readonly analyzeCounterfactual: (
|
|
selection: CounterfactualSelection,
|
|
) => Promise<CounterfactualAnalysis | StaleCounterfactualAnalysis>;
|
|
}
|
|
|
|
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
|
|
const calculationDate = planningDateInParis(new Date());
|
|
cancelLatestWorkers();
|
|
const transport = { ...snapshotRequest(request), calculationDate };
|
|
const snapshot = publicSnapshot(transport);
|
|
const id = `calculation:${++calculationSequence}`;
|
|
latestCalculationId = id;
|
|
const deadlineError = invalidDeadline(transport);
|
|
let run: PlanningRun;
|
|
if (deadlineError) {
|
|
run = {
|
|
outcome: new Promise((resolve) => queueMicrotask(() => resolve(deadlineError))),
|
|
explanation: Promise.resolve(undefined),
|
|
explanationError: Promise.resolve(undefined),
|
|
artifacts: Promise.resolve({}),
|
|
timings: Promise.resolve({}),
|
|
};
|
|
} else {
|
|
if (typeof window !== 'undefined') {
|
|
if (typeof Worker !== 'undefined') {
|
|
run = planInWorker(transport);
|
|
} else {
|
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
|
const artifacts = new Promise<PlanningArtifacts>(
|
|
(resolve) => (resolveArtifacts = resolve),
|
|
);
|
|
const explanation = new Promise<PlanningExplanation | undefined>(
|
|
(resolve) => (resolveExplanation = resolve),
|
|
);
|
|
const explanationError = new Promise<PlanningReason | undefined>(
|
|
(resolve) => (resolveExplanationError = resolve),
|
|
);
|
|
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
|
queueMicrotask(async () => {
|
|
const core = await import('./leave-planner.core');
|
|
const validation = core.invalidInputFor(transport);
|
|
if (validation) {
|
|
resolve(validation);
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(undefined);
|
|
resolveArtifacts({});
|
|
return;
|
|
}
|
|
const result = interruptedOutcome(
|
|
undefined,
|
|
'worker-unavailable',
|
|
'Planning could not start because isolated execution is unavailable.',
|
|
);
|
|
resolve(result);
|
|
const incompleteExplanation: PlanningExplanation = {
|
|
kind: 'IncompleteExplanation',
|
|
reason: result.reasons[0],
|
|
completedStages: [],
|
|
uncompletedStages: [],
|
|
};
|
|
resolveExplanation(incompleteExplanation);
|
|
resolveExplanationError(undefined);
|
|
resolveArtifacts({
|
|
explanation: incompleteExplanation,
|
|
});
|
|
});
|
|
});
|
|
run = {
|
|
outcome,
|
|
explanation,
|
|
explanationError,
|
|
artifacts,
|
|
timings: Promise.resolve({}),
|
|
};
|
|
}
|
|
} else {
|
|
let cancellationRequested = false;
|
|
let outcomeSettled = false;
|
|
let superseded = false;
|
|
const replacementReason: PlanningReason = {
|
|
code: 'stale-calculation',
|
|
message: 'Planning stopped because a newer calculation started.',
|
|
};
|
|
const explanationForRun = (value: PlanningExplanation | undefined) => {
|
|
if (!superseded) return value;
|
|
if (value?.kind === 'IncompleteExplanation')
|
|
return { ...value, reason: replacementReason };
|
|
if (value?.kind === 'PlanExplanation' && value.incomplete)
|
|
return {
|
|
...value,
|
|
incomplete: { ...value.incomplete, reason: replacementReason },
|
|
};
|
|
return value;
|
|
};
|
|
const supersede = () => {
|
|
if (!outcomeSettled) {
|
|
superseded = true;
|
|
cancellationRequested = true;
|
|
}
|
|
};
|
|
latestWorkerCancels.add(supersede);
|
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
|
let resolveTimings!: (timings: PlanningCalculationTimings) => void;
|
|
const artifacts = new Promise<PlanningArtifacts>(
|
|
(resolve) => (resolveArtifacts = resolve),
|
|
);
|
|
const explanation = new Promise<PlanningExplanation | undefined>(
|
|
(resolve) => (resolveExplanation = resolve),
|
|
);
|
|
const explanationError = new Promise<PlanningReason | undefined>(
|
|
(resolve) => (resolveExplanationError = resolve),
|
|
);
|
|
const timings = new Promise<PlanningCalculationTimings>(
|
|
(resolve) => (resolveTimings = resolve),
|
|
);
|
|
const outcome = new Promise<PlanningOutcome>((resolve) => {
|
|
queueMicrotask(async () => {
|
|
try {
|
|
const core = await import('./leave-planner.core');
|
|
let completedArtifacts: PlanningArtifacts = {};
|
|
const result = await core.calculatePlanningAsyncInternal(
|
|
transport,
|
|
undefined,
|
|
(value) => (completedArtifacts = value),
|
|
(value) => {
|
|
outcomeSettled = true;
|
|
resolve(
|
|
superseded && value.kind === 'PlanningIncomplete'
|
|
? {
|
|
...value,
|
|
reasons: [replacementReason],
|
|
}
|
|
: value,
|
|
);
|
|
},
|
|
(value, error) => {
|
|
resolveExplanation(explanationForRun(value));
|
|
resolveExplanationError(error);
|
|
},
|
|
() => cancellationRequested,
|
|
);
|
|
if (result.kind === 'InvalidInput') {
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(undefined);
|
|
}
|
|
resolve(result);
|
|
queueMicrotask(() => {
|
|
resolveArtifacts({
|
|
...completedArtifacts,
|
|
explanation: explanationForRun(completedArtifacts.explanation),
|
|
});
|
|
resolveTimings(completedArtifacts.timings ?? {});
|
|
});
|
|
} catch (error) {
|
|
const reason: PlanningReason = {
|
|
code: 'planner-error',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
resolve({
|
|
kind: 'PlanningIncomplete',
|
|
completedStages: [],
|
|
reasons: [reason],
|
|
});
|
|
resolveExplanation(undefined);
|
|
resolveExplanationError(reason);
|
|
resolveArtifacts({
|
|
explanationError: reason,
|
|
});
|
|
resolveTimings({});
|
|
} finally {
|
|
outcomeSettled = true;
|
|
latestWorkerCancels.delete(supersede);
|
|
}
|
|
});
|
|
});
|
|
run = {
|
|
outcome,
|
|
explanation,
|
|
explanationError,
|
|
artifacts,
|
|
timings,
|
|
cancel: () => {
|
|
if (!outcomeSettled) cancellationRequested = true;
|
|
},
|
|
};
|
|
}
|
|
}
|
|
let counterfactualSequence = 0;
|
|
const context: PlanningCalculationContext = {
|
|
id,
|
|
snapshot,
|
|
cancel: () => run.cancel?.(),
|
|
outcome: run.outcome.then((result) => ({
|
|
...result,
|
|
calculationDate,
|
|
...(result.kind === 'PlanningIncomplete' && result.incumbent
|
|
? { incumbent: { ...result.incumbent, calculationDate } }
|
|
: {}),
|
|
})),
|
|
explanation: run.explanation.then((result) =>
|
|
result ? { ...result, calculationDate } : undefined,
|
|
),
|
|
explanationError: run.explanationError,
|
|
timings: run.timings,
|
|
analyzeCounterfactual: (selection) => {
|
|
const sequence = ++counterfactualSequence;
|
|
if (id === latestCalculationId) latestCounterfactualCancel?.();
|
|
return runCounterfactual(
|
|
context,
|
|
selection,
|
|
run.artifacts,
|
|
() => sequence === counterfactualSequence,
|
|
);
|
|
},
|
|
};
|
|
return context;
|
|
}
|
|
|
|
export async function planLeave(request: PlanningRequest): Promise<PlanningOutcome> {
|
|
return createPlanningCalculation(request).outcome;
|
|
}
|