feat(observatory): ship modular AI inference labs

This commit is contained in:
DCCONSTRUCTIONS
2026-09-04 17:59:05 +03:00
parent eff60e490a
commit cada687173
145 changed files with 17651 additions and 1667 deletions
@@ -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), []),
};
}