fix(observatory): fence portable catalog refresh
This commit is contained in:
@@ -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") {
|
||||||
|
try {
|
||||||
publishSetupCatalog(
|
publishSetupCatalog(
|
||||||
mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
|
mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
|
||||||
setCatalog,
|
setCatalog,
|
||||||
setSelectedSetupId,
|
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: "Нужна сверка"/);
|
||||||
|
|||||||
Reference in New Issue
Block a user