fix(observatory): offer only uncalculated profiles without completion badges
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user