fix(observatory): fence portable catalog refresh

This commit is contained in:
DCCONSTRUCTIONS
2026-08-31 11:11:36 +03:00
parent c9302c78c2
commit d1b75efcea
5 changed files with 80 additions and 42 deletions
@@ -41,10 +41,11 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
decodePortableSourceRequirements(row.source_requirements);
const compatibility = record(row.source_compatibility, "portable source_compatibility");
const compatibilityKeys = compatibility.evidence === undefined
? ["compatible", "outcome", "reason"]
: ["compatible", "evidence", "outcome", "reason"];
exactKeys(compatibility, compatibilityKeys, "portable source_compatibility");
exactKeys(
compatibility,
["compatible", "outcome", "reason"],
"portable source_compatibility",
);
const compatible = boolean(compatibility.compatible, "portable compatible");
exact(
compatibility.outcome,
@@ -52,15 +53,6 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
"portable compatibility outcome",
);
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
if (compatibility.evidence !== undefined) {
const evidence = record(compatibility.evidence, "portable compatibility evidence");
exactKeys(evidence, [
"frame_count", "timeline_end_seconds", "timeline_start_seconds",
], "portable compatibility evidence");
positiveInteger(evidence.frame_count, "portable frame_count");
finiteNumber(evidence.timeline_start_seconds, "portable timeline_start_seconds");
finiteNumber(evidence.timeline_end_seconds, "portable timeline_end_seconds");
}
const executor = record(row.executor, "portable executor");
exactKeys(executor, ["contour_id", "ready", "reason", "state"], "portable executor");
@@ -258,15 +250,6 @@ function positiveInteger(value: unknown, label: string): number {
return Number(value);
}
function finiteNumber(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидалось конечное число.`,
);
}
return value;
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new ObservatoryPortableSetupDecodeError(
@@ -52,25 +52,37 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal })
.then(async (legacyCatalog) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
publishSetupCatalog(
legacyCatalog,
setCatalog,
setSelectedSetupId,
{ preserveUnknownSelection: true },
);
setState("ready");
const optionalPortable = await portableResult;
if (request.signal.aborted || requestSequence.current !== sequence) return;
if (optionalPortable.status === "fulfilled") {
publishSetupCatalog(
mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
setCatalog,
setSelectedSetupId,
);
setError(null);
try {
publishSetupCatalog(
mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
setCatalog,
setSelectedSetupId,
);
setError(null);
} catch (caught) {
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
caught,
"Portable-каталог LAB V1 нарушил локальный контракт.",
));
}
return;
}
setError(
optionalPortable.reason instanceof Error
&& optionalPortable.reason.message.trim()
? optionalPortable.reason.message
: "Portable-каталог LAB V1 недоступен.",
);
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
optionalPortable.reason,
"Portable-каталог LAB V1 недоступен.",
));
})
.catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
@@ -84,6 +96,12 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
useEffect(() => () => preflightRequest.current?.abort(), []);
useEffect(() => {
preflightRequest.current?.abort();
preflightRequest.current = null;
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
}, [selectedSetupId, sourceSessionId]);
const selectedSetup = useMemo(
() => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null,
[activeCatalog, selectedSetupId],
@@ -147,12 +165,14 @@ function publishSetupCatalog(
next: ObservatoryLaboratorySetupCatalog,
setCatalog: (catalog: ObservatoryLaboratorySetupCatalog) => void,
setSelectedSetupId: (update: (current: string) => string) => void,
{ preserveUnknownSelection = false }: { preserveUnknownSelection?: boolean } = {},
): void {
setCatalog(next);
setSelectedSetupId((current) => {
if (next.setups.some(
(setup) => setup.setupId === current && setup.compatibility.compatible,
)) return current;
if (preserveUnknownSelection && current) return current;
return next.setups.find(
(setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing",
)?.setupId
@@ -162,6 +182,12 @@ function publishSetupCatalog(
});
}
function catalogErrorMessage(caught: unknown, fallback: string): string {
return caught instanceof Error && caught.message.trim()
? caught.message
: fallback;
}
function mergeSetupCatalogs(
legacy: ObservatoryLaboratorySetupCatalog,
portable: ObservatoryLaboratorySetupCatalog,
@@ -220,9 +220,16 @@ export function ObservatoryWorkspace({
: "Несовместим с выбранной сессией",
})) ?? []
), [setupController.catalog]);
const runPreflight = setupController.preflight.kind === "ready"
const preflightCandidate = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const runPreflight = preflightCandidate
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
&& preflightCandidate.definitionSha256
=== setupController.selectedSetup?.runDefinition?.definitionSha256
? preflightCandidate
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
@@ -117,11 +117,6 @@ function portableSetup() {
outcome: "pass",
compatible: true,
reason: "Запись соответствует требованиям EoMT + DDRNet.",
evidence: {
frame_count: 6830,
timeline_start_seconds: 0,
timeline_end_seconds: 819,
},
},
executor: {
contour_id: "worker-006",
@@ -281,6 +276,27 @@ test("portable LAB V1 rejects an unbound existing result projection", async () =
);
});
test("portable LAB V1 rejects heavyweight compatibility evidence", async () => {
const heavyweight = portableSetup();
heavyweight.source_compatibility.evidence = {
frame_count: 6830,
timeline_start_seconds: 0,
timeline_end_seconds: 819,
};
await assert.rejects(
fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [heavyweight],
authority,
}), { status: 200 }),
}),
/неизвестные поля/,
);
});
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
@@ -240,8 +240,14 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
/"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/,
);
assert.doesNotMatch(setupHook, /Promise\.allSettled/);
assert.match(setupHook, /publishSetupCatalog\(legacyCatalog/);
assert.match(setupHook, /publishSetupCatalog\(\s*legacyCatalog/);
assert.match(setupHook, /preserveUnknownSelection: true/);
assert.match(setupHook, /selectedSetupId, sourceSessionId/);
assert.match(workspace, /Worker установлен, запуск закрыт/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
);
assert.match(workspace, /succeeded: \{ label: "Вычислено · ждёт публикации"/);
assert.match(workspace, /failed: \{ label: "Ошибка расчёта"/);
assert.match(workspace, /"reconciliation-required": \{ label: "Нужна сверка"/);