feat(observatory): add laboratory setup preflight

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 22:21:58 +03:00
parent be83283ee0
commit 8d5aeb0533
15 changed files with 2491 additions and 9 deletions
@@ -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
>;
+150 -3
View File
@@ -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/);
});
+97
View File
@@ -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
}
}
]
}
+31
View File
@@ -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
+19
View File
@@ -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,
}
],
}
+524
View File
@@ -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
View File
@@ -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")
)
+208 -3
View File
@@ -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,
+404
View File
@@ -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