fix(observatory): offer only uncalculated profiles without completion badges

This commit is contained in:
DCCONSTRUCTIONS
2026-09-03 10:16:01 +03:00
parent c2710b55a7
commit e436fb56d4
14 changed files with 420 additions and 368 deletions
@@ -74,6 +74,16 @@ export interface ObservatoryLaboratorySetupCatalog {
readonly setups: readonly ObservatoryLaboratorySetup[];
}
// Cache identity is verified by the portable catalog decoder/server, never by a LAB label.
export function selectableObservatorySetups(catalog: ObservatoryLaboratorySetupCatalog | null) {
return catalog?.setups.filter((setup) => (
setup.origin === "portable-definition"
&& setup.runDefinition !== null
&& setup.compatibility.compatible
&& setup.preflight.outcome !== "existing"
)) ?? [];
}
export interface ObservatoryLaboratoryRunPreflight {
readonly sourceSessionId: string;
readonly setupId: string;
@@ -60,9 +60,11 @@ export async function fetchObservatoryRecordedJobs(
setupId: string,
{
signal,
definitionSha256,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
definitionSha256?: string;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<readonly ObservatoryRecordedJob[]> {
@@ -71,6 +73,7 @@ export async function fetchObservatoryRecordedJobs(
setup_id: setupId,
limit: "20",
});
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
method: "GET",
headers: { Accept: "application/json" },
@@ -84,6 +87,7 @@ export async function fetchObservatoryRecordedJobs(
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
));
}
@@ -121,9 +125,10 @@ export async function submitObservatoryRecordedJob(
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId) {
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
throw new ObservatoryRecordedJobContractError(
"Расчёт относится к другой сессии или сетапу.",
"Расчёт относится к другой сессии, сетапу или версии профиля.",
);
}
return job;
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservatoryLaboratorySetups,
fetchObservatoryPortableLaboratorySetups,
preflightObservatoryLaboratorySetup,
selectableObservatorySetups,
type ObservatoryLaboratoryRunPreflight,
type ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
@@ -25,6 +25,9 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
const requestSequence = useRef(0);
const preflightRequest = useRef<AbortController | null>(null);
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
const selectableSetups = useMemo(() => selectableObservatorySetups(activeCatalog), [activeCatalog]);
const selectedSetup = selectableSetups.find((setup) => setup.setupId === selectedSetupId) ?? null;
const selectedDefinitionSha256 = selectedSetup?.runDefinition?.definitionSha256 ?? null;
useEffect(() => {
preflightRequest.current?.abort();
@@ -42,64 +45,25 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
setError(null);
setPreflight({ kind: "idle" });
const legacyResult = settled(
fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }),
);
const portableResult = settled(
fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal }),
);
void Promise.all([legacyResult, portableResult])
.then(([legacy, portable]) => {
// Archived definitions belong to evidence, not to the calculation selector.
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
.then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
if (legacy.status === "fulfilled" && portable.status === "fulfilled") {
try {
publishSetupCatalog(
mergeSetupCatalogs(legacy.value, portable.value),
setCatalog,
setSelectedSetupId,
);
setError(null);
} catch (caught) {
publishSetupCatalog(legacy.value, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
caught,
"Portable-каталог профилей нарушил локальный контракт.",
));
}
} else if (
legacy.status === "rejected"
&& portable.status === "fulfilled"
) {
publishSetupCatalog(portable.value, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
legacy.reason,
"Архивный каталог сетапов недоступен.",
));
} else if (
legacy.status === "fulfilled"
&& portable.status === "rejected"
) {
publishSetupCatalog(legacy.value, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
portable.reason,
"Portable-каталог профилей недоступен.",
));
} else if (
legacy.status === "rejected"
&& portable.status === "rejected"
) {
setState("error");
setError(catalogErrorMessage(
portable.reason,
catalogErrorMessage(legacy.reason, "Каталог сетапов недоступен."),
));
return;
} else {
setState("error");
setError("Каталог сетапов вернул неизвестное состояние.");
return;
}
setCatalog(next);
const selectable = selectableObservatorySetups(next);
setSelectedSetupId((current) => (
selectable.some((setup) => setup.setupId === current)
? current
: selectable[0]?.setupId ?? ""
));
setState("ready");
})
.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]);
@@ -110,12 +74,7 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
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],
);
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
const selectSetup = useCallback((setupId: string) => {
preflightRequest.current?.abort();
@@ -160,9 +119,10 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
return {
catalog: activeCatalog,
selectableSetups,
state,
error,
selectedSetupId,
selectedSetupId: selectedSetup?.setupId ?? "",
selectedSetup,
selectSetup,
refresh,
@@ -170,59 +130,3 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
check,
};
}
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
?? next.setups.find((setup) => setup.compatibility.compatible)?.setupId
?? next.setups[0]?.setupId
?? "";
});
}
function catalogErrorMessage(caught: unknown, fallback: string): string {
return caught instanceof Error && caught.message.trim()
? caught.message
: fallback;
}
function settled<T>(promise: Promise<T>): Promise<PromiseSettledResult<T>> {
return promise.then(
(value) => ({ status: "fulfilled", value }),
(reason: unknown) => ({ status: "rejected", reason }),
);
}
function mergeSetupCatalogs(
legacy: ObservatoryLaboratorySetupCatalog,
portable: ObservatoryLaboratorySetupCatalog,
): ObservatoryLaboratorySetupCatalog {
if (legacy.sourceSessionId !== portable.sourceSessionId) {
throw new Error("Каталоги сетапов относятся к разным исходным сессиям.");
}
const portableSetupIds = new Set(portable.setups.map((setup) => setup.setupId));
const setups = [
...legacy.setups.filter((setup) => !portableSetupIds.has(setup.setupId)),
...portable.setups,
];
if (new Set(setups.map((setup) => setup.setupId)).size !== setups.length) {
throw new Error("Каталоги сетапов содержат повторяющиеся идентификаторы.");
}
return { sourceSessionId: legacy.sourceSessionId, setups };
}
export type ObservatoryLaboratorySetupsController = ReturnType<
typeof useObservatoryLaboratorySetups
>;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservatoryRecordedJobs,
@@ -16,60 +16,83 @@ type RecordedJobsState =
| "retrying-publication"
| "error";
interface JobSnapshot {
readonly selectionKey: string;
readonly jobs: readonly ObservatoryRecordedJob[];
readonly state: RecordedJobsState;
readonly error: string | null;
}
const EMPTY_JOBS = [] as const;
const OPEN_STATES = new Set([
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"reconciliation-required",
]);
const POLL_INTERVAL_MS = 1_500;
export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) {
const [jobs, setJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [state, setState] = useState<RecordedJobsState>("idle");
const [error, setError] = useState<string | null>(null);
export function useObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
definitionSha256: string,
) {
const selectionKey = JSON.stringify([sourceSessionId, setupId, definitionSha256]);
const [snapshot, setSnapshot] = useState<JobSnapshot>({
selectionKey: "", jobs: EMPTY_JOBS, state: "idle", error: null,
});
const [revision, setRevision] = useState(0);
const requestRef = useRef<AbortController | null>(null);
const requestSequence = useRef(0);
const idempotencyKeys = useRef(new Map<string, string>());
const selectionKey = `${sourceSessionId}\u0000${setupId}`;
const observedJobId = useRef<string | null>(null);
const hasSelection = Boolean(sourceSessionId && setupId && definitionSha256);
// A source/profile change must not display even one render of the previous queue.
const current = snapshot.selectionKey === selectionKey ? snapshot : null;
const jobs = current?.jobs ?? EMPTY_JOBS;
const state = current?.state ?? (hasSelection ? "loading" : "idle");
const error = current?.error ?? null;
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (!sourceSessionId || !setupId) {
setJobs([]);
setState("idle");
setError(null);
if (!hasSelection) {
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
return;
}
const sequence = ++requestSequence.current;
const request = new AbortController();
requestRef.current = request;
setState((current) => jobs.length > 0 && current !== "idle" ? "refreshing" : "loading");
setError(null);
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, { signal: request.signal })
.then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setJobs(next);
setState("ready");
})
.catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setState("error");
setError(caught instanceof Error && caught.message.trim()
setSnapshot({
selectionKey, jobs,
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
});
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
definitionSha256, signal: request.signal,
}).then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
const pending = next.find((job) => OPEN_STATES.has(job.state));
if (pending) observedJobId.current = pending.jobId;
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
}).catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Очередь расчётов недоступна.");
: "Очередь расчётов недоступна.",
});
}).finally(() => {
if (requestRef.current === request) requestRef.current = null;
});
return () => request.abort();
}, [revision, selectionKey]);
useEffect(() => () => requestRef.current?.abort(), []);
const latestJob = jobs[0] ?? null;
const activeJob = useMemo(
() => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null,
[jobs],
);
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
const publicationPending = jobs.some((job) => job.publication.state === "pending");
// Do not promote historical terminal logs to a new operator action's error.
const computationFailed = latestJob?.state === "failed"
&& latestJob.jobId === observedJobId.current;
useEffect(() => {
if (state !== "ready" || (!activeJob && !publicationPending)) return;
@@ -93,80 +116,76 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
readonly checkSha256: string;
} | null = null,
): Promise<ObservatoryRecordedJob | null> => {
if (!sourceSessionId || !setupId || state === "submitting") return null;
if (!hasSelection || state !== "ready"
|| portableBinding?.definitionSha256 !== definitionSha256
|| publicationPending || latestJob?.publication.state === "failed") return null;
if (activeJob) return activeJob;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
const key = idempotencyKeys.current.get(selectionKey)
?? createIdempotencyKey();
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
idempotencyKeys.current.set(selectionKey, key);
setState("submitting");
setError(null);
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
try {
const job = await submitObservatoryRecordedJob(
sourceSessionId,
setupId,
key,
portableBinding,
{ signal: request.signal },
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
);
if (request.signal.aborted || requestRef.current !== request) return null;
setJobs((current) => [job, ...current.filter((candidate) => candidate.jobId !== job.jobId)]);
setState("ready");
observedJobId.current = job.jobId;
setSnapshot({
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
state: "ready", error: null,
});
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось поставить расчёт в очередь.");
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось поставить расчёт в очередь.",
});
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [activeJob, selectionKey, setupId, sourceSessionId, state]);
}, [activeJob, definitionSha256, hasSelection, jobs, latestJob,
publicationPending, selectionKey, setupId, sourceSessionId, state]);
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
if (!latestJob || latestJob.publication.state !== "failed") return null;
if (state === "retrying-publication") return null;
if (!latestJob || latestJob.publication.state !== "failed"
|| state === "retrying-publication") return null;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
setState("retrying-publication");
setError(null);
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
try {
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
signal: request.signal,
});
if (request.signal.aborted || requestRef.current !== request) return null;
setJobs((current) => [
job,
...current.filter((candidate) => candidate.jobId !== job.jobId),
]);
setState("ready");
setSnapshot({
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
state: "ready", error: null,
});
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось повторить публикацию результата.");
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось повторить публикацию результата.",
});
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [latestJob, state]);
}, [jobs, latestJob, selectionKey, state]);
return {
jobs,
latestJob,
activeJob,
state,
error,
refresh,
submit,
retryPublication,
jobs, latestJob, activeJob, publicationPending, computationFailed,
state, error, refresh, submit, retryPublication,
};
}