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/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"schema_version": "missioncore.observatory-laboratory-setup-registry/v1",
|
||||
"setups": [
|
||||
{
|
||||
"setup_id": "m49-tgs-full-shadow-v1",
|
||||
"display_name": "M4.9T5 · TRAVEL TGS · полный source-paced shadow",
|
||||
"description": "Сохранённая CPU-конфигурация RAVNOVES00: 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
|
||||
"origin": "archived-definition",
|
||||
"source": {
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"label": "RAVNOVES00",
|
||||
"required_modalities": [
|
||||
"point-cloud",
|
||||
"trajectory",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
"run_definition": {
|
||||
"definition_id": "m49-tgs-full-shadow",
|
||||
"version": 1,
|
||||
"work_id": "m49-tgs-full-shadow",
|
||||
"configuration": [
|
||||
{
|
||||
"role": "primary-profile",
|
||||
"path": "config/perception/m49-tgs-full-shadow-v1.json",
|
||||
"sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
|
||||
}
|
||||
]
|
||||
},
|
||||
"executor": {
|
||||
"contour_id": "worker-006",
|
||||
"state": "not-installed",
|
||||
"reason_code": "laboratory-runner-adapter-not-installed",
|
||||
"reason": "Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
|
||||
},
|
||||
"preserved_results": [
|
||||
{
|
||||
"result_id": "m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e",
|
||||
"result_kind": "recorded-source-paced-tgs-shadow",
|
||||
"relation": "primary-visual",
|
||||
"access": "legacy-lab",
|
||||
"created_at_utc": "2026-08-26T20:27:19.076865Z"
|
||||
},
|
||||
{
|
||||
"result_id": "m49-tgs-integrated-graph-shadow-75e48fd8acf0246be16613e087fcf26fe909f7834718d217c74785a57f494b24",
|
||||
"result_kind": "source-paced-integrated-graph-shadow",
|
||||
"relation": "compute-successor",
|
||||
"access": "evidence-only",
|
||||
"created_at_utc": "2026-08-27T08:50:05.5622153+00:00"
|
||||
}
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"production_accepted": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"setup_id": "lab-v1-ravnoves004tree-final",
|
||||
"display_name": "LAB V1 · финальный записанный контур восприятия",
|
||||
"description": "Текущий точный RAVNOVES004TREE result: 6 830 кадров EoMT + DDRNet и синхронный записанный replay без доступа к управлению.",
|
||||
"origin": "existing-result",
|
||||
"source": {
|
||||
"session_id": "20260828T130511Z_viewer_live",
|
||||
"label": "RAVNOVES004TREE",
|
||||
"required_modalities": [
|
||||
"point-cloud",
|
||||
"trajectory",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
"run_definition": null,
|
||||
"executor": {
|
||||
"contour_id": "worker-006",
|
||||
"state": "not-installed",
|
||||
"reason_code": "durable-dispatch-definition-unavailable",
|
||||
"reason": "Для готового результата не сохранён повторно запускаемый сетап; доступен записанный разбор."
|
||||
},
|
||||
"preserved_results": [
|
||||
{
|
||||
"result_id": "lab-v1-vegetation-shadow-8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60",
|
||||
"result_kind": "recorded-perception-qualification",
|
||||
"relation": "canonical-projection",
|
||||
"access": "observatory",
|
||||
"created_at_utc": "2026-08-29T18:05:11.329061Z"
|
||||
}
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"production_accepted": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -107,6 +107,32 @@ valid snapshot, and unavailable/error with retry. If a selected Session disappea
|
||||
selection moves to the first valid source or to the empty state. No demo rows, fabricated progress,
|
||||
placeholder actions or hidden polling are allowed.
|
||||
|
||||
## Session-to-setup configurator slice
|
||||
|
||||
The next bounded slice adds an explicit laboratory Setup selection beside the source Session. A
|
||||
Setup is a versioned, immutable compatibility contract, not a mutable bag of UI parameters. The
|
||||
catalog keeps two independently named historical facts:
|
||||
|
||||
- `M4.9T5 · TRAVEL TGS · полный source-paced shadow` is an archived RunDefinition bound exactly to
|
||||
`20260720T065719Z_viewer_live`. Its configuration references are content-addressed, its primary
|
||||
visual result remains in legacy LAB, and the integrated-graph result is retained as a compute
|
||||
successor rather than presented as a second viewer.
|
||||
- `LAB V1 · финальный записанный контур восприятия` is the current exact RAV004 result bound to
|
||||
`20260828T130511Z_viewer_live`. It predates the product RunDefinition contract and therefore keeps
|
||||
`run_definition = null`; the UI identifies it as an existing result and never fabricates a config
|
||||
digest for it.
|
||||
|
||||
Changing Session fetches only the small setup catalog. Changing Setup performs no computation.
|
||||
Compatibility requires the exact source Session identity, the preserved source label and all
|
||||
required modalities. An explicit `Проверить совместимость` action sends the selected source, setup
|
||||
and RunDefinition digest to a read-only preflight. Digest drift fails closed. The preflight may
|
||||
report an exact existing result or a blocked executor, but it cannot enqueue work.
|
||||
|
||||
The calculation action remains absent until a durable dispatcher exists with an idempotent
|
||||
submission key, immutable RunDefinition receipt, lifecycle ledger and exact result publication
|
||||
receipt. The currently preserved bespoke Worker scenario is not silently treated as that adapter.
|
||||
This boundary also keeps live equipment, historical replay and legacy LAB Rerun profiles separate.
|
||||
|
||||
## Design Guideline composition
|
||||
|
||||
The existing `ApplicationShell`, `AdminNavigationPanel` and `ApplicationPanel` composition remains
|
||||
@@ -130,6 +156,11 @@ workspace.
|
||||
- Linked evidence is never presented as a CV or safety pass.
|
||||
- Existing LAB, Simulation, K1 and Data/Sessions operator behavior remains unchanged.
|
||||
- Live, historical Session and legacy LAB viewer-profile contracts remain distinct.
|
||||
- Session and Setup are independently explicit selections; neither selection submits Worker work.
|
||||
- Both the archived M4.9T5 setup and the current final RAV004 result remain visible and immutable.
|
||||
- A pre-RunDefinition result never receives a fabricated configuration hash.
|
||||
- Preflight is read-only, digest-fenced and reports the missing executor adapter instead of
|
||||
fabricating queued or running states.
|
||||
|
||||
## Canary acceptance
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Observation-only laboratory orchestration contracts."""
|
||||
|
||||
from k1link.observatory.canonical_result import (
|
||||
is_admitted_observatory_recorded_result,
|
||||
)
|
||||
from k1link.observatory.setups import (
|
||||
LABORATORY_SETUP_CATALOG_SCHEMA,
|
||||
LABORATORY_SETUP_REGISTRY_SCHEMA,
|
||||
LaboratorySetupRegistry,
|
||||
LaboratorySetupRegistryError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LABORATORY_SETUP_CATALOG_SCHEMA",
|
||||
"LABORATORY_SETUP_REGISTRY_SCHEMA",
|
||||
"LaboratorySetupRegistry",
|
||||
"LaboratorySetupRegistryError",
|
||||
"is_admitted_observatory_recorded_result",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Exact admission for the current canonical recorded Observatory result."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from k1link.sessions.models import SessionSummary
|
||||
|
||||
_SHA256: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
_CANONICAL_RESULT: Final = re.compile(r"^lab-v1-vegetation-shadow-([a-f0-9]{64})$")
|
||||
_CANONICAL_SOURCE_SESSION_ID: Final = "20260828T130511Z_viewer_live"
|
||||
_CANONICAL_PIPELINE_ID: Final = (
|
||||
"ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||
)
|
||||
|
||||
|
||||
def is_admitted_observatory_recorded_result(
|
||||
summary: SessionSummary,
|
||||
*,
|
||||
expected_result_id: str,
|
||||
expected_source_session_id: str,
|
||||
expected_result_kind: str,
|
||||
) -> bool:
|
||||
"""Return true only for the exact capability-owned canonical projection."""
|
||||
|
||||
lab = summary.lab
|
||||
match = _CANONICAL_RESULT.fullmatch(expected_result_id)
|
||||
if (
|
||||
lab is None
|
||||
or match is None
|
||||
or expected_source_session_id != _CANONICAL_SOURCE_SESSION_ID
|
||||
or summary.session_id != expected_result_id
|
||||
or summary.origin != "missioncore.lab-instance/v1"
|
||||
or summary.status != "ready"
|
||||
or not summary.replayable
|
||||
or lab.session_id != expected_result_id
|
||||
or lab.result_id != expected_result_id
|
||||
or lab.source_session_id != expected_source_session_id
|
||||
or lab.lab_id != "LAB V1"
|
||||
or lab.result_kind != expected_result_kind
|
||||
or lab.result_kind != "recorded-perception-qualification"
|
||||
or lab.config_sha256 is not None
|
||||
or not isinstance(lab.source_result_id, str)
|
||||
or _CANONICAL_RESULT.fullmatch(lab.source_result_id) is None
|
||||
or lab.replay_capability is None
|
||||
):
|
||||
return False
|
||||
|
||||
capability = lab.replay_capability.as_dict()
|
||||
provenance = lab.provenance
|
||||
evidence_identity = match.group(1)
|
||||
if set(provenance) != {
|
||||
"schema_version",
|
||||
"evidence_identity_sha256",
|
||||
"result_document_sha256",
|
||||
"replay_capability",
|
||||
"authority",
|
||||
"method",
|
||||
}:
|
||||
return False
|
||||
result_document_sha256 = provenance.get("result_document_sha256")
|
||||
if (
|
||||
provenance.get("schema_version")
|
||||
!= "missioncore.canonical-recorded-lab-projection/v1"
|
||||
or provenance.get("evidence_identity_sha256") != evidence_identity
|
||||
or not isinstance(result_document_sha256, str)
|
||||
or _SHA256.fullmatch(result_document_sha256) is None
|
||||
or provenance.get("replay_capability") != capability
|
||||
or provenance.get("authority")
|
||||
!= {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
}
|
||||
):
|
||||
return False
|
||||
|
||||
return provenance.get("method") == {
|
||||
"schema_version": "missioncore.laboratory-method/v1",
|
||||
"completeness": "legacy-partial",
|
||||
"execution_class": "ai-inference",
|
||||
"pipeline_id": _CANONICAL_PIPELINE_ID,
|
||||
"components": [
|
||||
{
|
||||
"kind": "source",
|
||||
"name": "sealed full-route LAB result",
|
||||
"version": "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
"role": "immutable Session catalog projection",
|
||||
"identity_sha256": evidence_identity,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
"""Strict setup catalog for the Observatory laboratory configurator.
|
||||
|
||||
The catalog can represent both a real historical RunDefinition and an exact
|
||||
sealed result that predates RunDefinitions. It never upgrades the latter into
|
||||
a fabricated executable configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from k1link.sessions.models import SessionSummary
|
||||
|
||||
LABORATORY_SETUP_REGISTRY_SCHEMA: Final = (
|
||||
"missioncore.observatory-laboratory-setup-registry/v1"
|
||||
)
|
||||
LABORATORY_SETUP_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.observatory-laboratory-setup-catalog/v1"
|
||||
)
|
||||
_MAX_REGISTRY_BYTES: Final = 256 * 1024
|
||||
_MAX_CONFIGURATION_BYTES: Final = 4 * 1024 * 1024
|
||||
_IDENTIFIER: Final = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
|
||||
_RESULT_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,159}$")
|
||||
_SHA256: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
_MODALITIES: Final = frozenset({"point-cloud", "trajectory", "video"})
|
||||
_AUTHORITY: Final = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
|
||||
SetupOrigin = Literal["archived-definition", "existing-result"]
|
||||
ResultAccess = Literal["legacy-lab", "observatory", "evidence-only"]
|
||||
|
||||
|
||||
class LaboratorySetupRegistryError(ValueError):
|
||||
"""A setup registry or its immutable references are invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ConfigurationReference:
|
||||
role: str
|
||||
path: PurePosixPath
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RunDefinition:
|
||||
definition_id: str
|
||||
version: int
|
||||
work_id: str
|
||||
configuration: tuple[_ConfigurationReference, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Executor:
|
||||
contour_id: str
|
||||
state: Literal["not-installed"]
|
||||
reason_code: str
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreservedResult:
|
||||
result_id: str
|
||||
result_kind: str
|
||||
relation: str
|
||||
access: ResultAccess
|
||||
created_at_utc: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratorySetup:
|
||||
setup_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
origin: SetupOrigin
|
||||
source_session_id: str
|
||||
source_label: str
|
||||
required_modalities: tuple[str, ...]
|
||||
run_definition: _RunDefinition | None
|
||||
executor: _Executor
|
||||
preserved_results: tuple[_PreservedResult, ...]
|
||||
|
||||
def project(
|
||||
self,
|
||||
source: SessionSummary,
|
||||
*,
|
||||
available_observatory_result_ids: frozenset[str],
|
||||
) -> dict[str, object]:
|
||||
reasons: list[dict[str, str]] = []
|
||||
if source.lab is not None:
|
||||
reasons.append(_reason("source-is-lab", "Нужна исходная, а не LAB-сессия."))
|
||||
if source.session_id != self.source_session_id:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-session-not-admitted",
|
||||
"Эта версия привязана к другой запечатанной исходной сессии.",
|
||||
)
|
||||
)
|
||||
if source.display_name != self.source_label:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-label-mismatch",
|
||||
"Идентичность источника не совпадает с сохранённым сетапом.",
|
||||
)
|
||||
)
|
||||
if source.status != "ready":
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-not-ready",
|
||||
"Исходная сессия не находится в запечатанном состоянии ready.",
|
||||
)
|
||||
)
|
||||
if not source.replayable:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"source-not-replayable",
|
||||
"Для исходной сессии недоступно воспроизводимое чтение.",
|
||||
)
|
||||
)
|
||||
missing = sorted(set(self.required_modalities) - set(source.modalities))
|
||||
if missing:
|
||||
reasons.append(
|
||||
_reason(
|
||||
"required-modalities-unavailable",
|
||||
"Не хватает каналов: " + ", ".join(missing) + ".",
|
||||
)
|
||||
)
|
||||
compatible = not reasons
|
||||
ready_result_ids = [
|
||||
result.result_id
|
||||
for result in self.preserved_results
|
||||
if result.access == "observatory"
|
||||
and result.result_id in available_observatory_result_ids
|
||||
]
|
||||
if compatible and ready_result_ids:
|
||||
action = "open-existing"
|
||||
action_reason = "Точный запечатанный результат уже опубликован в Обсерватории."
|
||||
elif compatible and any(
|
||||
result.access == "legacy-lab" for result in self.preserved_results
|
||||
):
|
||||
action = "open-legacy"
|
||||
action_reason = "Точный результат сохранён в legacy LAB."
|
||||
else:
|
||||
action = "blocked"
|
||||
action_reason = reasons[0]["message"] if reasons else self.executor.reason
|
||||
return {
|
||||
"setup_id": self.setup_id,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"origin": self.origin,
|
||||
"source": {
|
||||
"session_id": self.source_session_id,
|
||||
"label": self.source_label,
|
||||
"required_modalities": list(self.required_modalities),
|
||||
},
|
||||
"run_definition": _project_definition(self, self.run_definition),
|
||||
"compatibility": {"compatible": compatible, "reasons": reasons},
|
||||
"executor": {
|
||||
"contour_id": self.executor.contour_id,
|
||||
"state": self.executor.state,
|
||||
"reason_code": self.executor.reason_code,
|
||||
"reason": self.executor.reason,
|
||||
},
|
||||
"preserved_results": [
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_kind": result.result_kind,
|
||||
"relation": result.relation,
|
||||
"access": result.access,
|
||||
"created_at_utc": result.created_at_utc,
|
||||
"observatory_projection_available": (
|
||||
result.result_id in available_observatory_result_ids
|
||||
),
|
||||
}
|
||||
for result in self.preserved_results
|
||||
],
|
||||
"preflight": {
|
||||
"outcome": "existing" if action == "open-existing" else "blocked",
|
||||
"action": action,
|
||||
"reason": action_reason,
|
||||
"submission_allowed": False,
|
||||
"existing_result_ids": ready_result_ids,
|
||||
},
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratorySetupRegistry:
|
||||
setups: tuple[LaboratorySetup, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
setup_ids = [setup.setup_id for setup in self.setups]
|
||||
if not setup_ids or len(setup_ids) != len(set(setup_ids)):
|
||||
raise LaboratorySetupRegistryError("setup IDs must be unique and non-empty")
|
||||
definition_versions = [
|
||||
(setup.run_definition.definition_id, setup.run_definition.version)
|
||||
for setup in self.setups
|
||||
if setup.run_definition is not None
|
||||
]
|
||||
if len(definition_versions) != len(set(definition_versions)):
|
||||
raise LaboratorySetupRegistryError("RunDefinition versions must be unique")
|
||||
result_ids = [
|
||||
result.result_id
|
||||
for setup in self.setups
|
||||
for result in setup.preserved_results
|
||||
]
|
||||
if len(result_ids) != len(set(result_ids)):
|
||||
raise LaboratorySetupRegistryError("preserved result IDs must be globally unique")
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
repository_root: Path,
|
||||
) -> LaboratorySetupRegistry:
|
||||
candidate = path.expanduser().absolute()
|
||||
root = repository_root.expanduser().resolve(strict=True)
|
||||
if candidate.is_symlink() or not candidate.is_file():
|
||||
raise LaboratorySetupRegistryError("setup registry must be a regular file")
|
||||
if candidate.stat().st_size > _MAX_REGISTRY_BYTES:
|
||||
raise LaboratorySetupRegistryError("setup registry is too large")
|
||||
try:
|
||||
document = _object(json.loads(candidate.read_text(encoding="utf-8")), "registry")
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise LaboratorySetupRegistryError("setup registry is unreadable") from exc
|
||||
_exact_keys(document, {"schema_version", "setups"}, "registry")
|
||||
if document["schema_version"] != LABORATORY_SETUP_REGISTRY_SCHEMA:
|
||||
raise LaboratorySetupRegistryError("setup registry schema is invalid")
|
||||
rows = document["setups"]
|
||||
if not isinstance(rows, list):
|
||||
raise LaboratorySetupRegistryError("setup registry rows must be an array")
|
||||
return cls(tuple(_setup(row, repository_root=root) for row in rows))
|
||||
|
||||
def catalog(
|
||||
self,
|
||||
source: SessionSummary,
|
||||
*,
|
||||
available_observatory_result_ids: frozenset[str] = frozenset(),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": LABORATORY_SETUP_CATALOG_SCHEMA,
|
||||
"source_session_id": source.session_id,
|
||||
"setups": [
|
||||
setup.project(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_result_ids,
|
||||
)
|
||||
for setup in self.setups
|
||||
],
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
|
||||
def setup(self, setup_id: str) -> LaboratorySetup:
|
||||
for setup in self.setups:
|
||||
if setup.setup_id == setup_id:
|
||||
return setup
|
||||
raise KeyError(setup_id)
|
||||
|
||||
@property
|
||||
def observatory_result_ids(self) -> frozenset[str]:
|
||||
return frozenset(
|
||||
result.result_id
|
||||
for setup in self.setups
|
||||
for result in setup.preserved_results
|
||||
if result.access == "observatory"
|
||||
)
|
||||
|
||||
def observatory_result_kind(self, result_id: str) -> str | None:
|
||||
for setup in self.setups:
|
||||
for result in setup.preserved_results:
|
||||
if result.access == "observatory" and result.result_id == result_id:
|
||||
return result.result_kind
|
||||
return None
|
||||
|
||||
|
||||
def _setup(value: object, *, repository_root: Path) -> LaboratorySetup:
|
||||
row = _object(value, "setup")
|
||||
_exact_keys(
|
||||
row,
|
||||
{
|
||||
"setup_id",
|
||||
"display_name",
|
||||
"description",
|
||||
"origin",
|
||||
"source",
|
||||
"run_definition",
|
||||
"executor",
|
||||
"preserved_results",
|
||||
"authority",
|
||||
},
|
||||
"setup",
|
||||
)
|
||||
origin = row["origin"]
|
||||
if not isinstance(origin, str) or origin not in (
|
||||
"archived-definition",
|
||||
"existing-result",
|
||||
):
|
||||
raise LaboratorySetupRegistryError("setup origin is invalid")
|
||||
source = _object(row["source"], "source")
|
||||
_exact_keys(source, {"session_id", "label", "required_modalities"}, "source")
|
||||
modalities = _text_array(source["required_modalities"], "required_modalities")
|
||||
if not set(modalities).issubset(_MODALITIES):
|
||||
raise LaboratorySetupRegistryError("setup modalities are invalid")
|
||||
definition = (
|
||||
None
|
||||
if row["run_definition"] is None
|
||||
else _definition(row["run_definition"], repository_root=repository_root)
|
||||
)
|
||||
if (origin == "archived-definition") != (definition is not None):
|
||||
raise LaboratorySetupRegistryError("setup origin and RunDefinition disagree")
|
||||
if row["authority"] != _AUTHORITY:
|
||||
raise LaboratorySetupRegistryError("setup authority must remain observation-only")
|
||||
results = row["preserved_results"]
|
||||
if not isinstance(results, list) or not results:
|
||||
raise LaboratorySetupRegistryError("preserved results must be non-empty")
|
||||
preserved = tuple(_preserved_result(item) for item in results)
|
||||
if len({item.result_id for item in preserved}) != len(preserved):
|
||||
raise LaboratorySetupRegistryError("preserved result IDs must be unique")
|
||||
return LaboratorySetup(
|
||||
setup_id=_identifier(row["setup_id"], "setup_id"),
|
||||
display_name=_text(row["display_name"], "display_name"),
|
||||
description=_text(row["description"], "description"),
|
||||
origin=origin,
|
||||
source_session_id=_text(source["session_id"], "source session_id"),
|
||||
source_label=_text(source["label"], "source label"),
|
||||
required_modalities=modalities,
|
||||
run_definition=definition,
|
||||
executor=_executor(row["executor"]),
|
||||
preserved_results=preserved,
|
||||
)
|
||||
|
||||
|
||||
def _definition(value: object, *, repository_root: Path) -> _RunDefinition:
|
||||
row = _object(value, "RunDefinition")
|
||||
_exact_keys(
|
||||
row,
|
||||
{"definition_id", "version", "work_id", "configuration"},
|
||||
"RunDefinition",
|
||||
)
|
||||
version = row["version"]
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||||
raise LaboratorySetupRegistryError("RunDefinition version is invalid")
|
||||
configuration = row["configuration"]
|
||||
if not isinstance(configuration, list) or not configuration:
|
||||
raise LaboratorySetupRegistryError("RunDefinition configuration is empty")
|
||||
references = tuple(
|
||||
_configuration(item, repository_root=repository_root) for item in configuration
|
||||
)
|
||||
if len({item.role for item in references}) != len(references):
|
||||
raise LaboratorySetupRegistryError("configuration roles must be unique")
|
||||
return _RunDefinition(
|
||||
definition_id=_identifier(row["definition_id"], "definition_id"),
|
||||
version=version,
|
||||
work_id=_identifier(row["work_id"], "work_id"),
|
||||
configuration=references,
|
||||
)
|
||||
|
||||
|
||||
def _configuration(value: object, *, repository_root: Path) -> _ConfigurationReference:
|
||||
row = _object(value, "configuration")
|
||||
_exact_keys(row, {"role", "path", "sha256"}, "configuration")
|
||||
relative = PurePosixPath(_text(row["path"], "configuration path"))
|
||||
if relative.is_absolute() or ".." in relative.parts:
|
||||
raise LaboratorySetupRegistryError("configuration path is unsafe")
|
||||
candidate = repository_root.joinpath(*relative.parts)
|
||||
if candidate.is_symlink() or not candidate.is_file():
|
||||
raise LaboratorySetupRegistryError("configuration file is unavailable")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if resolved.stat().st_size > _MAX_CONFIGURATION_BYTES:
|
||||
raise LaboratorySetupRegistryError("configuration file is too large")
|
||||
except OSError as exc:
|
||||
raise LaboratorySetupRegistryError("configuration file is unavailable") from exc
|
||||
if not resolved.is_relative_to(repository_root):
|
||||
raise LaboratorySetupRegistryError("configuration escaped repository root")
|
||||
digest = _digest(row["sha256"], "configuration sha256")
|
||||
if _file_sha256(resolved) != digest:
|
||||
raise LaboratorySetupRegistryError("configuration digest changed")
|
||||
return _ConfigurationReference(
|
||||
role=_identifier(row["role"], "configuration role"),
|
||||
path=relative,
|
||||
sha256=digest,
|
||||
)
|
||||
|
||||
|
||||
def _executor(value: object) -> _Executor:
|
||||
row = _object(value, "executor")
|
||||
_exact_keys(row, {"contour_id", "state", "reason_code", "reason"}, "executor")
|
||||
if row["state"] != "not-installed":
|
||||
raise LaboratorySetupRegistryError("only fail-closed executors are admitted in v1")
|
||||
return _Executor(
|
||||
contour_id=_identifier(row["contour_id"], "contour_id"),
|
||||
state="not-installed",
|
||||
reason_code=_identifier(row["reason_code"], "reason_code"),
|
||||
reason=_text(row["reason"], "executor reason"),
|
||||
)
|
||||
|
||||
|
||||
def _preserved_result(value: object) -> _PreservedResult:
|
||||
row = _object(value, "preserved result")
|
||||
_exact_keys(
|
||||
row,
|
||||
{"result_id", "result_kind", "relation", "access", "created_at_utc"},
|
||||
"preserved result",
|
||||
)
|
||||
result_id = _text(row["result_id"], "result_id")
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise LaboratorySetupRegistryError("result_id is invalid")
|
||||
access = row["access"]
|
||||
if not isinstance(access, str) or access not in (
|
||||
"legacy-lab",
|
||||
"observatory",
|
||||
"evidence-only",
|
||||
):
|
||||
raise LaboratorySetupRegistryError("result access is invalid")
|
||||
return _PreservedResult(
|
||||
result_id=result_id,
|
||||
result_kind=_identifier(row["result_kind"], "result_kind"),
|
||||
relation=_identifier(row["relation"], "result relation"),
|
||||
access=access,
|
||||
created_at_utc=_text(row["created_at_utc"], "created_at_utc"),
|
||||
)
|
||||
|
||||
|
||||
def _project_definition(
|
||||
setup: LaboratorySetup,
|
||||
definition: _RunDefinition | None,
|
||||
) -> dict[str, object] | None:
|
||||
if definition is None:
|
||||
return None
|
||||
identity = {
|
||||
"schema_version": "missioncore.observatory-run-definition/v1",
|
||||
"definition_id": definition.definition_id,
|
||||
"version": definition.version,
|
||||
"work_id": definition.work_id,
|
||||
"source": {
|
||||
"session_id": setup.source_session_id,
|
||||
"label": setup.source_label,
|
||||
"required_modalities": list(setup.required_modalities),
|
||||
},
|
||||
"configuration": [
|
||||
{"role": item.role, "sha256": item.sha256}
|
||||
for item in definition.configuration
|
||||
],
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
return {
|
||||
**identity,
|
||||
"definition_sha256": hashlib.sha256(_canonical_json(identity)).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(128 * 1024):
|
||||
digest.update(chunk)
|
||||
except OSError as exc:
|
||||
raise LaboratorySetupRegistryError("configuration file is unavailable") from exc
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _reason(code: str, message: str) -> dict[str, str]:
|
||||
return {"code": code, "message": message}
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise LaboratorySetupRegistryError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
|
||||
if set(value) != expected:
|
||||
raise LaboratorySetupRegistryError(f"{label} keys are invalid")
|
||||
|
||||
|
||||
def _text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip() or value != value.strip():
|
||||
raise LaboratorySetupRegistryError(f"{label} must be non-empty text")
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: object, label: str) -> str:
|
||||
text = _text(value, label)
|
||||
if _IDENTIFIER.fullmatch(text) is None:
|
||||
raise LaboratorySetupRegistryError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _digest(value: object, label: str) -> str:
|
||||
text = _text(value, label)
|
||||
if _SHA256.fullmatch(text) is None:
|
||||
raise LaboratorySetupRegistryError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _text_array(value: object, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise LaboratorySetupRegistryError(f"{label} must be a non-empty array")
|
||||
result = tuple(_text(item, label) for item in value)
|
||||
if len(result) != len(set(result)):
|
||||
raise LaboratorySetupRegistryError(f"{label} must contain unique values")
|
||||
return result
|
||||
+20
-1
@@ -36,6 +36,7 @@ from k1link.laboratory.m48_raw_evidence import (
|
||||
M48RawEvidenceError,
|
||||
M48RawEvidenceReader,
|
||||
)
|
||||
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedCameraFrameService,
|
||||
@@ -190,6 +191,18 @@ LABORATORY_RUNNER = LaboratoryRunner(
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
|
||||
)
|
||||
try:
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY = LaboratorySetupRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json",
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR = None
|
||||
except (LaboratorySetupRegistryError, OSError) as exc:
|
||||
# Observatory is an optional observation-only slice. Its configuration must
|
||||
# fail closed locally without preventing K1, Simulation or legacy LAB from
|
||||
# starting.
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY = None
|
||||
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR = str(exc)
|
||||
LABORATORY_EVIDENCE_REPORTS = LaboratoryEvidenceReportService(
|
||||
LABORATORY_EVIDENCE_REGISTRY,
|
||||
lambda: REPOSITORY_ROOT / ".runtime" / "compute-experiments",
|
||||
@@ -692,7 +705,13 @@ app.include_router(
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
app.include_router(build_observatory_router(session_store))
|
||||
app.include_router(
|
||||
build_observatory_router(
|
||||
session_store,
|
||||
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
|
||||
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
|
||||
)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.observatory import (
|
||||
LaboratorySetupRegistry,
|
||||
is_admitted_observatory_recorded_result,
|
||||
)
|
||||
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
|
||||
|
||||
OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||
@@ -13,6 +17,12 @@ OBSERVATORY_PROJECTION_SCHEMA: Literal[
|
||||
OBSERVATORY_RENAME_SCHEMA: Literal[
|
||||
"missioncore.observatory-lab-projection-rename/v1"
|
||||
] = "missioncore.observatory-lab-projection-rename/v1"
|
||||
OBSERVATORY_RUN_PREFLIGHT_REQUEST_SCHEMA: Literal[
|
||||
"missioncore.observatory-run-preflight-request/v1"
|
||||
] = "missioncore.observatory-run-preflight-request/v1"
|
||||
OBSERVATORY_RUN_PREFLIGHT_SCHEMA: Literal[
|
||||
"missioncore.observatory-run-preflight/v1"
|
||||
] = "missioncore.observatory-run-preflight/v1"
|
||||
|
||||
|
||||
class _StrictApiModel(BaseModel):
|
||||
@@ -32,11 +42,206 @@ class ObservatoryProjectionDocument(_StrictApiModel):
|
||||
display_name: str
|
||||
|
||||
|
||||
def build_observatory_router(store: SessionStore) -> APIRouter:
|
||||
class ObservatoryRunPreflightRequest(_StrictApiModel):
|
||||
schema_version: Literal["missioncore.observatory-run-preflight-request/v1"]
|
||||
source_session_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
)
|
||||
setup_id: str = Field(
|
||||
min_length=3,
|
||||
max_length=96,
|
||||
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
||||
)
|
||||
definition_sha256: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
def build_observatory_router(
|
||||
store: SessionStore,
|
||||
*,
|
||||
setup_registry: LaboratorySetupRegistry | None = None,
|
||||
setup_registry_error: str | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build bounded catalog-only mutations for typed Observatory projections."""
|
||||
|
||||
router = APIRouter(tags=["observatory"])
|
||||
|
||||
def source_summary(session_id: str):
|
||||
try:
|
||||
summary = store.get_session(session_id).summary
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Исходная сессия не найдена.") from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор исходной сессии.",
|
||||
) from exc
|
||||
except SessionIntegrityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Каталог исходной сессии нарушил контракт целостности.",
|
||||
) from exc
|
||||
if summary.lab is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Для запуска нужна исходная, а не лабораторная сессия.",
|
||||
)
|
||||
return summary
|
||||
|
||||
def available_observatory_results(source_session_id: str) -> frozenset[str]:
|
||||
if setup_registry is None:
|
||||
return frozenset()
|
||||
available: set[str] = set()
|
||||
for result_id in setup_registry.observatory_result_ids:
|
||||
expected_result_kind = setup_registry.observatory_result_kind(result_id)
|
||||
try:
|
||||
summary = store.get_session(result_id).summary
|
||||
except (SessionIntegrityError, SessionNotFoundError, ValueError):
|
||||
continue
|
||||
if expected_result_kind is not None and is_admitted_observatory_recorded_result(
|
||||
summary,
|
||||
expected_result_id=result_id,
|
||||
expected_source_session_id=source_session_id,
|
||||
expected_result_kind=expected_result_kind,
|
||||
):
|
||||
available.add(result_id)
|
||||
return frozenset(available)
|
||||
|
||||
if setup_registry is not None:
|
||||
|
||||
@router.get("/api/v1/observatory/laboratory-setups")
|
||||
def list_observatory_laboratory_setups(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
source = source_summary(source_session_id)
|
||||
return setup_registry.catalog(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_results(
|
||||
source_session_id
|
||||
),
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observatory/run-preflights")
|
||||
def preflight_observatory_run(
|
||||
request: ObservatoryRunPreflightRequest,
|
||||
) -> dict[str, object]:
|
||||
source = source_summary(request.source_session_id)
|
||||
try:
|
||||
setup_registry.setup(request.setup_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail="Сетап лаборатории не найден.") from exc
|
||||
catalog = setup_registry.catalog(
|
||||
source,
|
||||
available_observatory_result_ids=available_observatory_results(
|
||||
request.source_session_id
|
||||
),
|
||||
)
|
||||
setups = catalog["setups"]
|
||||
assert isinstance(setups, list)
|
||||
projected = next(
|
||||
item
|
||||
for item in setups
|
||||
if isinstance(item, dict) and item.get("setup_id") == request.setup_id
|
||||
)
|
||||
definition = projected["run_definition"]
|
||||
expected_digest = (
|
||||
definition.get("definition_sha256")
|
||||
if isinstance(definition, dict)
|
||||
else None
|
||||
)
|
||||
if request.definition_sha256 != expected_digest:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Идентичность RunDefinition изменилась; обновите каталог.",
|
||||
)
|
||||
compatibility = projected["compatibility"]
|
||||
preflight = projected["preflight"]
|
||||
executor = projected["executor"]
|
||||
assert isinstance(compatibility, dict)
|
||||
assert isinstance(preflight, dict)
|
||||
assert isinstance(executor, dict)
|
||||
compatible = compatibility.get("compatible") is True
|
||||
existing = preflight.get("outcome") == "existing"
|
||||
checks: list[dict[str, Any]] = [
|
||||
{
|
||||
"check_id": "source-compatibility",
|
||||
"outcome": "pass" if compatible else "fail",
|
||||
"reason_code": (
|
||||
"source-compatible" if compatible else "source-incompatible"
|
||||
),
|
||||
"message": (
|
||||
"Источник точно совместим с сохранённым сетапом."
|
||||
if compatible
|
||||
else str(preflight.get("reason"))
|
||||
),
|
||||
},
|
||||
{
|
||||
"check_id": "existing-result",
|
||||
"outcome": "pass" if existing else "not-applicable",
|
||||
"reason_code": (
|
||||
"exact-result-available" if existing else "exact-result-not-openable"
|
||||
),
|
||||
"message": str(preflight.get("reason")),
|
||||
},
|
||||
{
|
||||
"check_id": "executor",
|
||||
"outcome": "not-applicable" if existing else "fail",
|
||||
"reason_code": (
|
||||
"existing-result-does-not-require-executor"
|
||||
if existing
|
||||
else str(executor.get("reason_code"))
|
||||
),
|
||||
"message": (
|
||||
"Готовый результат открывается без повторного запуска Worker."
|
||||
if existing
|
||||
else str(executor.get("reason"))
|
||||
),
|
||||
},
|
||||
]
|
||||
return {
|
||||
"schema_version": OBSERVATORY_RUN_PREFLIGHT_SCHEMA,
|
||||
"source_session_id": request.source_session_id,
|
||||
"setup_id": request.setup_id,
|
||||
"definition_sha256": expected_digest,
|
||||
"outcome": "existing" if existing else "blocked",
|
||||
"submission_allowed": False,
|
||||
"checks": checks,
|
||||
"existing_result_ids": preflight.get("existing_result_ids", []),
|
||||
"executor": executor,
|
||||
"authority": catalog["authority"],
|
||||
}
|
||||
|
||||
elif setup_registry_error is not None:
|
||||
|
||||
@router.get("/api/v1/observatory/laboratory-setups")
|
||||
def unavailable_observatory_laboratory_setups(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
) -> None:
|
||||
del source_session_id
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observatory/run-preflights")
|
||||
def unavailable_observatory_run_preflight(
|
||||
request: ObservatoryRunPreflightRequest,
|
||||
) -> None:
|
||||
del request
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
|
||||
)
|
||||
|
||||
@router.patch(
|
||||
"/api/v1/observatory/lab-projections/{session_id}",
|
||||
response_model=ObservatoryProjectionDocument,
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
|
||||
from k1link.sessions import LabReplayCapability, LabSessionBinding, SessionNotFoundError
|
||||
from k1link.sessions.models import SessionSummary
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY_PATH = REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json"
|
||||
RAV00_SESSION_ID = "20260720T065719Z_viewer_live"
|
||||
RAV004_SESSION_ID = "20260828T130511Z_viewer_live"
|
||||
RAV004_RESULT_ID = (
|
||||
"lab-v1-vegetation-shadow-"
|
||||
"8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60"
|
||||
)
|
||||
|
||||
|
||||
def _registry() -> LaboratorySetupRegistry:
|
||||
return LaboratorySetupRegistry.from_file(
|
||||
REGISTRY_PATH,
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
)
|
||||
|
||||
|
||||
def _source(session_id: str, label: str) -> SessionSummary:
|
||||
return SessionSummary(
|
||||
session_id=session_id,
|
||||
display_name=label,
|
||||
status="ready",
|
||||
started_at_utc="2026-08-28T13:05:16Z",
|
||||
completed_at_utc="2026-08-28T13:18:45Z",
|
||||
duration_seconds=808.0,
|
||||
modalities=("point-cloud", "trajectory", "video"),
|
||||
source_count=3,
|
||||
total_bytes=1,
|
||||
replayable=True,
|
||||
origin="recorded",
|
||||
)
|
||||
|
||||
|
||||
def _rav004_projection() -> SessionSummary:
|
||||
evidence_identity = RAV004_RESULT_ID.removeprefix("lab-v1-vegetation-shadow-")
|
||||
capability = LabReplayCapability(
|
||||
schema_version="missioncore.observation-lab-replay-capability/v1",
|
||||
kind="canonical-recorded-rerun",
|
||||
viewer_profile="recorded-session",
|
||||
timeline="session_time",
|
||||
activation="explicit",
|
||||
commands_enabled=False,
|
||||
)
|
||||
binding = LabSessionBinding(
|
||||
session_id=RAV004_RESULT_ID,
|
||||
source_session_id=RAV004_SESSION_ID,
|
||||
lab_id="LAB V1",
|
||||
result_kind="recorded-perception-qualification",
|
||||
result_id=RAV004_RESULT_ID,
|
||||
source_result_id=(
|
||||
"lab-v1-vegetation-shadow-"
|
||||
"d179462134967ace1c5ebd6fbdbdd8659905d390484b9c01ea7930f083bb74d1"
|
||||
),
|
||||
config_sha256=None,
|
||||
run_created_at_utc="2026-08-29T18:05:11.329061Z",
|
||||
published_at_utc="2026-08-30T15:16:27.747Z",
|
||||
replay_capability=capability,
|
||||
provenance={
|
||||
"schema_version": "missioncore.canonical-recorded-lab-projection/v1",
|
||||
"evidence_identity_sha256": evidence_identity,
|
||||
"result_document_sha256": (
|
||||
"598a3d859fffcc7426e4a08b7ab4b59cbf09cad634ac0b618ff5845254e82b64"
|
||||
),
|
||||
"replay_capability": capability.as_dict(),
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
},
|
||||
"method": {
|
||||
"schema_version": "missioncore.laboratory-method/v1",
|
||||
"completeness": "legacy-partial",
|
||||
"execution_class": "ai-inference",
|
||||
"pipeline_id": (
|
||||
"ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||
),
|
||||
"components": [
|
||||
{
|
||||
"kind": "source",
|
||||
"name": "sealed full-route LAB result",
|
||||
"version": "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
"role": "immutable Session catalog projection",
|
||||
"identity_sha256": evidence_identity,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
return SessionSummary(
|
||||
session_id=RAV004_RESULT_ID,
|
||||
display_name="RAVNOVES004TREE · полный маршрут восприятия",
|
||||
status="ready",
|
||||
started_at_utc=binding.run_created_at_utc,
|
||||
completed_at_utc=binding.run_created_at_utc,
|
||||
duration_seconds=718.0,
|
||||
modalities=(),
|
||||
source_count=0,
|
||||
total_bytes=0,
|
||||
replayable=True,
|
||||
origin="missioncore.lab-instance/v1",
|
||||
lab=binding,
|
||||
)
|
||||
|
||||
|
||||
def test_repository_setup_registry_keeps_real_definition_and_pre_definition_result() -> None:
|
||||
registry = _registry()
|
||||
|
||||
assert [setup.setup_id for setup in registry.setups] == [
|
||||
"m49-tgs-full-shadow-v1",
|
||||
"lab-v1-ravnoves004tree-final",
|
||||
]
|
||||
rav00 = registry.catalog(_source(RAV00_SESSION_ID, "RAVNOVES00"))
|
||||
m49, current = rav00["setups"]
|
||||
assert m49["origin"] == "archived-definition"
|
||||
assert m49["compatibility"]["compatible"] is True
|
||||
assert m49["preflight"] == {
|
||||
"outcome": "blocked",
|
||||
"action": "open-legacy",
|
||||
"reason": "Точный результат сохранён в legacy LAB.",
|
||||
"submission_allowed": False,
|
||||
"existing_result_ids": [],
|
||||
}
|
||||
assert m49["run_definition"]["definition_sha256"]
|
||||
assert m49["run_definition"]["configuration"] == [
|
||||
{
|
||||
"role": "primary-profile",
|
||||
"sha256": (
|
||||
"c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
|
||||
),
|
||||
}
|
||||
]
|
||||
assert all("path" not in row for row in m49["run_definition"]["configuration"])
|
||||
assert current["compatibility"]["compatible"] is False
|
||||
|
||||
rav004 = registry.catalog(
|
||||
_source(RAV004_SESSION_ID, "RAVNOVES004TREE"),
|
||||
available_observatory_result_ids=frozenset({RAV004_RESULT_ID}),
|
||||
)
|
||||
existing = rav004["setups"][1]
|
||||
assert existing["origin"] == "existing-result"
|
||||
assert existing["run_definition"] is None
|
||||
assert existing["preflight"]["outcome"] == "existing"
|
||||
assert existing["preflight"]["action"] == "open-existing"
|
||||
assert existing["preflight"]["submission_allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "reason_code"),
|
||||
[
|
||||
(replace(_source(RAV00_SESSION_ID, "RAVNOVES00"), status="failed"), "source-not-ready"),
|
||||
(
|
||||
replace(_source(RAV00_SESSION_ID, "RAVNOVES00"), replayable=False),
|
||||
"source-not-replayable",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_setup_compatibility_requires_a_ready_replayable_source(
|
||||
source: SessionSummary,
|
||||
reason_code: str,
|
||||
) -> None:
|
||||
setup = _registry().catalog(source)["setups"][0]
|
||||
|
||||
assert setup["compatibility"]["compatible"] is False
|
||||
assert reason_code in {
|
||||
reason["code"] for reason in setup["compatibility"]["reasons"]
|
||||
}
|
||||
assert setup["preflight"]["outcome"] == "blocked"
|
||||
assert setup["preflight"]["submission_allowed"] is False
|
||||
|
||||
|
||||
def test_registry_fails_closed_when_referenced_configuration_digest_changes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
document = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
document["setups"][0]["run_definition"]["configuration"][0]["sha256"] = "0" * 64
|
||||
path = tmp_path / "setups.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
with pytest.raises(LaboratorySetupRegistryError, match="digest changed"):
|
||||
LaboratorySetupRegistry.from_file(path, repository_root=REPOSITORY_ROOT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_path", "invalid"),
|
||||
[
|
||||
(("setups", 0, "origin"), []),
|
||||
(("setups", 0, "preserved_results", 0, "access"), {}),
|
||||
],
|
||||
)
|
||||
def test_registry_normalizes_untrusted_json_types_to_registry_error(
|
||||
tmp_path: Path,
|
||||
field_path: tuple[str | int, ...],
|
||||
invalid: object,
|
||||
) -> None:
|
||||
document: object = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
target = document
|
||||
for part in field_path[:-1]:
|
||||
target = target[part] # type: ignore[index]
|
||||
target[field_path[-1]] = invalid # type: ignore[index]
|
||||
path = tmp_path / "setups.json"
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
|
||||
with pytest.raises(LaboratorySetupRegistryError):
|
||||
LaboratorySetupRegistry.from_file(path, repository_root=REPOSITORY_ROOT)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self) -> None:
|
||||
self.sessions = {
|
||||
RAV00_SESSION_ID: _source(RAV00_SESSION_ID, "RAVNOVES00"),
|
||||
RAV004_SESSION_ID: _source(RAV004_SESSION_ID, "RAVNOVES004TREE"),
|
||||
RAV004_RESULT_ID: _rav004_projection(),
|
||||
}
|
||||
|
||||
def get_session(self, session_id: str):
|
||||
try:
|
||||
summary = self.sessions[session_id]
|
||||
except KeyError as exc:
|
||||
raise SessionNotFoundError(session_id) from exc
|
||||
return SimpleNamespace(summary=summary)
|
||||
|
||||
|
||||
def test_observatory_setup_catalog_and_preflight_are_read_only_and_fail_closed() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(_Store(), setup_registry=_registry())) # type: ignore[arg-type]
|
||||
client = TestClient(app)
|
||||
|
||||
catalog = client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
assert catalog.status_code == 200
|
||||
existing = catalog.json()["setups"][1]
|
||||
assert existing["preflight"]["outcome"] == "existing"
|
||||
assert existing["run_definition"] is None
|
||||
|
||||
preflight = client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV004_SESSION_ID,
|
||||
"setup_id": existing["setup_id"],
|
||||
"definition_sha256": None,
|
||||
},
|
||||
)
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.json()["outcome"] == "existing"
|
||||
assert preflight.json()["submission_allowed"] is False
|
||||
assert preflight.json()["existing_result_ids"] == [RAV004_RESULT_ID]
|
||||
assert next(
|
||||
check
|
||||
for check in preflight.json()["checks"]
|
||||
if check["check_id"] == "executor"
|
||||
)["outcome"] == "not-applicable"
|
||||
|
||||
m49_catalog = client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV00_SESSION_ID},
|
||||
).json()
|
||||
m49 = m49_catalog["setups"][0]
|
||||
blocked = client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV00_SESSION_ID,
|
||||
"setup_id": m49["setup_id"],
|
||||
"definition_sha256": m49["run_definition"]["definition_sha256"],
|
||||
},
|
||||
)
|
||||
assert blocked.status_code == 200
|
||||
assert blocked.json()["outcome"] == "blocked"
|
||||
assert blocked.json()["submission_allowed"] is False
|
||||
assert blocked.json()["executor"]["state"] == "not-installed"
|
||||
|
||||
stale = client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV00_SESSION_ID,
|
||||
"setup_id": m49["setup_id"],
|
||||
"definition_sha256": "f" * 64,
|
||||
},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert client.post("/api/v1/observatory/runs", json={}).status_code == 404
|
||||
|
||||
|
||||
def test_existing_result_is_not_openable_when_its_sealed_contract_drifts() -> None:
|
||||
store = _Store()
|
||||
projection = store.sessions[RAV004_RESULT_ID]
|
||||
assert projection.lab is not None
|
||||
store.sessions[RAV004_RESULT_ID] = replace(
|
||||
projection,
|
||||
lab=replace(projection.lab, result_kind="unexpected-result-kind"),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(store, setup_registry=_registry())) # type: ignore[arg-type]
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
current = response.json()["setups"][1]
|
||||
assert current["preflight"]["outcome"] == "blocked"
|
||||
assert current["preflight"]["action"] == "blocked"
|
||||
assert current["preflight"]["existing_result_ids"] == []
|
||||
|
||||
|
||||
def test_existing_result_is_not_openable_without_canonical_provenance() -> None:
|
||||
store = _Store()
|
||||
projection = store.sessions[RAV004_RESULT_ID]
|
||||
assert projection.lab is not None
|
||||
store.sessions[RAV004_RESULT_ID] = replace(
|
||||
projection,
|
||||
lab=replace(projection.lab, provenance={}),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(store, setup_registry=_registry())) # type: ignore[arg-type]
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["setups"][1]["preflight"]["outcome"] == "blocked"
|
||||
|
||||
|
||||
def test_existing_result_is_not_openable_outside_capability_owned_origin() -> None:
|
||||
store = _Store()
|
||||
store.sessions[RAV004_RESULT_ID] = replace(
|
||||
store.sessions[RAV004_RESULT_ID],
|
||||
origin="laboratory",
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(build_observatory_router(store, setup_registry=_registry())) # type: ignore[arg-type]
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["setups"][1]["preflight"]["outcome"] == "blocked"
|
||||
|
||||
|
||||
def test_setup_routes_reject_unsafe_identifiers_and_isolate_registry_failure() -> None:
|
||||
normal = FastAPI()
|
||||
normal.include_router(build_observatory_router(_Store(), setup_registry=_registry())) # type: ignore[arg-type]
|
||||
normal_client = TestClient(normal, raise_server_exceptions=False)
|
||||
|
||||
assert normal_client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": "../bad"},
|
||||
).status_code == 422
|
||||
assert normal_client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": "../bad",
|
||||
"setup_id": "m49-tgs-full-shadow-v1",
|
||||
"definition_sha256": None,
|
||||
},
|
||||
).status_code == 422
|
||||
|
||||
unavailable = FastAPI()
|
||||
unavailable.include_router(
|
||||
build_observatory_router(
|
||||
_Store(), # type: ignore[arg-type]
|
||||
setup_registry_error="configuration digest changed",
|
||||
)
|
||||
)
|
||||
unavailable_client = TestClient(unavailable)
|
||||
assert unavailable_client.get(
|
||||
"/api/v1/observatory/laboratory-setups",
|
||||
params={"source_session_id": RAV004_SESSION_ID},
|
||||
).status_code == 503
|
||||
assert unavailable_client.post(
|
||||
"/api/v1/observatory/run-preflights",
|
||||
json={
|
||||
"schema_version": "missioncore.observatory-run-preflight-request/v1",
|
||||
"source_session_id": RAV004_SESSION_ID,
|
||||
"setup_id": "lab-v1-ravnoves004tree-final",
|
||||
"definition_sha256": None,
|
||||
},
|
||||
).status_code == 503
|
||||
Reference in New Issue
Block a user