Files
kandev/apps/web/lib/state/workflow-session-focus.ts
2026-09-19 21:24:01 +01:00

282 lines
8.4 KiB
TypeScript

import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
export type WorkflowSessionFocusStart = {
taskId: string;
workflowId: string;
destinationStepId: string;
presentationToken: number;
navigationRevision: number;
};
export type WorkflowSessionFocusIntent = WorkflowSessionFocusStart & {
requestId: number;
entryIdentity: string | null;
};
export type WorkflowSessionFocusRequest = {
requestId: number;
presentationToken: number;
taskId: string;
sessionId: string;
};
export type WorkflowSessionFocusState = {
nextRequestId: number;
intent: WorkflowSessionFocusIntent | null;
request: WorkflowSessionFocusRequest | null;
};
export type WorkflowSessionFocusCancelScope = {
requestId?: number;
presentationToken?: number;
taskId?: string;
};
export type WorkflowSessionFocusRoute = {
operationId: string;
destinationStepId: string;
entryIdentity: string;
destinationSessionId: string;
phase: "committed";
};
/**
* The task projection returned by the move request. It is a candidate only;
* the live task projection remains authoritative when it is newer.
*/
export type WorkflowSessionFocusTaskProjection = {
metadata: unknown;
updatedAt?: string | null;
entryIdentity?: string | null;
workflowStepId?: string | null;
};
export type WorkflowSessionFocusReconcileInput = {
activeTaskId: string | null;
navigationRevision: number;
routeMetadata: unknown;
routeUpdatedAt?: string | null;
workflowStepId?: string | null;
responseProjection?: WorkflowSessionFocusTaskProjection;
knownSessionIds: readonly string[];
};
export type WorkflowSessionFocusReconcileResult = {
state: WorkflowSessionFocusState;
sessionId: string | null;
};
export function createWorkflowSessionFocusState(): WorkflowSessionFocusState {
return { nextRequestId: 0, intent: null, request: null };
}
function clearWorkflowSessionFocusState(
state: WorkflowSessionFocusState,
): WorkflowSessionFocusState {
return { ...state, intent: null, request: null };
}
function nonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized === "" ? null : normalized;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function timestampValue(value: string | null | undefined): bigint | null {
return parseTurnTimestamp(value ?? undefined);
}
function acceptedTaskProjection(
intent: WorkflowSessionFocusIntent,
input: WorkflowSessionFocusReconcileInput,
): WorkflowSessionFocusTaskProjection {
const liveProjection: WorkflowSessionFocusTaskProjection = {
metadata: input.routeMetadata,
updatedAt: input.routeUpdatedAt,
workflowStepId: input.workflowStepId,
};
const liveRoute = readCommittedWorkflowSessionRoute(input.routeMetadata);
if (liveRoute?.entryIdentity === intent.entryIdentity) return liveProjection;
const response = input.responseProjection;
if (!response) return liveProjection;
const responseRoute = readCommittedWorkflowSessionRoute(response.metadata);
const responseEntryIdentity = nonEmptyString(response.entryIdentity);
const liveUpdatedAt = timestampValue(input.routeUpdatedAt);
const responseUpdatedAt = timestampValue(response.updatedAt);
const responseIsEntryCorrelated =
responseEntryIdentity === intent.entryIdentity &&
responseRoute?.entryIdentity === intent.entryIdentity;
const responseIsFreshEnough =
responseIsEntryCorrelated &&
(liveUpdatedAt === null || (responseUpdatedAt !== null && responseUpdatedAt >= liveUpdatedAt));
if (!responseIsFreshEnough) return liveProjection;
// When timestamps tie, retain a different live committed entry. Equal
// timestamps are common for grouped task writes, and the live entry is
// the projection that won the store's ordering guard.
const liveHasDifferentCommittedEntry =
liveRoute !== null && liveRoute.entryIdentity !== intent.entryIdentity;
return liveHasDifferentCommittedEntry && responseUpdatedAt === liveUpdatedAt
? liveProjection
: response;
}
export function readCommittedWorkflowSessionRoute(
routeMetadata: unknown,
): WorkflowSessionFocusRoute | null {
const metadata = asRecord(routeMetadata);
const route = asRecord(metadata?.workflow_session_route ?? routeMetadata);
if (!route) return null;
const operationId = nonEmptyString(route.operation_id);
const destinationStepId = nonEmptyString(route.destination_step_id);
const entryIdentity = nonEmptyString(route.entry_identity);
const destinationSessionId = nonEmptyString(route.destination_session_id);
if (
!operationId ||
!destinationStepId ||
!entryIdentity ||
!destinationSessionId ||
route.phase !== "committed"
) {
return null;
}
return {
operationId,
destinationStepId,
entryIdentity,
destinationSessionId,
phase: "committed",
};
}
export function beginWorkflowSessionFocus(
state: WorkflowSessionFocusState,
input: WorkflowSessionFocusStart,
): { state: WorkflowSessionFocusState; requestId: number | null } {
if (!input.taskId || !input.workflowId || !input.destinationStepId) {
return { state, requestId: null };
}
const requestId = state.nextRequestId + 1;
return {
requestId,
state: {
nextRequestId: requestId,
intent: { ...input, requestId, entryIdentity: null },
request: null,
},
};
}
export function bindWorkflowSessionFocus(
state: WorkflowSessionFocusState,
input: {
requestId: number;
presentationToken: number;
entryIdentity: string;
},
): WorkflowSessionFocusState {
const intent = state.intent;
if (
!intent ||
intent.requestId !== input.requestId ||
intent.presentationToken !== input.presentationToken
) {
return state;
}
const entryIdentity = nonEmptyString(input.entryIdentity);
if (!entryIdentity) return clearWorkflowSessionFocusState(state);
return { ...state, intent: { ...intent, entryIdentity } };
}
function scopeMatches(
value: WorkflowSessionFocusIntent | WorkflowSessionFocusRequest,
scope: WorkflowSessionFocusCancelScope,
): boolean {
if (scope.requestId !== undefined && value.requestId !== scope.requestId) return false;
if (
scope.presentationToken !== undefined &&
value.presentationToken !== scope.presentationToken
) {
return false;
}
if (scope.taskId !== undefined && value.taskId !== scope.taskId) return false;
return true;
}
export function cancelWorkflowSessionFocus(
state: WorkflowSessionFocusState,
scope: WorkflowSessionFocusCancelScope = {},
): WorkflowSessionFocusState {
const activeValues = [state.intent, state.request].filter(
(value): value is WorkflowSessionFocusIntent | WorkflowSessionFocusRequest => value !== null,
);
if (activeValues.length > 0 && !activeValues.some((value) => scopeMatches(value, scope))) {
return state;
}
return clearWorkflowSessionFocusState(state);
}
export function reconcileWorkflowSessionFocus(
state: WorkflowSessionFocusState,
input: WorkflowSessionFocusReconcileInput,
): WorkflowSessionFocusReconcileResult {
const intent = state.intent;
if (!intent || !intent.entryIdentity) return { state, sessionId: null };
if (
input.activeTaskId !== intent.taskId ||
input.navigationRevision !== intent.navigationRevision
) {
return { state: clearWorkflowSessionFocusState(state), sessionId: null };
}
const projection = acceptedTaskProjection(intent, input);
const route = readCommittedWorkflowSessionRoute(projection.metadata);
if (
!route ||
route.destinationStepId !== intent.destinationStepId ||
route.entryIdentity !== intent.entryIdentity ||
!input.knownSessionIds.includes(route.destinationSessionId)
) {
return { state, sessionId: null };
}
if (projection.workflowStepId !== intent.destinationStepId) {
return { state: clearWorkflowSessionFocusState(state), sessionId: null };
}
return {
state: {
...state,
intent: null,
request: {
requestId: intent.requestId,
presentationToken: intent.presentationToken,
taskId: intent.taskId,
sessionId: route.destinationSessionId,
},
},
sessionId: route.destinationSessionId,
};
}
export function acknowledgeWorkflowSessionFocus(
state: WorkflowSessionFocusState,
requestId: number,
): WorkflowSessionFocusState {
if (state.request?.requestId !== requestId) return state;
return { ...state, request: null };
}