feat(observatory): ship modular AI inference labs
This commit is contained in:
@@ -28,19 +28,25 @@ export async function resolveCanonicalLabReplay(
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs";
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!(sourceKind === "portable-tgs" ? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId) : SAFE_RESULT_ID.test(resultId))
|
||||
!(sourceKind === "portable-tgs"
|
||||
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
|
||||
: sourceKind === "portable-semantic"
|
||||
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
|
||||
: sourceKind === "portable-objects"
|
||||
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
|
||||
: SAFE_RESULT_ID.test(resultId))
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = sourceKind === "portable-tgs"
|
||||
const sourceUrl = sourceKind !== "legacy-vegetation"
|
||||
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
|
||||
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
@@ -76,3 +82,53 @@ export async function resolveCanonicalLabReplay(
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAICompositionReplay(
|
||||
runId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
{
|
||||
origin = window.location.origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
const response = await fetcher(descriptorUrl.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const byteLength = Number(response.headers.get("Content-Length"));
|
||||
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|
||||
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|
||||
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|
||||
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||
) {
|
||||
throw new Error("Составной replay LAB не прошёл проверку.");
|
||||
}
|
||||
return {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||
byteLength,
|
||||
sha256,
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Small lease for the server's ephemeral blueprint, never for the recording. */
|
||||
export function keepRecordedBlueprintSession({
|
||||
endpointUrl,
|
||||
origin,
|
||||
applicationId,
|
||||
recordingId,
|
||||
ownerId,
|
||||
fetcher = fetch,
|
||||
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
|
||||
cancel = (handle: number) => window.clearInterval(handle),
|
||||
}: {
|
||||
endpointUrl: string;
|
||||
origin: string;
|
||||
applicationId: string;
|
||||
recordingId: string;
|
||||
ownerId: string;
|
||||
fetcher?: typeof fetch;
|
||||
schedule?: (callback: () => void) => number;
|
||||
cancel?: (handle: number) => void;
|
||||
}): () => void {
|
||||
const endpoint = new URL(endpointUrl, origin);
|
||||
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
|
||||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
|
||||
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
|
||||
}
|
||||
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
|
||||
const identity = { application_id: applicationId, recording_id: recordingId,
|
||||
blueprint_session_id: ownerId };
|
||||
let closed = false;
|
||||
let pending: AbortController | null = null;
|
||||
const renew = () => {
|
||||
if (closed) return;
|
||||
pending?.abort();
|
||||
pending = new AbortController();
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
|
||||
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
|
||||
};
|
||||
const timer = schedule(renew);
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cancel(timer);
|
||||
pending?.abort();
|
||||
pending = null;
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "release" }),
|
||||
keepalive: true, signal: AbortSignal.timeout(5_000),
|
||||
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
decodeObservationSessionCatalog,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
type ObservationSessionCatalog,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionScope,
|
||||
} from "./sessionArchive";
|
||||
|
||||
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
|
||||
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
|
||||
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
|
||||
readonly nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
|
||||
export async function fetchObservationSessionCatalogPage({
|
||||
scope,
|
||||
limit = 100,
|
||||
cursor = null,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
scope: ObservationSessionScope;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
}): Promise<ObservationSessionCatalogPage> {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|
||||
|| (cursor !== null && !CURSOR.test(cursor))) {
|
||||
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const query = new URLSearchParams({ limit: String(limit), scope });
|
||||
if (scope === "laboratory") query.set("lab_contract", "v3");
|
||||
query.set("pagination", "cursor-v1");
|
||||
if (cursor !== null) query.set("cursor", cursor);
|
||||
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ObservationSessionApiError(
|
||||
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
|
||||
}
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
|
||||
}
|
||||
const page = body as Record<string, unknown>;
|
||||
if (Object.keys(page).length !== 3
|
||||
|| page.schema_version !== PAGE_SCHEMA
|
||||
|| !Object.hasOwn(page, "items")
|
||||
|| !(page.next_cursor === null
|
||||
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
|
||||
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
|
||||
}
|
||||
const catalog = decodeObservationSessionCatalog({ items: page.items });
|
||||
if (catalog.items.length > limit) {
|
||||
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { ...catalog, nextCursor: page.next_cursor };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface RerunPlaybackController {
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
cameraImage?: boolean;
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
@@ -76,6 +77,8 @@ export interface RecordedSessionRerunProfile {
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||
unifiedPerception?: boolean;
|
||||
/** Controlled camera-column share for the native horizontal Rerun container. */
|
||||
unifiedCameraShare?: number;
|
||||
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||
planView?: boolean;
|
||||
perceptionRetryGeneration: number;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
decodeObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} from "./recordedJobs";
|
||||
|
||||
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
|
||||
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
|
||||
|
||||
export interface AIModule {
|
||||
readonly moduleId: string;
|
||||
readonly moduleSha256: string;
|
||||
readonly label: string;
|
||||
readonly dockerName: string;
|
||||
readonly requires: readonly string[];
|
||||
readonly provides: readonly string[];
|
||||
readonly defaults: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface AIModuleGroup {
|
||||
readonly group: AIGroupId;
|
||||
readonly modules: readonly AIModule[];
|
||||
}
|
||||
|
||||
export interface AIModuleCatalog {
|
||||
readonly groups: readonly AIModuleGroup[];
|
||||
}
|
||||
|
||||
export interface AICompositionReceipt {
|
||||
readonly compositionSha256: string;
|
||||
readonly created: boolean;
|
||||
readonly dispatchReady: boolean;
|
||||
readonly dispatchReason: string;
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly run: AICompositionRun;
|
||||
}
|
||||
|
||||
export interface AIViewerLayer {
|
||||
readonly layerId: string;
|
||||
readonly paneId: "camera" | "spatial";
|
||||
readonly label: string;
|
||||
readonly control: "toggle" | "toggle-with-settings";
|
||||
readonly order: number;
|
||||
}
|
||||
|
||||
export interface AICompositionPresentation {
|
||||
readonly configurationLabel: string;
|
||||
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
|
||||
readonly viewerLayers: readonly AIViewerLayer[];
|
||||
}
|
||||
|
||||
export interface AICompositionRun {
|
||||
readonly runId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly compositionSha256: string;
|
||||
readonly moduleIds: readonly string[];
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobIds: readonly string[];
|
||||
readonly resultIds: readonly string[];
|
||||
readonly createdAtUtc: string;
|
||||
readonly state: "running" | "ready" | "failed";
|
||||
readonly configurationLabel: string;
|
||||
readonly displayName: string | null;
|
||||
readonly presentation: AICompositionPresentation;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
}
|
||||
|
||||
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
ddrnet: "ai-segmentation-ddrnet-v1",
|
||||
eomt: "ai-segmentation-eomt-v1",
|
||||
tgs: "m49-tgs-portable-v2",
|
||||
"rf-detr": "ai-detection-rf-detr-v1",
|
||||
"object-distance": "ai-range-object-distance-v1",
|
||||
});
|
||||
|
||||
export function aiModuleIdForSetup(setupId: string): string | null {
|
||||
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
|
||||
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "каталог AI-модулей");
|
||||
exactKeys(root, ["authority", "groups", "schema_version"]);
|
||||
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
|
||||
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
|
||||
const groups = array(root.groups).map((value): AIModuleGroup => {
|
||||
const group = record(value, "группа AI-модулей");
|
||||
exactKeys(group, ["group", "modules"]);
|
||||
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
|
||||
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
|
||||
}
|
||||
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
|
||||
});
|
||||
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
|
||||
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
|
||||
}
|
||||
return { groups };
|
||||
}
|
||||
|
||||
export async function saveAIComposition(
|
||||
sourceSessionId: string,
|
||||
selections: readonly { group: AIGroupId; module: AIModule }[],
|
||||
): Promise<AICompositionReceipt> {
|
||||
const response = await fetch("/api/v1/observatory/ai-compositions", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: COMPOSITION_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
selections: selections.map(({ group, module }) => ({
|
||||
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
|
||||
parameters: module.defaults,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "конфигурация AI-слоя");
|
||||
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
|
||||
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
|
||||
throw new Error("Сервер вернул конфигурацию для другой записи.");
|
||||
}
|
||||
const digest = text(root.composition_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
|
||||
const dispatch = record(root.dispatch, "готовность композиции");
|
||||
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
|
||||
if (dispatch.ready !== true) {
|
||||
throw new Error(text(dispatch.reason));
|
||||
}
|
||||
const setupIds = array(dispatch.setup_ids).map(text);
|
||||
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
|
||||
if (!jobs.length || jobs.length !== setupIds.length
|
||||
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
|
||||
throw new Error("Сервер вернул неполную очередь композиции.");
|
||||
}
|
||||
const run = compositionRunOf(root.run);
|
||||
return {
|
||||
compositionSha256: digest,
|
||||
created: boolean(dispatch && root.created),
|
||||
dispatchReady: boolean(dispatch.ready),
|
||||
dispatchReason: text(dispatch.reason),
|
||||
setupIds,
|
||||
jobs,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAICompositionRuns(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly AICompositionRun[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "запуски композиций");
|
||||
exactKeys(root, ["items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
|
||||
throw new Error("Версия запусков AI-композиций изменилась.");
|
||||
}
|
||||
return array(root.items).map(compositionRunOf);
|
||||
}
|
||||
|
||||
export async function renameAICompositionRunProjection(
|
||||
runId: string,
|
||||
displayName: string,
|
||||
): Promise<string> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI inference.");
|
||||
}
|
||||
const normalized = displayName.trim();
|
||||
if (!normalized || normalized.length > 160) {
|
||||
throw new Error("Название результата должно содержать от 1 до 160 символов.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
|
||||
display_name: normalized,
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const row = record(body, "результат AI inference");
|
||||
exactKeys(row, ["display_name", "run_id", "schema_version"]);
|
||||
if (
|
||||
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|
||||
|| row.run_id !== runId
|
||||
|| row.display_name !== normalized
|
||||
) throw new Error("Сервер не подтвердил переименование результата AI inference.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI inference.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(await bodyOf(response), response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function compositionRunOf(value: unknown): AICompositionRun {
|
||||
const row = record(value, "запуск AI-композиции");
|
||||
const expected = [
|
||||
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
|
||||
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
|
||||
"source_session_id", "state",
|
||||
];
|
||||
// The receipt embeds the immutable run before its live state projection.
|
||||
const receiptShape = [
|
||||
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
|
||||
"run_id", "schema_version", "setup_ids", "source_session_id",
|
||||
];
|
||||
const keys = Object.keys(row);
|
||||
const projected = keys.length === expected.length;
|
||||
exactKeys(row, projected ? expected : receiptShape);
|
||||
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
|
||||
throw new Error("Версия запуска AI-композиции изменилась.");
|
||||
}
|
||||
const sourceSessionId = text(row.source_session_id);
|
||||
const compositionSha256 = text(row.composition_sha256);
|
||||
const runId = text(row.run_id);
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
|
||||
throw new Error("Некорректная идентичность запуска AI-композиции.");
|
||||
}
|
||||
const presentation = presentationOf(row.presentation);
|
||||
const state = projected ? text(row.state) : "running";
|
||||
if (!new Set(["running", "ready", "failed"]).has(state)) {
|
||||
throw new Error("Неизвестное состояние запуска AI-композиции.");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
sourceSessionId,
|
||||
compositionSha256,
|
||||
moduleIds: array(row.module_ids).map(text),
|
||||
setupIds: array(row.setup_ids).map(text),
|
||||
jobIds: array(row.job_ids).map(text),
|
||||
resultIds: projected ? array(row.result_ids).map(text) : [],
|
||||
createdAtUtc: text(row.created_at_utc),
|
||||
state: state as AICompositionRun["state"],
|
||||
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
|
||||
displayName: projected
|
||||
? row.display_name === null ? null : text(row.display_name)
|
||||
: null,
|
||||
presentation,
|
||||
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function presentationOf(value: unknown): AICompositionPresentation {
|
||||
const row = record(value, "проекция AI-композиции");
|
||||
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
|
||||
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
|
||||
throw new Error("Версия проекции AI-композиции изменилась.");
|
||||
}
|
||||
const modules = array(row.modules).map((value) => {
|
||||
const module = record(value, "модуль проекции");
|
||||
exactKeys(module, ["label", "module_id"]);
|
||||
return { moduleId: text(module.module_id), label: text(module.label) };
|
||||
});
|
||||
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
|
||||
const layer = record(value, "слой viewer");
|
||||
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
|
||||
const paneId = text(layer.pane_id);
|
||||
const control = text(layer.control);
|
||||
if (!new Set(["camera", "spatial"]).has(paneId)
|
||||
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|
||||
|| typeof layer.order !== "number") {
|
||||
throw new Error("Некорректная проекция слоя viewer.");
|
||||
}
|
||||
return {
|
||||
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
|
||||
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
|
||||
};
|
||||
});
|
||||
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
|
||||
}
|
||||
|
||||
export async function fetchAICompositionJobs(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "очередь AI-слоёв");
|
||||
exactKeys(root, ["authority", "items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
|
||||
throw new Error("Версия очереди AI-слоёв изменилась.");
|
||||
}
|
||||
return array(root.items).map(decodeObservatoryRecordedJob);
|
||||
}
|
||||
|
||||
function moduleOf(value: unknown): AIModule {
|
||||
const row = record(value, "AI-модуль");
|
||||
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
|
||||
const digest = text(row.module_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
|
||||
return {
|
||||
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
|
||||
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
|
||||
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
|
||||
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
|
||||
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
|
||||
}
|
||||
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
|
||||
function apiError(body: unknown, status: number): Error {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
|
||||
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `ai-layer:${entropy}`;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
fetchObservationSessionCatalog,
|
||||
type ObservationLabInstance,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../observation/sessionArchive";
|
||||
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
|
||||
import {
|
||||
observatoryRecordedRunBinding,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
|
||||
|
||||
function evidenceFromSession(
|
||||
session: ObservationSessionSummary,
|
||||
): ObservatoryEvidence {
|
||||
): ObservatoryEvidence | null {
|
||||
if (!session.lab) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||
);
|
||||
}
|
||||
// Historical LABs retain their archive and replay adapters. Observatory is
|
||||
// the portable-profile product; a familiar LAB label is not admission.
|
||||
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
|
||||
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
|
||||
return {
|
||||
sessionId: session.id,
|
||||
label: session.label,
|
||||
status: session.status,
|
||||
publishedAtUtc: session.lab.publishedAtUtc,
|
||||
lab: session.lab,
|
||||
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
|
||||
recordedRun,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
|
||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||
for (const laboratorySession of laboratorySessions) {
|
||||
const evidence = evidenceFromSession(laboratorySession);
|
||||
if (!evidence) continue;
|
||||
const sourceId = evidence.lab.sourceSessionId;
|
||||
if (!sources.has(sourceId)) {
|
||||
// Both API projections are bounded newest-first windows without a
|
||||
// cursor. Absence from the source window is not proof of a broken
|
||||
// relationship and must not be presented as an integrity fault.
|
||||
// A bounded traversal may stop before this source. Absence alone is
|
||||
// not proof of a broken relationship or permission to remove data.
|
||||
unresolvedEvidence.push(evidence);
|
||||
continue;
|
||||
}
|
||||
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
|
||||
window: {
|
||||
limit: safeLimit,
|
||||
sourceCount: sourceSessions.length,
|
||||
laboratoryCount: laboratorySessions.length,
|
||||
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
|
||||
+ unresolvedEvidence.length,
|
||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||
},
|
||||
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {}): Promise<ObservatoryCatalog> {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
|
||||
]);
|
||||
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
|
||||
const request = new AbortController();
|
||||
const abort = () => request.abort(signal?.reason);
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
const [sources, laboratories] = await Promise.all([
|
||||
readCatalogPages("source", safeLimit, fetcher, request.signal),
|
||||
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
|
||||
]);
|
||||
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
|
||||
return {
|
||||
...catalog,
|
||||
window: {
|
||||
...catalog.window,
|
||||
sourceLimitReached: sources.hasMore,
|
||||
laboratoryLimitReached: laboratories.hasMore,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
request.abort();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata only, never recordings or result artifacts. The cap is an explicit
|
||||
// partial window, not silent completeness or an unbounded background crawler.
|
||||
const MAX_CATALOG_PAGES = 256;
|
||||
|
||||
async function readCatalogPages(
|
||||
scope: "source" | "laboratory",
|
||||
limit: number,
|
||||
fetcher: ObservationSessionFetch,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
|
||||
const items: ObservationSessionSummary[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
|
||||
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
|
||||
for (const item of page.items) {
|
||||
if (seenIds.has(item.id)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
// Do not accumulate legacy provenance while walking the shared catalog.
|
||||
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
|
||||
items.push(item);
|
||||
} else if (!item.lab) {
|
||||
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
|
||||
}
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (cursor === null) return { items, hasMore: false };
|
||||
if (cursors.has(cursor)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
|
||||
}
|
||||
cursors.add(cursor);
|
||||
}
|
||||
return { items, hasMore: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
|
||||
|
||||
export async function fetchLabViewProfile(
|
||||
resultId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings | null> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
export async function saveLabViewProfile(
|
||||
resultId: string,
|
||||
settings: SceneSettings,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
method: "PUT",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PROFILE_SCHEMA,
|
||||
result_id: resultId,
|
||||
scene_settings: {
|
||||
point_size: settings.pointSize,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
show_grid: settings.showGrid,
|
||||
show_labels: settings.showLabels,
|
||||
show_camera_frustums: settings.showCameraFrustums,
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
|
||||
const row = record(value);
|
||||
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
|
||||
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
|
||||
throw new Error("Сервер вернул профиль другой LAB.");
|
||||
}
|
||||
const settings = record(row.scene_settings);
|
||||
exactKeys(settings, [
|
||||
"accumulation_seconds", "color_mode", "palette", "point_size",
|
||||
"show_camera_frustums", "show_grid", "show_labels",
|
||||
]);
|
||||
const pointSize = finite(settings.point_size);
|
||||
const accumulationSeconds = finite(settings.accumulation_seconds);
|
||||
const colorMode = settings.color_mode;
|
||||
const palette = settings.palette;
|
||||
if (
|
||||
pointSize < 0.1
|
||||
|| accumulationSeconds < 0
|
||||
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|
||||
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|
||||
|| typeof settings.show_grid !== "boolean"
|
||||
|| typeof settings.show_labels !== "boolean"
|
||||
|| typeof settings.show_camera_frustums !== "boolean"
|
||||
) throw new Error("Сервер вернул повреждённый профиль LAB.");
|
||||
return {
|
||||
projection: "3d",
|
||||
pointSize,
|
||||
colorMode: colorMode as SceneSettings["colorMode"],
|
||||
palette: palette as SceneSettings["palette"],
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: settings.show_grid,
|
||||
showLabels: settings.show_labels,
|
||||
showCameraFrustums: settings.show_camera_frustums,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Некорректный профиль отображения LAB.");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("Некорректное числовое значение профиля LAB.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
|
||||
throw new Error("Контракт профиля отображения LAB изменился.");
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
|
||||
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
||||
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
||||
observationAuthority(row.authority);
|
||||
return array(row.items, "items").map(decodeJob).filter((job) => (
|
||||
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
|
||||
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||
));
|
||||
@@ -125,7 +125,7 @@ export async function submitObservatoryRecordedJob(
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
@@ -156,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.jobId !== jobId) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Повтор публикации вернул другой расчёт.",
|
||||
@@ -165,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
|
||||
return job;
|
||||
}
|
||||
|
||||
function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
|
||||
const row = record(value, "расчёт");
|
||||
exactKeys(row, [
|
||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface RecordedProgressView {
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly ageSeconds: number;
|
||||
readonly elapsedSeconds: number;
|
||||
readonly phaseElapsedSeconds: number;
|
||||
}
|
||||
|
||||
export async function fetchRecordedProgress(
|
||||
@@ -71,7 +73,8 @@ export function decodeRecordedProgress(
|
||||
const completed = integer(progress.completed);
|
||||
const total = progress.total === null ? null : integer(progress.total, 1);
|
||||
const elapsed = finite(progress.elapsed_seconds);
|
||||
if ((total !== null && completed > total) || finite(progress.phase_elapsed_seconds) > elapsed) {
|
||||
const phaseElapsed = finite(progress.phase_elapsed_seconds);
|
||||
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
|
||||
throw new Error("Некорректные счётчики прогресса.");
|
||||
}
|
||||
integer(progress.phase_index);
|
||||
@@ -79,6 +82,7 @@ export function decodeRecordedProgress(
|
||||
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
||||
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
||||
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
||||
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +91,8 @@ export function recordedProgressLabel(
|
||||
): string {
|
||||
if (job?.publication.state === "pending") return "Сохраняем результат";
|
||||
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
|
||||
if (job.state === "failed") return "Ошибка расчёта";
|
||||
if (job.state === "succeeded") return "Расчёт завершён";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
|
||||
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
||||
if (!progress || progress.jobId !== job.jobId
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
AI_MODULE_SETUP_IDS,
|
||||
fetchAIModuleCatalog,
|
||||
fetchAICompositionJobs,
|
||||
fetchAICompositionRuns,
|
||||
type AICompositionRun,
|
||||
} from "./aiComposition";
|
||||
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
|
||||
import type { ObservatoryRecordedJob } from "./recordedJobs";
|
||||
|
||||
const OPEN = new Set(["accepted", "queued", "claimed", "running", "paused",
|
||||
"preemption-pending", "reconciliation-required"]);
|
||||
|
||||
interface Snapshot {
|
||||
readonly sourceSessionId: string;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly runs: readonly AICompositionRun[];
|
||||
readonly moduleLabelsBySetup: Readonly<Record<string, string>>;
|
||||
readonly progress: Readonly<Record<string, RecordedProgressView>>;
|
||||
readonly error: string | null;
|
||||
}
|
||||
|
||||
export function useAICompositionJobs(sourceSessionId: string) {
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<Snapshot>({
|
||||
sourceSessionId: "", jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
|
||||
});
|
||||
const current = snapshot.sourceSessionId === sourceSessionId ? snapshot : {
|
||||
sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceSessionId) {
|
||||
setSnapshot({ sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null });
|
||||
return;
|
||||
}
|
||||
const request = new AbortController();
|
||||
void Promise.all([
|
||||
fetchAICompositionJobs(sourceSessionId, request.signal),
|
||||
fetchAICompositionRuns(sourceSessionId, request.signal),
|
||||
fetchAIModuleCatalog(request.signal),
|
||||
]).then(async ([jobs, runs, catalog]) => {
|
||||
const active = jobs.filter((job) => OPEN.has(job.state));
|
||||
const samples = await Promise.all(active.map(async (job) => [
|
||||
job.jobId, await fetchRecordedProgress(job, { signal: request.signal }).catch(() => null),
|
||||
] as const));
|
||||
if (request.signal.aborted) return;
|
||||
const catalogLabels = catalog.groups.flatMap(({ modules }) => (
|
||||
modules.flatMap((module) => {
|
||||
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
|
||||
return setupId ? [[setupId, module.label] as const] : [];
|
||||
})
|
||||
));
|
||||
const projectedLabels = runs.flatMap((run) => run.presentation.modules.flatMap((module) => {
|
||||
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
|
||||
return setupId ? [[setupId, module.label] as const] : [];
|
||||
}));
|
||||
setSnapshot({
|
||||
sourceSessionId, jobs, runs,
|
||||
moduleLabelsBySetup: Object.fromEntries([...catalogLabels, ...projectedLabels]),
|
||||
progress: Object.fromEntries(samples.filter(
|
||||
(sample): sample is readonly [string, RecordedProgressView] => sample[1] !== null,
|
||||
)),
|
||||
error: null,
|
||||
});
|
||||
}).catch((error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
setSnapshot((value) => ({
|
||||
sourceSessionId, jobs: value.sourceSessionId === sourceSessionId ? value.jobs : [],
|
||||
runs: value.sourceSessionId === sourceSessionId ? value.runs : [],
|
||||
moduleLabelsBySetup: value.sourceSessionId === sourceSessionId
|
||||
? value.moduleLabelsBySetup : {},
|
||||
progress: value.sourceSessionId === sourceSessionId ? value.progress : {},
|
||||
error: error instanceof Error ? error.message : "Очередь AI-слоёв недоступна.",
|
||||
}));
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
const active = current.runs.some((run) => run.state === "running")
|
||||
|| current.jobs.some((job) => OPEN.has(job.state) || job.publication.state === "pending");
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = globalThis.setTimeout(() => setRevision((value) => value + 1), 1_500);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [active, revision]);
|
||||
|
||||
return {
|
||||
jobs: current.jobs,
|
||||
runs: current.runs,
|
||||
moduleLabelsBySetup: current.moduleLabelsBySetup,
|
||||
progress: current.progress,
|
||||
error: current.error,
|
||||
refresh: useCallback(() => setRevision((value) => value + 1), []),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user