fix(replay): serve admitted AI overlays without revalidation

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 23:13:42 +03:00
parent 8e4891d608
commit cc6838ed24
10 changed files with 990 additions and 173 deletions
@@ -0,0 +1,125 @@
export type PerceptionPreparationPhase =
| "idle"
| "cache-lookup"
| "queued"
| "artifact-validation"
| "rendering"
| "cache-write"
| "ready"
| "unavailable"
| "error";
export interface PerceptionPreparationStatus {
state: "idle" | "preparing" | "ready" | "unavailable" | "error";
phase: PerceptionPreparationPhase;
elapsedSeconds: number;
byteLength: number | null;
}
const RECORDED_PERCEPTION_PATH =
/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
const SAFE_RECORDING_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
export async function fetchPerceptionPreparationStatus(
endpointUrl: string,
recordingId: string,
{
origin,
signal,
fetcher = globalThis.fetch,
}: {
origin: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
},
): Promise<PerceptionPreparationStatus> {
const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin);
if (
endpoint.origin !== base.origin ||
endpoint.search ||
endpoint.hash ||
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
!SAFE_RECORDING_ID.test(recordingId)
) {
throw new Error("Unsafe perception preparation status request");
}
endpoint.pathname = endpoint.pathname.replace(/\/perception\.rrd$/, "/perception/status");
endpoint.searchParams.set("recording_id", recordingId);
const response = await fetcher(endpoint.href, {
method: "GET",
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json" },
signal,
});
if (!response.ok) {
throw new Error("Perception preparation status is unavailable");
}
const value: unknown = await response.json();
if (!isPerceptionPreparationStatus(value)) {
throw new Error("Invalid perception preparation status");
}
return {
state: value.state,
phase: value.phase,
elapsedSeconds: value.elapsed_seconds,
byteLength: value.byte_length,
};
}
export function perceptionPreparationMessage(
phase: PerceptionPreparationPhase,
): string | null {
switch (phase) {
case "cache-lookup":
return "Проверяем готовый AI-слой.";
case "queued":
return "AI-слой ожидает последовательной подготовки.";
case "artifact-validation":
return "Проверяем опубликованный AI-результат.";
case "rendering":
return "Формируем AI-слой.";
case "cache-write":
return "Сохраняем AI-слой.";
case "ready":
return "AI-слой готов. Загружаем.";
default:
return null;
}
}
function isPerceptionPreparationStatus(
value: unknown,
): value is {
state: PerceptionPreparationStatus["state"];
phase: PerceptionPreparationPhase;
elapsed_seconds: number;
byte_length: number | null;
} {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
["idle", "preparing", "ready", "unavailable", "error"].includes(
String(candidate.state),
) &&
[
"idle",
"cache-lookup",
"queued",
"artifact-validation",
"rendering",
"cache-write",
"ready",
"unavailable",
"error",
].includes(String(candidate.phase)) &&
typeof candidate.elapsed_seconds === "number" &&
Number.isFinite(candidate.elapsed_seconds) &&
candidate.elapsed_seconds >= 0 &&
(candidate.byte_length === null ||
(typeof candidate.byte_length === "number" &&
Number.isSafeInteger(candidate.byte_length) &&
candidate.byte_length >= 0))
);
}