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); decodePortableSourceRequirements(row.source_requirements);
const compatibility = record(row.source_compatibility, "portable source_compatibility"); const compatibility = record(row.source_compatibility, "portable source_compatibility");
const compatibilityKeys = compatibility.evidence === undefined exactKeys(
? ["compatible", "outcome", "reason"] compatibility,
: ["compatible", "evidence", "outcome", "reason"]; ["compatible", "outcome", "reason"],
exactKeys(compatibility, compatibilityKeys, "portable source_compatibility"); "portable source_compatibility",
);
const compatible = boolean(compatibility.compatible, "portable compatible"); const compatible = boolean(compatibility.compatible, "portable compatible");
exact( exact(
compatibility.outcome, compatibility.outcome,
@@ -52,15 +53,6 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
"portable compatibility outcome", "portable compatibility outcome",
); );
const compatibilityReason = text(compatibility.reason, "portable compatibility reason"); 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"); const executor = record(row.executor, "portable executor");
exactKeys(executor, ["contour_id", "ready", "reason", "state"], "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); 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 { function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) { if (value !== expected) {
throw new ObservatoryPortableSetupDecodeError( throw new ObservatoryPortableSetupDecodeError(
@@ -52,25 +52,37 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }) void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal })
.then(async (legacyCatalog) => { .then(async (legacyCatalog) => {
if (request.signal.aborted || requestSequence.current !== sequence) return; if (request.signal.aborted || requestSequence.current !== sequence) return;
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId); publishSetupCatalog(
legacyCatalog,
setCatalog,
setSelectedSetupId,
{ preserveUnknownSelection: true },
);
setState("ready"); setState("ready");
const optionalPortable = await portableResult; const optionalPortable = await portableResult;
if (request.signal.aborted || requestSequence.current !== sequence) return; if (request.signal.aborted || requestSequence.current !== sequence) return;
if (optionalPortable.status === "fulfilled") { if (optionalPortable.status === "fulfilled") {
publishSetupCatalog( try {
mergeSetupCatalogs(legacyCatalog, optionalPortable.value), publishSetupCatalog(
setCatalog, mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
setSelectedSetupId, setCatalog,
); setSelectedSetupId,
setError(null); );
setError(null);
} catch (caught) {
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
caught,
"Portable-каталог LAB V1 нарушил локальный контракт.",
));
}
return; return;
} }
setError( publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
optionalPortable.reason instanceof Error setError(catalogErrorMessage(
&& optionalPortable.reason.message.trim() optionalPortable.reason,
? optionalPortable.reason.message "Portable-каталог LAB V1 недоступен.",
: "Portable-каталог LAB V1 недоступен.", ));
);
}) })
.catch((caught: unknown) => { .catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return; 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(), []);
useEffect(() => {
preflightRequest.current?.abort();
preflightRequest.current = null;
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
}, [selectedSetupId, sourceSessionId]);
const selectedSetup = useMemo( const selectedSetup = useMemo(
() => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null, () => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null,
[activeCatalog, selectedSetupId], [activeCatalog, selectedSetupId],
@@ -147,12 +165,14 @@ function publishSetupCatalog(
next: ObservatoryLaboratorySetupCatalog, next: ObservatoryLaboratorySetupCatalog,
setCatalog: (catalog: ObservatoryLaboratorySetupCatalog) => void, setCatalog: (catalog: ObservatoryLaboratorySetupCatalog) => void,
setSelectedSetupId: (update: (current: string) => string) => void, setSelectedSetupId: (update: (current: string) => string) => void,
{ preserveUnknownSelection = false }: { preserveUnknownSelection?: boolean } = {},
): void { ): void {
setCatalog(next); setCatalog(next);
setSelectedSetupId((current) => { setSelectedSetupId((current) => {
if (next.setups.some( if (next.setups.some(
(setup) => setup.setupId === current && setup.compatibility.compatible, (setup) => setup.setupId === current && setup.compatibility.compatible,
)) return current; )) return current;
if (preserveUnknownSelection && current) return current;
return next.setups.find( return next.setups.find(
(setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing", (setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing",
)?.setupId )?.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( function mergeSetupCatalogs(
legacy: ObservatoryLaboratorySetupCatalog, legacy: ObservatoryLaboratorySetupCatalog,
portable: ObservatoryLaboratorySetupCatalog, portable: ObservatoryLaboratorySetupCatalog,
@@ -220,9 +220,16 @@ export function ObservatoryWorkspace({
: "Несовместим с выбранной сессией", : "Несовместим с выбранной сессией",
})) ?? [] })) ?? []
), [setupController.catalog]); ), [setupController.catalog]);
const runPreflight = setupController.preflight.kind === "ready" const preflightCandidate = setupController.preflight.kind === "ready"
? setupController.preflight.value ? setupController.preflight.value
: null; : 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( const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible && setupController.selectedSetup.compatibility.compatible
@@ -117,11 +117,6 @@ function portableSetup() {
outcome: "pass", outcome: "pass",
compatible: true, compatible: true,
reason: "Запись соответствует требованиям EoMT + DDRNet.", reason: "Запись соответствует требованиям EoMT + DDRNet.",
evidence: {
frame_count: 6830,
timeline_start_seconds: 0,
timeline_end_seconds: 819,
},
}, },
executor: { executor: {
contour_id: "worker-006", 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 () => { test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", { const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({ 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: "Ждём подтверждения остановки"/, /"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/,
); );
assert.doesNotMatch(setupHook, /Promise\.allSettled/); 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, /Worker установлен, запуск закрыт/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
);
assert.match(workspace, /succeeded: \{ label: "Вычислено · ждёт публикации"/); assert.match(workspace, /succeeded: \{ label: "Вычислено · ждёт публикации"/);
assert.match(workspace, /failed: \{ label: "Ошибка расчёта"/); assert.match(workspace, /failed: \{ label: "Ошибка расчёта"/);
assert.match(workspace, /"reconciliation-required": \{ label: "Нужна сверка"/); assert.match(workspace, /"reconciliation-required": \{ label: "Нужна сверка"/);