fix(replay): serve admitted AI overlays without revalidation
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user