From d1b75efcea7fc212ed08328d9a465f3135750abb Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 31 Aug 2026 11:11:36 +0300 Subject: [PATCH] fix(observatory): fence portable catalog refresh --- .../portableLaboratorySetupDecoder.ts | 27 ++-------- .../useObservatoryLaboratorySetups.ts | 52 ++++++++++++++----- .../observatory/ObservatoryWorkspace.tsx | 9 +++- .../test/observatoryLaboratorySetups.test.mjs | 26 ++++++++-- .../test/observatoryWorkspace.test.mjs | 8 ++- 5 files changed, 80 insertions(+), 42 deletions(-) diff --git a/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts b/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts index 1d3b52c..89d278f 100644 --- a/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts +++ b/apps/control-station/src/core/observatory/portableLaboratorySetupDecoder.ts @@ -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(value: unknown, expected: T, label: string): T { if (value !== expected) { throw new ObservatoryPortableSetupDecodeError( diff --git a/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts b/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts index 3d08826..a8eebf3 100644 --- a/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts +++ b/apps/control-station/src/core/observatory/useObservatoryLaboratorySetups.ts @@ -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, diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index b331da4..9ec08cb 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -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 diff --git a/apps/control-station/test/observatoryLaboratorySetups.test.mjs b/apps/control-station/test/observatoryLaboratorySetups.test.mjs index 283889b..52e7baa 100644 --- a/apps/control-station/test/observatoryLaboratorySetups.test.mjs +++ b/apps/control-station/test/observatoryLaboratorySetups.test.mjs @@ -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({ diff --git a/apps/control-station/test/observatoryWorkspace.test.mjs b/apps/control-station/test/observatoryWorkspace.test.mjs index 88b2b3c..875179b 100644 --- a/apps/control-station/test/observatoryWorkspace.test.mjs +++ b/apps/control-station/test/observatoryWorkspace.test.mjs @@ -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: "Нужна сверка"/);