feat(observatory): add laboratory setup preflight
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
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 interface ObservatoryLaboratoryRunDefinition {
|
||||
readonly definitionId: string;
|
||||
readonly version: number;
|
||||
readonly workId: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly configuration: readonly {
|
||||
readonly role: string;
|
||||
readonly sha256: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryPreservedResult {
|
||||
readonly resultId: string;
|
||||
readonly resultKind: string;
|
||||
readonly relation: string;
|
||||
readonly access: "legacy-lab" | "observatory" | "evidence-only";
|
||||
readonly createdAtUtc: string;
|
||||
readonly observatoryProjectionAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetup {
|
||||
readonly setupId: string;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly origin: ObservatoryLaboratorySetupOrigin;
|
||||
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
|
||||
readonly compatibility: {
|
||||
readonly compatible: boolean;
|
||||
readonly reasons: readonly { readonly code: string; readonly message: string }[];
|
||||
};
|
||||
readonly executor: {
|
||||
readonly contourId: string;
|
||||
readonly state: "not-installed";
|
||||
readonly reasonCode: string;
|
||||
readonly reason: string;
|
||||
};
|
||||
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
|
||||
readonly preflight: {
|
||||
readonly outcome: "existing" | "blocked";
|
||||
readonly action: ObservatoryLaboratorySetupAction;
|
||||
readonly reason: string;
|
||||
readonly submissionAllowed: false;
|
||||
readonly existingResultIds: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetupCatalog {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setups: readonly ObservatoryLaboratorySetup[];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryRunPreflight {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string | null;
|
||||
readonly outcome: "existing" | "blocked";
|
||||
readonly submissionAllowed: false;
|
||||
readonly checks: readonly {
|
||||
readonly checkId: string;
|
||||
readonly outcome: "pass" | "fail" | "not-applicable";
|
||||
readonly reasonCode: string;
|
||||
readonly message: string;
|
||||
}[];
|
||||
readonly existingResultIds: readonly string[];
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class ObservatoryLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/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 = decodeCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId: string,
|
||||
setup: ObservatoryLaboratorySetup,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratoryRunPreflight> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
"/api/v1/observatory/run-preflights",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PREFLIGHT_REQUEST_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setup.setupId,
|
||||
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const preflight = decodePreflight(body);
|
||||
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другому источнику или сетапу.",
|
||||
);
|
||||
}
|
||||
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другой версии сетапа.",
|
||||
);
|
||||
}
|
||||
return preflight;
|
||||
}
|
||||
|
||||
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "каталог сетапов");
|
||||
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
|
||||
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
|
||||
observationAuthority(row.authority);
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "source_session_id"),
|
||||
setups: array(row.setups, "setups").map(decodeSetup),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "compatibility", "description", "display_name", "executor", "origin",
|
||||
"preflight", "preserved_results", "run_definition", "setup_id", "source",
|
||||
], "сетап");
|
||||
observationAuthority(row.authority);
|
||||
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
|
||||
const compatibility = record(row.compatibility, "compatibility");
|
||||
const executor = record(row.executor, "executor");
|
||||
const preflight = record(row.preflight, "preflight");
|
||||
const source = record(row.source, "source");
|
||||
const runDefinition = row.run_definition === null
|
||||
? null
|
||||
: decodeRunDefinition(row.run_definition);
|
||||
if ((origin === "archived-definition") !== (runDefinition !== null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Происхождение сетапа не совпадает с RunDefinition.",
|
||||
);
|
||||
}
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
|
||||
text(source.session_id, "source.session_id");
|
||||
text(source.label, "source.label");
|
||||
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
|
||||
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
|
||||
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
|
||||
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
|
||||
return {
|
||||
setupId: text(row.setup_id, "setup_id"),
|
||||
displayName: text(row.display_name, "display_name"),
|
||||
description: text(row.description, "description"),
|
||||
origin,
|
||||
runDefinition,
|
||||
compatibility: {
|
||||
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
|
||||
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
|
||||
const reason = record(item, "compatibility reason");
|
||||
exactKeys(reason, ["code", "message"], "compatibility reason");
|
||||
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
|
||||
}),
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "executor.contour_id"),
|
||||
state: exact(executor.state, "not-installed", "executor.state"),
|
||||
reasonCode: text(executor.reason_code, "executor.reason_code"),
|
||||
reason: text(executor.reason, "executor.reason"),
|
||||
},
|
||||
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
|
||||
preflight: {
|
||||
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
|
||||
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
|
||||
reason: text(preflight.reason, "preflight.reason"),
|
||||
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
|
||||
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "RunDefinition");
|
||||
exactKeys(row, [
|
||||
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
|
||||
"source", "version", "work_id",
|
||||
], "RunDefinition");
|
||||
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
|
||||
observationAuthority(row.authority);
|
||||
const source = record(row.source, "RunDefinition source");
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
|
||||
text(source.session_id, "RunDefinition source.session_id");
|
||||
text(source.label, "RunDefinition source.label");
|
||||
array(source.required_modalities, "RunDefinition source.required_modalities")
|
||||
.forEach((item) => text(item, "RunDefinition source modality"));
|
||||
const digest = text(row.definition_sha256, "definition_sha256");
|
||||
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
|
||||
return {
|
||||
definitionId: text(row.definition_id, "definition_id"),
|
||||
version: positiveInteger(row.version, "definition version"),
|
||||
workId: text(row.work_id, "work_id"),
|
||||
definitionSha256: digest,
|
||||
configuration: array(row.configuration, "configuration").map((item) => {
|
||||
const reference = record(item, "configuration reference");
|
||||
exactKeys(reference, ["role", "sha256"], "configuration reference");
|
||||
const sha256 = text(reference.sha256, "configuration sha256");
|
||||
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
|
||||
return { role: text(reference.role, "configuration role"), sha256 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
|
||||
const row = record(value, "preserved result");
|
||||
exactKeys(row, [
|
||||
"access", "created_at_utc", "observatory_projection_available", "relation",
|
||||
"result_id", "result_kind",
|
||||
], "preserved result");
|
||||
return {
|
||||
resultId: text(row.result_id, "result_id"),
|
||||
resultKind: text(row.result_kind, "result_kind"),
|
||||
relation: text(row.relation, "relation"),
|
||||
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
|
||||
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
|
||||
const row = record(value, "preflight");
|
||||
exactKeys(row, [
|
||||
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
|
||||
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
|
||||
], "preflight");
|
||||
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
|
||||
observationAuthority(row.authority);
|
||||
const digest = row.definition_sha256;
|
||||
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
|
||||
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
|
||||
}
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
|
||||
setupId: text(row.setup_id, "preflight setup_id"),
|
||||
definitionSha256: digest,
|
||||
outcome: oneOf(row.outcome, ["existing", "blocked"] as const, "preflight outcome"),
|
||||
submissionAllowed: exact(row.submission_allowed, false, "preflight submission_allowed"),
|
||||
checks: array(row.checks, "preflight checks").map((item) => {
|
||||
const check = record(item, "preflight check");
|
||||
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
|
||||
return {
|
||||
checkId: text(check.check_id, "check_id"),
|
||||
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
|
||||
reasonCode: text(check.reason_code, "check reason_code"),
|
||||
message: text(check.message, "check message"),
|
||||
};
|
||||
}),
|
||||
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
};
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(fetcher: ObservatoryLaboratorySetupFetch, 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 ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
|
||||
}
|
||||
}
|
||||
|
||||
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): ObservatoryLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${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 ObservatoryLaboratorySetupContractError(`${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 ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
type ObservatoryLaboratoryRunPreflight,
|
||||
type ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
|
||||
export type SetupPreflightState =
|
||||
| { readonly kind: "idle" }
|
||||
| { readonly kind: "checking" }
|
||||
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
|
||||
| { readonly kind: "error"; readonly message: string };
|
||||
|
||||
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
|
||||
const [state, setState] = useState<SetupCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSetupId, setSelectedSetupId] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
|
||||
const requestSequence = useRef(0);
|
||||
const preflightRequest = useRef<AbortController | null>(null);
|
||||
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
|
||||
|
||||
useEffect(() => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
if (!sourceSessionId) {
|
||||
setCatalog(null);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setSelectedSetupId("");
|
||||
setPreflight({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence.current;
|
||||
const request = new AbortController();
|
||||
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
setPreflight({ kind: "idle" });
|
||||
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||
.then((next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setCatalog(next);
|
||||
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
|
||||
?? "";
|
||||
});
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setState("error");
|
||||
setError(caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Каталог сетапов недоступен.");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
useEffect(() => () => preflightRequest.current?.abort(), []);
|
||||
|
||||
const selectedSetup = useMemo(
|
||||
() => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null,
|
||||
[activeCatalog, selectedSetupId],
|
||||
);
|
||||
|
||||
const selectSetup = useCallback((setupId: string) => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
setSelectedSetupId(setupId);
|
||||
setPreflight({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
|
||||
preflightRequest.current?.abort();
|
||||
const request = new AbortController();
|
||||
preflightRequest.current = request;
|
||||
setPreflight({ kind: "checking" });
|
||||
try {
|
||||
const value = await preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId,
|
||||
selectedSetup,
|
||||
{ signal: request.signal },
|
||||
);
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({ kind: "ready", value });
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({
|
||||
kind: "error",
|
||||
message: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось проверить возможность запуска.",
|
||||
});
|
||||
} finally {
|
||||
if (preflightRequest.current === request) preflightRequest.current = null;
|
||||
}
|
||||
}, [preflight.kind, selectedSetup, sourceSessionId]);
|
||||
|
||||
return {
|
||||
catalog: activeCatalog,
|
||||
state,
|
||||
error,
|
||||
selectedSetupId,
|
||||
selectedSetup,
|
||||
selectSetup,
|
||||
refresh,
|
||||
preflight,
|
||||
check,
|
||||
};
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupsController = ReturnType<
|
||||
typeof useObservatoryLaboratorySetups
|
||||
>;
|
||||
@@ -52,13 +52,13 @@
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
flex: 1 1 32rem;
|
||||
flex: 2 1 48rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 1 1 22rem;
|
||||
min-width: min(28rem, 100%);
|
||||
flex: 1 1 18rem;
|
||||
min-width: min(22rem, 100%);
|
||||
}
|
||||
|
||||
.observatory-state {
|
||||
@@ -148,6 +148,130 @@
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-setup-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.85rem 1rem;
|
||||
container-name: observatory-setup;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__identity h3 {
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__identity p {
|
||||
max-width: 68rem;
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__badges,
|
||||
.observatory-setup-detail__actions,
|
||||
.observatory-setup-detail__reason {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__badges {
|
||||
align-self: start;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__facts {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__facts > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-setup-detail__facts dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-setup-detail__facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-setup-results {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.observatory-setup-results li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 3.15rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-setup-results span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.observatory-setup-results strong,
|
||||
.observatory-setup-results small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-setup-results small {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-setup-detail__footer {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__reason {
|
||||
min-width: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.observatory-evidence {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
@@ -315,6 +439,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-setup (max-width: 48rem) {
|
||||
.observatory-setup-detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observatory-setup-detail__badges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observatory-setup-detail__footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-setup-detail__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 48rem) {
|
||||
.observatory-evidence-card {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservatoryEvidence } from "../../core/observatory/catalog";
|
||||
import type { ObservatoryLaboratorySetup } from "../../core/observatory/laboratorySetups";
|
||||
import type { ObservatoryRecordedRunBinding } from "../../core/observatory/recordedRun";
|
||||
import type { SetupPreflightState } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
|
||||
const relationLabel: Readonly<Record<string, string>> = {
|
||||
"primary-visual": "Основной визуальный разбор",
|
||||
"compute-successor": "Связанное вычислительное доказательство",
|
||||
"canonical-projection": "Готовый записанный разбор",
|
||||
};
|
||||
|
||||
function workerLabel(value: string): string {
|
||||
const match = /^worker-(\d+)$/.exec(value);
|
||||
return match ? `Worker ${match[1]}` : value;
|
||||
}
|
||||
|
||||
export function ObservatorySetupDetail({
|
||||
setup,
|
||||
evidence,
|
||||
preflight,
|
||||
onCheck,
|
||||
onOpenExisting,
|
||||
}: {
|
||||
setup: ObservatoryLaboratorySetup;
|
||||
evidence: readonly ObservatoryEvidence[];
|
||||
preflight: SetupPreflightState;
|
||||
onCheck: () => void;
|
||||
onOpenExisting: (binding: ObservatoryRecordedRunBinding) => void;
|
||||
}) {
|
||||
const exactExisting = evidence.find(
|
||||
(candidate) => candidate.recordedRun
|
||||
&& setup.preflight.existingResultIds.includes(candidate.recordedRun.resultId),
|
||||
)?.recordedRun ?? null;
|
||||
const displayedReason = preflight.kind === "ready"
|
||||
? preflight.value.checks.find((check) => check.checkId === "executor")?.message
|
||||
?? setup.executor.reason
|
||||
: preflight.kind === "error"
|
||||
? preflight.message
|
||||
: setup.preflight.reason;
|
||||
|
||||
return (
|
||||
<GlassSurface className="observatory-setup-detail" padding="md">
|
||||
<div className="observatory-setup-detail__identity">
|
||||
<span className="section-eyebrow">СЕТАП ЛАБОРАТОРИИ</span>
|
||||
<h3>{setup.displayName}</h3>
|
||||
<p>{setup.description}</p>
|
||||
</div>
|
||||
<div className="observatory-setup-detail__badges">
|
||||
<StatusBadge tone={setup.compatibility.compatible ? "success" : "warning"}>
|
||||
{setup.compatibility.compatible ? "Совместим" : "Другая сессия"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={setup.origin === "existing-result" ? "accent" : "neutral"}>
|
||||
{setup.origin === "existing-result" ? "Готовый результат" : "Архивный сетап"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<dl className="observatory-setup-detail__facts">
|
||||
<div>
|
||||
<dt>Идентичность</dt>
|
||||
<dd>
|
||||
{setup.runDefinition
|
||||
? `Версия ${setup.runDefinition.version} · конфигурация зафиксирована`
|
||||
: "Готовый записанный результат без повторно запускаемой конфигурации"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исполнитель</dt>
|
||||
<dd>
|
||||
{setup.preflight.outcome === "existing"
|
||||
? "Не требуется для просмотра"
|
||||
: `${workerLabel(setup.executor.contourId)} · повторный запуск не подключён`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Сохранено</dt>
|
||||
<dd>{setup.preservedResults.length} неизменяемых результатов</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ol className="observatory-setup-results" aria-label="Сохранённые результаты сетапа">
|
||||
{setup.preservedResults.map((result) => (
|
||||
<li key={result.resultId}>
|
||||
<Icon name={result.access === "evidence-only" ? "database" : "clipboard"} size={15} />
|
||||
<span>
|
||||
<strong>{relationLabel[result.relation] ?? "Сохранённый результат"}</strong>
|
||||
<small>
|
||||
{result.access === "observatory"
|
||||
? "Доступен в Обсерватории"
|
||||
: result.access === "legacy-lab"
|
||||
? "Сохранён в legacy LAB"
|
||||
: "Сохранено как связанное доказательство"}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge tone={result.observatoryProjectionAvailable ? "success" : "neutral"}>
|
||||
{result.access === "observatory"
|
||||
? result.observatoryProjectionAvailable ? "Готов" : "Сохранён"
|
||||
: result.access === "legacy-lab" ? "Legacy LAB" : "Evidence"}
|
||||
</StatusBadge>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="observatory-setup-detail__footer">
|
||||
<div className="observatory-setup-detail__reason" role={preflight.kind === "error" ? "alert" : "status"}>
|
||||
<Icon name={preflight.kind === "error" ? "alert" : "activity"} size={16} />
|
||||
<span>{displayedReason}</span>
|
||||
</div>
|
||||
<div className="observatory-setup-detail__actions">
|
||||
{exactExisting ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="eye" size={15} />}
|
||||
onClick={() => onOpenExisting(exactExisting)}
|
||||
>
|
||||
Открыть готовый результат
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={preflight.kind === "checking"}
|
||||
onClick={onCheck}
|
||||
>
|
||||
{preflight.kind === "checking"
|
||||
? <ActivityIndicator size="compact" label="Проверяем совместимость" />
|
||||
: "Проверить совместимость"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
renameObservatoryLabProjection,
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
import { ObservatorySetupDetail } from "./ObservatorySetupDetail";
|
||||
|
||||
const MAX_PRESENTED_EVIDENCE = 6;
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
@@ -133,6 +135,7 @@ export function ObservatoryWorkspace({
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
@@ -170,6 +173,15 @@ export function ObservatoryWorkspace({
|
||||
label: source.label,
|
||||
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||
})), [items]);
|
||||
const setupOptions = useMemo(() => (
|
||||
setupController.catalog?.setups.map((setup) => ({
|
||||
value: setup.setupId,
|
||||
label: setup.displayName,
|
||||
description: setup.compatibility.compatible
|
||||
? setup.origin === "existing-result" ? "Готовый результат" : "Совместимый архивный сетап"
|
||||
: "Несовместим с выбранной сессией",
|
||||
})) ?? []
|
||||
), [setupController.catalog]);
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
@@ -375,12 +387,32 @@ export function ObservatoryWorkspace({
|
||||
menuWidth={460}
|
||||
onChange={selectSession}
|
||||
/>
|
||||
<Select
|
||||
label="Выбрать сетап лаборатории"
|
||||
value={setupController.selectedSetupId}
|
||||
options={setupOptions}
|
||||
disabled={!selectedSessionId || setupOptions.length === 0}
|
||||
searchable
|
||||
searchPlaceholder="Поиск по сетапам"
|
||||
emptyLabel={setupController.state === "error" ? "Каталог сетапов недоступен" : "Сетап не найден"}
|
||||
minMenuWidth={360}
|
||||
menuWidth={500}
|
||||
onChange={setupController.selectSetup}
|
||||
/>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={controller.state === "loading" || controller.state === "refreshing"}
|
||||
disabled={
|
||||
controller.state === "loading"
|
||||
|| controller.state === "refreshing"
|
||||
|| setupController.state === "loading"
|
||||
|| setupController.state === "refreshing"
|
||||
}
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={controller.refresh}
|
||||
onClick={() => {
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
@@ -395,6 +427,14 @@ export function ObservatoryWorkspace({
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{setupController.error && setupController.catalog ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<StatusBadge tone="warning">Показан последний каталог сетапов</StatusBadge>
|
||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{controller.catalog
|
||||
&& (controller.catalog.window.sourceLimitReached
|
||||
|| controller.catalog.window.laboratoryLimitReached) ? (
|
||||
@@ -460,6 +500,22 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
{setupController.selectedSetup ? (
|
||||
<ObservatorySetupDetail
|
||||
setup={setupController.selectedSetup}
|
||||
evidence={selectedSession.evidence}
|
||||
preflight={setupController.preflight}
|
||||
onCheck={() => { void setupController.check(); }}
|
||||
onOpenExisting={openReplay}
|
||||
/>
|
||||
) : setupController.error ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<StatusBadge tone="warning">Сетапы недоступны</StatusBadge>
|
||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<section className="observatory-evidence">
|
||||
<header>
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchObservatoryLaboratorySetups;
|
||||
let preflightObservatoryLaboratorySetup;
|
||||
let ObservatoryLaboratorySetupContractError;
|
||||
|
||||
const authority = {
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
production_accepted: false,
|
||||
};
|
||||
|
||||
function definition() {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-run-definition/v1",
|
||||
definition_id: "m49-tgs-full-shadow",
|
||||
version: 1,
|
||||
work_id: "m49-tgs-full-shadow",
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
label: "RAVNOVES00",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
},
|
||||
configuration: [{ role: "primary-profile", sha256: "a".repeat(64) }],
|
||||
authority,
|
||||
definition_sha256: "b".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return {
|
||||
setup_id: "m49-tgs-full-shadow-v1",
|
||||
display_name: "M4.9T5",
|
||||
description: "Сохранённый полный source-paced shadow.",
|
||||
origin: "archived-definition",
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
label: "RAVNOVES00",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
},
|
||||
run_definition: definition(),
|
||||
compatibility: { compatible: true, reasons: [] },
|
||||
executor: {
|
||||
contour_id: "worker-006",
|
||||
state: "not-installed",
|
||||
reason_code: "laboratory-runner-adapter-not-installed",
|
||||
reason: "Адаптер не установлен.",
|
||||
},
|
||||
preserved_results: [{
|
||||
result_id: `m49-tgs-full-shadow-${"c".repeat(64)}`,
|
||||
result_kind: "recorded-source-paced-tgs-shadow",
|
||||
relation: "primary-visual",
|
||||
access: "legacy-lab",
|
||||
created_at_utc: "2026-08-26T20:27:19Z",
|
||||
observatory_projection_available: false,
|
||||
}],
|
||||
preflight: {
|
||||
outcome: "blocked",
|
||||
action: "open-legacy",
|
||||
reason: "Точный результат сохранён в legacy LAB.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [],
|
||||
},
|
||||
authority,
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchObservatoryLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("Observatory setup catalog keeps definition identity separate from executor availability", async () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
|
||||
const catalog = await fetchObservatoryLaboratorySetups("source-a", { fetcher });
|
||||
assert.equal(catalog.setups[0].runDefinition.definitionSha256, "b".repeat(64));
|
||||
assert.equal(catalog.setups[0].executor.state, "not-installed");
|
||||
assert.equal(catalog.setups[0].preflight.submissionAllowed, false);
|
||||
assert.equal(catalog.setups[0].preservedResults[0].access, "legacy-lab");
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observatory/laboratory-setups?source_session_id=source-a",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "GET");
|
||||
});
|
||||
|
||||
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({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
let request;
|
||||
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async (input, init) => {
|
||||
request = { input: String(input), init };
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "b".repeat(64),
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [{
|
||||
check_id: "executor",
|
||||
outcome: "fail",
|
||||
reason_code: "laboratory-runner-adapter-not-installed",
|
||||
message: "Адаптер не установлен.",
|
||||
}],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(request.input, "/api/v1/observatory/run-preflights");
|
||||
assert.equal(request.init.method, "POST");
|
||||
assert.deepEqual(JSON.parse(request.init.body), {
|
||||
schema_version: "missioncore.observatory-run-preflight-request/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: "m49-tgs-full-shadow-v1",
|
||||
definition_sha256: "b".repeat(64),
|
||||
});
|
||||
assert.equal(preflight.outcome, "blocked");
|
||||
assert.equal(preflight.submissionAllowed, false);
|
||||
});
|
||||
|
||||
test("Observatory setup contract rejects authority escalation and response drift", async () => {
|
||||
const escalated = setup();
|
||||
escalated.authority = { ...authority, commands_enabled: true };
|
||||
await assert.rejects(
|
||||
fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [escalated],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
|
||||
const drifted = setup();
|
||||
drifted.unexpected = true;
|
||||
await assert.rejects(
|
||||
fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [drifted],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
await assert.rejects(
|
||||
preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "c".repeat(64),
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
});
|
||||
@@ -189,3 +189,24 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
|
||||
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||
});
|
||||
|
||||
test("Observatory configurator keeps selection and preflight read-only", async () => {
|
||||
const [workspace, setupDetail, setupHook] = await Promise.all([
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("workspaces/observatory/ObservatorySetupDetail.tsx"),
|
||||
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
||||
]);
|
||||
|
||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.match(workspace, /Показан последний каталог сетапов/);
|
||||
assert.match(setupDetail, /Проверить совместимость/);
|
||||
assert.doesNotMatch(
|
||||
setupDetail,
|
||||
/Рассчитать лабораторию|definitionSha256|<small>\{result\.resultId\}/,
|
||||
);
|
||||
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
||||
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
||||
assert.match(setupHook, /preflightRequest\.current !== request/);
|
||||
assert.doesNotMatch(`${workspace}\n${setupDetail}\n${setupHook}`, /\/api\/v1\/observatory\/runs/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user