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
@@ -10,6 +10,10 @@ import {
} from "../core/observation/liveReceiverWatchdog";
import { postLiveViewerDiagnostic } from "../core/observation/liveViewerDiagnostics";
import type { LiveViewerFailureStage } from "../core/observation/liveViewerDiagnostics";
import {
fetchPerceptionPreparationStatus,
perceptionPreparationMessage,
} from "../core/observation/perceptionPreparation";
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
@@ -1575,6 +1579,9 @@ export function RerunViewport({
}
const abort = new AbortController();
let lastReportedPercent = -1;
let requestSettled = false;
let transferStarted = false;
let statusTimer: number | null = null;
onPerceptionLoadChange?.({
phase: "loading",
receivedBytes: 0,
@@ -1582,10 +1589,51 @@ export function RerunViewport({
progress: null,
message: "Сервер готовит AI-слои.",
});
const pollPreparationStatus = () => {
void fetchPerceptionPreparationStatus(
recordedPerceptionUrl,
identity.recordingId,
{
origin: window.location.origin,
signal: abort.signal,
},
).then((status) => {
if (
abort.signal.aborted ||
requestSettled ||
transferStarted ||
perceptionChannelRef.current !== active
) {
return;
}
const message = perceptionPreparationMessage(status.phase);
if (message) {
onPerceptionLoadChange?.({
phase: "loading",
receivedBytes: 0,
totalBytes: status.byteLength,
progress: null,
message,
});
}
}).catch(() => {
// The primary RRD request remains authoritative when status polling is unavailable.
}).finally(() => {
if (!abort.signal.aborted && !requestSettled && !transferStarted) {
statusTimer = window.setTimeout(pollPreparationStatus, 750);
}
});
};
pollPreparationStatus();
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
origin: window.location.origin,
signal: abort.signal,
onProgress: (receivedBytes, totalBytes) => {
transferStarted = true;
if (statusTimer !== null) {
window.clearTimeout(statusTimer);
statusTimer = null;
}
const progress = totalBytes > 0 ? receivedBytes / totalBytes : null;
const percent = progress === null ? -1 : Math.floor(progress * 100);
if (percent === lastReportedPercent && receivedBytes !== totalBytes) return;
@@ -1640,8 +1688,18 @@ export function RerunViewport({
: "AI-слои недоступны.",
});
// The base recording remains available when no admitted perception layer exists.
}).finally(() => {
requestSettled = true;
if (statusTimer !== null) {
window.clearTimeout(statusTimer);
statusTimer = null;
}
});
return () => abort.abort();
return () => {
requestSettled = true;
if (statusTimer !== null) window.clearTimeout(statusTimer);
abort.abort();
};
}, [
onPerceptionLoadChange,
perceptionChannelRevision,
@@ -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))
);
}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchPerceptionPreparationStatus;
let perceptionPreparationMessage;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchPerceptionPreparationStatus,
perceptionPreparationMessage,
} = await server.ssrLoadModule(
"/src/core/observation/perceptionPreparation.ts",
));
});
after(async () => {
await server?.close();
});
test("perception preparation status is same-origin, typed and phase-specific", async () => {
let requested;
const status = await fetchPerceptionPreparationStatus(
"/api/v1/observation-sessions/session-1/perception.rrd",
"recording-001",
{
origin: "http://127.0.0.1:8000",
fetcher: async (input, init) => {
requested = { url: String(input), method: init.method };
return Response.json({
state: "preparing",
phase: "artifact-validation",
elapsed_seconds: 2.5,
byte_length: null,
});
},
},
);
assert.deepEqual(requested, {
url: "http://127.0.0.1:8000/api/v1/observation-sessions/session-1/perception/status?recording_id=recording-001",
method: "GET",
});
assert.deepEqual(status, {
state: "preparing",
phase: "artifact-validation",
elapsedSeconds: 2.5,
byteLength: null,
});
assert.equal(
perceptionPreparationMessage(status.phase),
"Проверяем опубликованный AI-результат.",
);
});
test("perception preparation status rejects cross-origin endpoints", async () => {
await assert.rejects(
fetchPerceptionPreparationStatus(
"https://example.com/api/v1/observation-sessions/session-1/perception.rrd",
"recording-001",
{
origin: "http://127.0.0.1:8000",
fetcher: async () => {
throw new Error("must not fetch");
},
},
),
/Unsafe perception preparation status request/,
);
});