feat(observatory): add portable LAB V1 foundation
This commit is contained in:
@@ -1,16 +1,34 @@
|
||||
import { preflightObservatoryPortableLaboratorySetup } from "./portableLaboratorySetups";
|
||||
|
||||
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
|
||||
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
|
||||
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
|
||||
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ObservatoryLaboratorySetupOrigin = "archived-definition" | "existing-result";
|
||||
export type ObservatoryLaboratorySetupAction = "open-existing" | "open-legacy" | "blocked";
|
||||
export type ObservatoryLaboratorySetupOrigin =
|
||||
| "archived-definition"
|
||||
| "existing-result"
|
||||
| "portable-definition";
|
||||
export type ObservatoryLaboratorySetupAction =
|
||||
| "open-existing"
|
||||
| "open-legacy"
|
||||
| "blocked";
|
||||
|
||||
export interface ObservatoryLaboratoryRunDefinition {
|
||||
readonly definitionId: string;
|
||||
readonly version: number;
|
||||
readonly workId: string;
|
||||
readonly workId: string | null;
|
||||
readonly definitionSha256: string;
|
||||
readonly resultSchema: string | null;
|
||||
readonly resultKind: string | null;
|
||||
readonly models: readonly {
|
||||
readonly name: string;
|
||||
readonly releaseId: string;
|
||||
readonly modelId: string;
|
||||
readonly architecture: string;
|
||||
}[];
|
||||
readonly configuration: readonly {
|
||||
readonly role: string;
|
||||
readonly sha256: string;
|
||||
@@ -38,7 +56,7 @@ export interface ObservatoryLaboratorySetup {
|
||||
};
|
||||
readonly executor: {
|
||||
readonly contourId: string;
|
||||
readonly state: "not-installed";
|
||||
readonly state: "not-installed" | "ready";
|
||||
readonly reasonCode: string;
|
||||
readonly reason: string;
|
||||
};
|
||||
@@ -47,7 +65,7 @@ export interface ObservatoryLaboratorySetup {
|
||||
readonly outcome: "existing" | "blocked";
|
||||
readonly action: ObservatoryLaboratorySetupAction;
|
||||
readonly reason: string;
|
||||
readonly submissionAllowed: false;
|
||||
readonly submissionAllowed: boolean;
|
||||
readonly existingResultIds: readonly string[];
|
||||
};
|
||||
}
|
||||
@@ -124,6 +142,9 @@ export async function preflightObservatoryLaboratorySetup(
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratoryRunPreflight> {
|
||||
if (setup.origin === "portable-definition") {
|
||||
return preflightObservatoryPortableLaboratorySetup(sourceSessionId, setup);
|
||||
}
|
||||
const response = await request(
|
||||
fetcher,
|
||||
"/api/v1/observatory/run-preflights",
|
||||
@@ -245,6 +266,9 @@ function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition
|
||||
version: positiveInteger(row.version, "definition version"),
|
||||
workId: text(row.work_id, "work_id"),
|
||||
definitionSha256: digest,
|
||||
resultSchema: null,
|
||||
resultKind: null,
|
||||
models: [],
|
||||
configuration: array(row.configuration, "configuration").map((item) => {
|
||||
const reference = record(item, "configuration reference");
|
||||
exactKeys(reference, ["role", "sha256"], "configuration reference");
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import type {
|
||||
ObservatoryLaboratoryRunDefinition,
|
||||
ObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
const PORTABLE_CATALOG_SCHEMA = "missioncore.observatory-portable-setup-catalog/v2";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export class ObservatoryPortableSetupDecodeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryPortableSetupDecodeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePortableCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "portable-каталог сетапов");
|
||||
exactKeys(
|
||||
row,
|
||||
["authority", "schema_version", "setups", "source_session_id"],
|
||||
"portable-каталог сетапов",
|
||||
);
|
||||
exact(row.schema_version, PORTABLE_CATALOG_SCHEMA, "schema_version portable-каталога");
|
||||
observationAuthority(row.authority);
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "portable source_session_id"),
|
||||
setups: array(row.setups, "portable setups").map(decodePortableSetup),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "portable-сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "description", "display_name", "executor", "existing_results",
|
||||
"origin", "preflight", "run_definition", "setup_id", "source_compatibility",
|
||||
"source_requirements",
|
||||
], "portable-сетап");
|
||||
exact(row.origin, "portable-definition", "portable origin");
|
||||
observationAuthority(row.authority);
|
||||
decodePortableSourceRequirements(row.source_requirements);
|
||||
|
||||
const compatibility = record(row.source_compatibility, "portable source_compatibility");
|
||||
const compatibilityKeys = compatibility.evidence === undefined
|
||||
? ["compatible", "outcome", "reason"]
|
||||
: ["compatible", "evidence", "outcome", "reason"];
|
||||
exactKeys(compatibility, compatibilityKeys, "portable source_compatibility");
|
||||
const compatible = boolean(compatibility.compatible, "portable compatible");
|
||||
exact(
|
||||
compatibility.outcome,
|
||||
compatible ? "pass" : "blocked",
|
||||
"portable compatibility outcome",
|
||||
);
|
||||
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
|
||||
if (compatibility.evidence !== undefined) {
|
||||
const evidence = record(compatibility.evidence, "portable compatibility evidence");
|
||||
exactKeys(evidence, [
|
||||
"frame_count", "timeline_end_seconds", "timeline_start_seconds",
|
||||
], "portable compatibility evidence");
|
||||
positiveInteger(evidence.frame_count, "portable frame_count");
|
||||
finiteNumber(evidence.timeline_start_seconds, "portable timeline_start_seconds");
|
||||
finiteNumber(evidence.timeline_end_seconds, "portable timeline_end_seconds");
|
||||
}
|
||||
|
||||
const executor = record(row.executor, "portable executor");
|
||||
exactKeys(executor, ["contour_id", "ready", "reason", "state"], "portable executor");
|
||||
const executorState = oneOf(
|
||||
executor.state,
|
||||
["not-installed", "ready"] as const,
|
||||
"portable executor state",
|
||||
);
|
||||
const executorReady = boolean(executor.ready, "portable executor ready");
|
||||
if (executorReady !== (executorState === "ready")) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable executor: состояние готовности противоречиво.",
|
||||
);
|
||||
}
|
||||
const executorReason = executor.reason === null
|
||||
? "Исполнитель установлен."
|
||||
: text(executor.reason, "portable executor reason");
|
||||
|
||||
const preflight = record(row.preflight, "portable preflight");
|
||||
exactKeys(preflight, [
|
||||
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
|
||||
], "portable preflight");
|
||||
exact(preflight.outcome, "blocked", "portable preflight outcome");
|
||||
exact(preflight.action, "blocked", "portable preflight action");
|
||||
const submissionAllowed = boolean(
|
||||
preflight.submission_allowed,
|
||||
"portable preflight submission_allowed",
|
||||
);
|
||||
if (submissionAllowed) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable preflight: постановка в очередь ещё не поддерживается.",
|
||||
);
|
||||
}
|
||||
const existingResults = array(row.existing_results, "portable existing_results");
|
||||
const existingResultIds = array(
|
||||
preflight.existing_result_ids,
|
||||
"portable existing_result_ids",
|
||||
);
|
||||
if (existingResults.length > 0 || existingResultIds.length > 0) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable result: проверяемая привязка результата к RunDefinition ещё не поддерживается.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
setupId: text(row.setup_id, "portable setup_id"),
|
||||
displayName: text(row.display_name, "portable display_name"),
|
||||
description: text(row.description, "portable description"),
|
||||
origin: "portable-definition",
|
||||
runDefinition: decodePortableRunDefinition(row.run_definition),
|
||||
compatibility: {
|
||||
compatible,
|
||||
reasons: compatible
|
||||
? []
|
||||
: [{ code: "source-capability-blocked", message: compatibilityReason }],
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "portable executor contour_id"),
|
||||
state: executorState,
|
||||
reasonCode: executorReady
|
||||
? "portable-executor-ready"
|
||||
: "portable-executor-not-installed",
|
||||
reason: executorReason,
|
||||
},
|
||||
preservedResults: [],
|
||||
preflight: {
|
||||
outcome: "blocked",
|
||||
action: "blocked",
|
||||
reason: text(preflight.reason, "portable preflight reason"),
|
||||
submissionAllowed,
|
||||
existingResultIds: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableSourceRequirements(value: unknown): void {
|
||||
const row = record(value, "portable source_requirements");
|
||||
exactKeys(row, [
|
||||
"archive_id", "calibration_identity_sha256", "calibration_slot", "camera_height",
|
||||
"camera_semantic_channel_id", "camera_source_id", "camera_width",
|
||||
"exactly_one_media_epoch", "plugin_id", "recorded_media_init_sha256",
|
||||
"recorded_media_type", "required_modalities", "seekable",
|
||||
], "portable source_requirements");
|
||||
for (const key of [
|
||||
"archive_id", "calibration_slot", "camera_semantic_channel_id", "camera_source_id",
|
||||
"plugin_id", "recorded_media_type",
|
||||
]) text(row[key], `portable source_requirements.${key}`);
|
||||
for (const key of ["calibration_identity_sha256", "recorded_media_init_sha256"]) {
|
||||
const digest = text(row[key], `portable source_requirements.${key}`);
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`portable source_requirements.${key}: некорректный digest.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
positiveInteger(row.camera_width, "portable camera_width");
|
||||
positiveInteger(row.camera_height, "portable camera_height");
|
||||
exact(row.exactly_one_media_epoch, true, "portable exactly_one_media_epoch");
|
||||
exact(row.seekable, true, "portable seekable");
|
||||
array(row.required_modalities, "portable required_modalities")
|
||||
.forEach((item) => text(item, "portable required modality"));
|
||||
}
|
||||
|
||||
function decodePortableRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "portable RunDefinition");
|
||||
exactKeys(row, [
|
||||
"definition_id", "definition_sha256", "models", "result_kind", "result_schema",
|
||||
"version",
|
||||
], "portable RunDefinition");
|
||||
const digest = text(row.definition_sha256, "portable definition_sha256");
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable RunDefinition: некорректный digest.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
definitionId: text(row.definition_id, "portable definition_id"),
|
||||
version: positiveInteger(row.version, "portable definition version"),
|
||||
workId: null,
|
||||
definitionSha256: digest,
|
||||
resultSchema: text(row.result_schema, "portable result_schema"),
|
||||
resultKind: text(row.result_kind, "portable result_kind"),
|
||||
models: array(row.models, "portable models").map((item) => {
|
||||
const model = record(item, "portable model");
|
||||
exactKeys(
|
||||
model,
|
||||
["architecture", "model_id", "name", "release_id"],
|
||||
"portable model",
|
||||
);
|
||||
return {
|
||||
name: text(model.name, "portable model name"),
|
||||
releaseId: text(model.release_id, "portable model release_id"),
|
||||
modelId: text(model.model_id, "portable model model_id"),
|
||||
architecture: text(model.architecture, "portable model architecture"),
|
||||
};
|
||||
}),
|
||||
configuration: [],
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = [
|
||||
"commands_enabled",
|
||||
"actuation_allowed",
|
||||
"navigation_or_safety_accepted",
|
||||
"production_accepted",
|
||||
];
|
||||
exactKeys(row, keys, "authority");
|
||||
for (const key of keys) exact(row[key], false, `authority.${key}`);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался объект.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался текст.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался boolean.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидалось положительное целое.`,
|
||||
);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидалось конечное число.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: значение изменилось.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(
|
||||
value: unknown,
|
||||
allowed: T,
|
||||
label: string,
|
||||
): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: значение не поддерживается.`,
|
||||
);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (
|
||||
actual.length !== sortedExpected.length
|
||||
|| actual.some((key, index) => key !== sortedExpected[index])
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: обнаружены неизвестные поля.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
|
||||
import type {
|
||||
ObservatoryLaboratoryRunPreflight,
|
||||
ObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
export type ObservatoryPortableLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class ObservatoryPortableLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryPortableLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryPortableLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryPortableLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/portable-laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const catalog = decodePortableCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export function preflightObservatoryPortableLaboratorySetup(
|
||||
sourceSessionId: string,
|
||||
setup: ObservatoryLaboratorySetup,
|
||||
): ObservatoryLaboratoryRunPreflight {
|
||||
const definitionSha256 = setup.runDefinition?.definitionSha256 ?? null;
|
||||
if (setup.origin !== "portable-definition" || definitionSha256 === null) {
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-сетап не содержит RunDefinition.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
setup.preflight.outcome !== "blocked"
|
||||
|| setup.preflight.action !== "blocked"
|
||||
|| setup.preflight.existingResultIds.length > 0
|
||||
|| setup.preservedResults.length > 0
|
||||
) {
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-result ещё не имеет проверяемой привязки к RunDefinition.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
sourceSessionId,
|
||||
setupId: setup.setupId,
|
||||
definitionSha256,
|
||||
outcome: "blocked",
|
||||
submissionAllowed: false,
|
||||
checks: [
|
||||
{
|
||||
checkId: "source-compatibility",
|
||||
outcome: setup.compatibility.compatible ? "pass" : "fail",
|
||||
reasonCode: setup.compatibility.compatible
|
||||
? "source-capability-admitted"
|
||||
: "source-capability-blocked",
|
||||
message: setup.compatibility.compatible
|
||||
? "Запись соответствует portable-профилю LAB V1."
|
||||
: setup.compatibility.reasons[0]?.message
|
||||
?? "Запись не соответствует portable-профилю LAB V1.",
|
||||
},
|
||||
{
|
||||
checkId: "executor",
|
||||
outcome: setup.executor.state === "ready" ? "pass" : "fail",
|
||||
reasonCode: setup.executor.reasonCode,
|
||||
message: setup.executor.reason,
|
||||
},
|
||||
{
|
||||
checkId: "durable-queue",
|
||||
outcome: "fail",
|
||||
reasonCode: "portable-dispatch-unavailable",
|
||||
message: setup.preflight.reason,
|
||||
},
|
||||
],
|
||||
existingResultIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryPortableLaboratorySetupFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-каталог сетапов недоступен.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const textBody = await response.text();
|
||||
if (!textBody) return undefined;
|
||||
try {
|
||||
return JSON.parse(textBody) as unknown;
|
||||
} catch {
|
||||
return textBody;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(
|
||||
body: unknown,
|
||||
status: number,
|
||||
): ObservatoryPortableLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryPortableLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim()
|
||||
? detail
|
||||
: `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryLaboratorySetups,
|
||||
fetchObservatoryPortableLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
type ObservatoryLaboratoryRunPreflight,
|
||||
type ObservatoryLaboratorySetupCatalog,
|
||||
@@ -41,22 +42,35 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
setPreflight({ kind: "idle" });
|
||||
const portableResult = fetchObservatoryPortableLaboratorySetups(
|
||||
sourceSessionId,
|
||||
{ signal: request.signal },
|
||||
).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason: unknown) => ({ status: "rejected" as const, reason }),
|
||||
);
|
||||
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||
.then((next) => {
|
||||
.then(async (legacyCatalog) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setCatalog(next);
|
||||
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
|
||||
setState("ready");
|
||||
setSelectedSetupId((current) => {
|
||||
if (next.setups.some(
|
||||
(setup) => setup.setupId === current && setup.compatibility.compatible,
|
||||
)) return current;
|
||||
return next.setups.find(
|
||||
(setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing",
|
||||
)?.setupId
|
||||
?? next.setups.find((setup) => setup.compatibility.compatible)?.setupId
|
||||
?? next.setups[0]?.setupId
|
||||
?? "";
|
||||
});
|
||||
const optionalPortable = await portableResult;
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
if (optionalPortable.status === "fulfilled") {
|
||||
publishSetupCatalog(
|
||||
mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
|
||||
setCatalog,
|
||||
setSelectedSetupId,
|
||||
);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
optionalPortable.reason instanceof Error
|
||||
&& optionalPortable.reason.message.trim()
|
||||
? optionalPortable.reason.message
|
||||
: "Portable-каталог LAB V1 недоступен.",
|
||||
);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
@@ -129,6 +143,39 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function publishSetupCatalog(
|
||||
next: ObservatoryLaboratorySetupCatalog,
|
||||
setCatalog: (catalog: ObservatoryLaboratorySetupCatalog) => void,
|
||||
setSelectedSetupId: (update: (current: string) => string) => void,
|
||||
): void {
|
||||
setCatalog(next);
|
||||
setSelectedSetupId((current) => {
|
||||
if (next.setups.some(
|
||||
(setup) => setup.setupId === current && setup.compatibility.compatible,
|
||||
)) return current;
|
||||
return next.setups.find(
|
||||
(setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing",
|
||||
)?.setupId
|
||||
?? next.setups.find((setup) => setup.compatibility.compatible)?.setupId
|
||||
?? next.setups[0]?.setupId
|
||||
?? "";
|
||||
});
|
||||
}
|
||||
|
||||
function mergeSetupCatalogs(
|
||||
legacy: ObservatoryLaboratorySetupCatalog,
|
||||
portable: ObservatoryLaboratorySetupCatalog,
|
||||
): ObservatoryLaboratorySetupCatalog {
|
||||
if (legacy.sourceSessionId !== portable.sourceSessionId) {
|
||||
throw new Error("Каталоги сетапов относятся к разным исходным сессиям.");
|
||||
}
|
||||
const setups = [...legacy.setups, ...portable.setups];
|
||||
if (new Set(setups.map((setup) => setup.setupId)).size !== setups.length) {
|
||||
throw new Error("Каталоги сетапов содержат повторяющиеся идентификаторы.");
|
||||
}
|
||||
return { sourceSessionId: legacy.sourceSessionId, setups };
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupsController = ReturnType<
|
||||
typeof useObservatoryLaboratorySetups
|
||||
>;
|
||||
|
||||
@@ -104,7 +104,7 @@ const recordedJobStatus: Record<
|
||||
label: "Ждём подтверждения остановки",
|
||||
tone: "warning",
|
||||
},
|
||||
succeeded: { label: "Готово", tone: "success" },
|
||||
succeeded: { label: "Вычислено · ждёт публикации", tone: "warning" },
|
||||
failed: { label: "Ошибка расчёта", tone: "danger" },
|
||||
"reconciliation-required": { label: "Нужна сверка", tone: "warning" },
|
||||
};
|
||||
@@ -210,7 +210,13 @@ export function ObservatoryWorkspace({
|
||||
value: setup.setupId,
|
||||
label: setup.displayName,
|
||||
description: setup.compatibility.compatible
|
||||
? setup.origin === "existing-result" ? "Готовый результат" : "Совместимый архивный сетап"
|
||||
? setup.origin === "existing-result"
|
||||
? "Готовый результат"
|
||||
: setup.origin === "portable-definition"
|
||||
? setup.executor.state === "ready"
|
||||
? "Запись совместима · Worker установлен, запуск закрыт"
|
||||
: "Запись совместима · Worker не установлен"
|
||||
: "Совместимый архивный сетап"
|
||||
: "Несовместим с выбранной сессией",
|
||||
})) ?? []
|
||||
), [setupController.catalog]);
|
||||
@@ -489,6 +495,20 @@ export function ObservatoryWorkspace({
|
||||
<StatusBadge tone="neutral">Проверяем сетап</StatusBadge>
|
||||
) : queueStateBusy ? (
|
||||
<StatusBadge tone="neutral">Читаем очередь</StatusBadge>
|
||||
) : setupController.selectedSetup?.origin === "portable-definition"
|
||||
&& runPreflight ? (
|
||||
<StatusBadge
|
||||
tone={setupController.selectedSetup.compatibility.compatible
|
||||
? "warning"
|
||||
: "danger"}
|
||||
title={setupController.selectedSetup.preflight.reason}
|
||||
>
|
||||
{setupController.selectedSetup.compatibility.compatible
|
||||
? setupController.selectedSetup.executor.state === "not-installed"
|
||||
? "Worker LAB V1 не установлен"
|
||||
: "Запуск LAB V1 недоступен"
|
||||
: "Запись несовместима"}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{canSubmitRecordedJob ? (
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchObservatoryLaboratorySetups;
|
||||
let fetchObservatoryPortableLaboratorySetups;
|
||||
let preflightObservatoryLaboratorySetup;
|
||||
let ObservatoryLaboratorySetupContractError;
|
||||
|
||||
@@ -70,6 +71,76 @@ function setup() {
|
||||
};
|
||||
}
|
||||
|
||||
function portableSetup() {
|
||||
return {
|
||||
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
|
||||
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
description: "Проверка записанной K1-сессии моделями EoMT и DDRNet; только наблюдение.",
|
||||
origin: "portable-definition",
|
||||
source_requirements: {
|
||||
plugin_id: "nodedc.device.xgrids-lixelkity-k1",
|
||||
archive_id: "xgrids-k1.viewer-live.evidence",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
camera_source_id: "sensor.camera.right",
|
||||
camera_semantic_channel_id: "camera.video.recorded",
|
||||
recorded_media_type: 'video/mp4; codecs="avc1.641028"',
|
||||
recorded_media_init_sha256: "1".repeat(64),
|
||||
camera_width: 800,
|
||||
camera_height: 600,
|
||||
calibration_slot: "camera_1",
|
||||
calibration_identity_sha256: "2".repeat(64),
|
||||
exactly_one_media_epoch: true,
|
||||
seekable: true,
|
||||
},
|
||||
run_definition: {
|
||||
definition_id: "lab-v1-eomt-ddrnet-portable",
|
||||
version: 1,
|
||||
definition_sha256: "3".repeat(64),
|
||||
result_schema: "missioncore.recorded-eomt-ddrnet-review/v2",
|
||||
result_kind: "recorded-perception-qualification",
|
||||
models: [
|
||||
{
|
||||
name: "EoMT Cityscapes Large 1024",
|
||||
release_id: "eomt-cityscapes-large-1024-v1",
|
||||
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||
architecture: "EomtForUniversalSegmentation",
|
||||
},
|
||||
{
|
||||
name: "DDRNet-39",
|
||||
release_id: "lab-v1-ddrnet-39-goose-fine-64-v1",
|
||||
model_id: "goose-ddrnet-class-512",
|
||||
architecture: "ddrnet_39",
|
||||
},
|
||||
],
|
||||
},
|
||||
source_compatibility: {
|
||||
outcome: "pass",
|
||||
compatible: true,
|
||||
reason: "Запись соответствует требованиям EoMT + DDRNet.",
|
||||
evidence: {
|
||||
frame_count: 6830,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 819,
|
||||
},
|
||||
},
|
||||
executor: {
|
||||
contour_id: "worker-006",
|
||||
state: "not-installed",
|
||||
ready: false,
|
||||
reason: "Immutable executor release не установлен.",
|
||||
},
|
||||
existing_results: [],
|
||||
preflight: {
|
||||
outcome: "blocked",
|
||||
action: "blocked",
|
||||
reason: "Вычислительный контур LAB V1 пока недоступен.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [],
|
||||
},
|
||||
authority,
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
@@ -78,6 +149,7 @@ before(async () => {
|
||||
});
|
||||
({
|
||||
fetchObservatoryLaboratorySetups,
|
||||
fetchObservatoryPortableLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
||||
@@ -111,6 +183,104 @@ test("Observatory setup catalog keeps definition identity separate from executor
|
||||
assert.equal(calls[0].init.method, "GET");
|
||||
});
|
||||
|
||||
test("portable LAB V1 reports compatible source separately from unavailable Worker", async () => {
|
||||
const calls = [];
|
||||
const selected = (await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [portableSetup()],
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
})).setups[0];
|
||||
|
||||
assert.equal(selected.origin, "portable-definition");
|
||||
assert.equal(selected.compatibility.compatible, true);
|
||||
assert.equal(selected.executor.state, "not-installed");
|
||||
assert.equal(selected.preflight.submissionAllowed, false);
|
||||
assert.deepEqual(selected.runDefinition.models.map((model) => model.name), [
|
||||
"EoMT Cityscapes Large 1024",
|
||||
"DDRNet-39",
|
||||
]);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observatory/portable-laboratory-setups?source_session_id=source-a",
|
||||
);
|
||||
|
||||
let unexpectedNetworkCall = false;
|
||||
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async () => {
|
||||
unexpectedNetworkCall = true;
|
||||
throw new Error("portable preflight must use its server projection");
|
||||
},
|
||||
});
|
||||
assert.equal(unexpectedNetworkCall, false);
|
||||
assert.equal(preflight.outcome, "blocked");
|
||||
assert.equal(preflight.submissionAllowed, false);
|
||||
assert.equal(preflight.checks[0].outcome, "pass");
|
||||
assert.equal(preflight.checks[1].outcome, "fail");
|
||||
});
|
||||
|
||||
test("portable LAB V1 rejects a premature enqueue projection", async () => {
|
||||
const premature = portableSetup();
|
||||
premature.executor = {
|
||||
contour_id: "worker-006",
|
||||
state: "ready",
|
||||
ready: true,
|
||||
reason: null,
|
||||
};
|
||||
premature.preflight = {
|
||||
outcome: "ready",
|
||||
action: "enqueue",
|
||||
reason: "Запись готова к постановке в очередь.",
|
||||
submission_allowed: true,
|
||||
existing_result_ids: [],
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [premature],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
/значение изменилось|значение не поддерживается|постановка в очередь ещё не поддерживается/,
|
||||
);
|
||||
});
|
||||
|
||||
test("portable LAB V1 rejects an unbound existing result projection", async () => {
|
||||
const spoofed = portableSetup();
|
||||
spoofed.existing_results = [{
|
||||
result_id: `legacy-vegetation-${"4".repeat(64)}`,
|
||||
result_kind: "recorded-perception-qualification",
|
||||
created_at_utc: "2026-08-30T18:16:00Z",
|
||||
}];
|
||||
spoofed.preflight = {
|
||||
outcome: "existing",
|
||||
action: "open-existing",
|
||||
reason: "Результат якобы готов.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [spoofed.existing_results[0].result_id],
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [spoofed],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
/значение изменилось|проверяемая привязка результата/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
|
||||
@@ -239,7 +239,10 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
|
||||
workspace,
|
||||
/"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/,
|
||||
);
|
||||
assert.match(workspace, /succeeded: \{ label: "Готово"/);
|
||||
assert.doesNotMatch(setupHook, /Promise\.allSettled/);
|
||||
assert.match(setupHook, /publishSetupCatalog\(legacyCatalog/);
|
||||
assert.match(workspace, /Worker установлен, запуск закрыт/);
|
||||
assert.match(workspace, /succeeded: \{ label: "Вычислено · ждёт публикации"/);
|
||||
assert.match(workspace, /failed: \{ label: "Ошибка расчёта"/);
|
||||
assert.match(workspace, /"reconciliation-required": \{ label: "Нужна сверка"/);
|
||||
assert.match(
|
||||
|
||||
Reference in New Issue
Block a user