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[];
|
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 {
|
export interface ObservatoryLaboratoryRunPreflight {
|
||||||
readonly sourceSessionId: string;
|
readonly sourceSessionId: string;
|
||||||
readonly setupId: string;
|
readonly setupId: string;
|
||||||
|
|||||||
@@ -60,9 +60,11 @@ export async function fetchObservatoryRecordedJobs(
|
|||||||
setupId: string,
|
setupId: string,
|
||||||
{
|
{
|
||||||
signal,
|
signal,
|
||||||
|
definitionSha256,
|
||||||
fetcher = globalThis.fetch,
|
fetcher = globalThis.fetch,
|
||||||
}: {
|
}: {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
definitionSha256?: string;
|
||||||
fetcher?: ObservatoryRecordedJobFetch;
|
fetcher?: ObservatoryRecordedJobFetch;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||||
@@ -71,6 +73,7 @@ export async function fetchObservatoryRecordedJobs(
|
|||||||
setup_id: setupId,
|
setup_id: setupId,
|
||||||
limit: "20",
|
limit: "20",
|
||||||
});
|
});
|
||||||
|
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
|
||||||
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
|
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
@@ -84,6 +87,7 @@ export async function fetchObservatoryRecordedJobs(
|
|||||||
observationAuthority(row.authority);
|
observationAuthority(row.authority);
|
||||||
return array(row.items, "items").map(decodeJob).filter((job) => (
|
return array(row.items, "items").map(decodeJob).filter((job) => (
|
||||||
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||||
|
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +125,10 @@ export async function submitObservatoryRecordedJob(
|
|||||||
const body = await responseBody(response);
|
const body = await responseBody(response);
|
||||||
if (!response.ok) throw apiError(body, response.status);
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
const job = decodeJob(body);
|
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(
|
throw new ObservatoryRecordedJobContractError(
|
||||||
"Расчёт относится к другой сессии или сетапу.",
|
"Расчёт относится к другой сессии, сетапу или версии профиля.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return job;
|
return job;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchObservatoryLaboratorySetups,
|
|
||||||
fetchObservatoryPortableLaboratorySetups,
|
fetchObservatoryPortableLaboratorySetups,
|
||||||
preflightObservatoryLaboratorySetup,
|
preflightObservatoryLaboratorySetup,
|
||||||
|
selectableObservatorySetups,
|
||||||
type ObservatoryLaboratoryRunPreflight,
|
type ObservatoryLaboratoryRunPreflight,
|
||||||
type ObservatoryLaboratorySetupCatalog,
|
type ObservatoryLaboratorySetupCatalog,
|
||||||
} from "./laboratorySetups";
|
} from "./laboratorySetups";
|
||||||
@@ -25,6 +25,9 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
|||||||
const requestSequence = useRef(0);
|
const requestSequence = useRef(0);
|
||||||
const preflightRequest = useRef<AbortController | null>(null);
|
const preflightRequest = useRef<AbortController | null>(null);
|
||||||
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : 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(() => {
|
useEffect(() => {
|
||||||
preflightRequest.current?.abort();
|
preflightRequest.current?.abort();
|
||||||
@@ -42,64 +45,25 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
|||||||
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
setPreflight({ kind: "idle" });
|
setPreflight({ kind: "idle" });
|
||||||
const legacyResult = settled(
|
// Archived definitions belong to evidence, not to the calculation selector.
|
||||||
fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }),
|
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||||
);
|
.then((next) => {
|
||||||
const portableResult = settled(
|
|
||||||
fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal }),
|
|
||||||
);
|
|
||||||
void Promise.all([legacyResult, portableResult])
|
|
||||||
.then(([legacy, portable]) => {
|
|
||||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||||
if (legacy.status === "fulfilled" && portable.status === "fulfilled") {
|
setCatalog(next);
|
||||||
try {
|
const selectable = selectableObservatorySetups(next);
|
||||||
publishSetupCatalog(
|
setSelectedSetupId((current) => (
|
||||||
mergeSetupCatalogs(legacy.value, portable.value),
|
selectable.some((setup) => setup.setupId === current)
|
||||||
setCatalog,
|
? current
|
||||||
setSelectedSetupId,
|
: selectable[0]?.setupId ?? ""
|
||||||
);
|
));
|
||||||
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;
|
|
||||||
}
|
|
||||||
setState("ready");
|
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();
|
return () => request.abort();
|
||||||
}, [revision, sourceSessionId]);
|
}, [revision, sourceSessionId]);
|
||||||
@@ -110,12 +74,7 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
|||||||
preflightRequest.current?.abort();
|
preflightRequest.current?.abort();
|
||||||
preflightRequest.current = null;
|
preflightRequest.current = null;
|
||||||
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
|
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
|
||||||
}, [selectedSetupId, sourceSessionId]);
|
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
|
||||||
|
|
||||||
const selectedSetup = useMemo(
|
|
||||||
() => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null,
|
|
||||||
[activeCatalog, selectedSetupId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectSetup = useCallback((setupId: string) => {
|
const selectSetup = useCallback((setupId: string) => {
|
||||||
preflightRequest.current?.abort();
|
preflightRequest.current?.abort();
|
||||||
@@ -160,9 +119,10 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
catalog: activeCatalog,
|
catalog: activeCatalog,
|
||||||
|
selectableSetups,
|
||||||
state,
|
state,
|
||||||
error,
|
error,
|
||||||
selectedSetupId,
|
selectedSetupId: selectedSetup?.setupId ?? "",
|
||||||
selectedSetup,
|
selectedSetup,
|
||||||
selectSetup,
|
selectSetup,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -170,59 +130,3 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
|||||||
check,
|
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 {
|
import {
|
||||||
fetchObservatoryRecordedJobs,
|
fetchObservatoryRecordedJobs,
|
||||||
@@ -16,60 +16,83 @@ type RecordedJobsState =
|
|||||||
| "retrying-publication"
|
| "retrying-publication"
|
||||||
| "error";
|
| "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([
|
const OPEN_STATES = new Set([
|
||||||
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||||
"reconciliation-required",
|
"reconciliation-required",
|
||||||
]);
|
]);
|
||||||
const POLL_INTERVAL_MS = 1_500;
|
const POLL_INTERVAL_MS = 1_500;
|
||||||
|
|
||||||
export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) {
|
export function useObservatoryRecordedJobs(
|
||||||
const [jobs, setJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
|
sourceSessionId: string,
|
||||||
const [state, setState] = useState<RecordedJobsState>("idle");
|
setupId: string,
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [revision, setRevision] = useState(0);
|
||||||
const requestRef = useRef<AbortController | null>(null);
|
const requestRef = useRef<AbortController | null>(null);
|
||||||
const requestSequence = useRef(0);
|
const requestSequence = useRef(0);
|
||||||
const idempotencyKeys = useRef(new Map<string, string>());
|
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(() => {
|
useEffect(() => {
|
||||||
requestRef.current?.abort();
|
requestRef.current?.abort();
|
||||||
requestRef.current = null;
|
requestRef.current = null;
|
||||||
if (!sourceSessionId || !setupId) {
|
if (!hasSelection) {
|
||||||
setJobs([]);
|
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
|
||||||
setState("idle");
|
|
||||||
setError(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sequence = ++requestSequence.current;
|
const sequence = ++requestSequence.current;
|
||||||
const request = new AbortController();
|
const request = new AbortController();
|
||||||
requestRef.current = request;
|
requestRef.current = request;
|
||||||
setState((current) => jobs.length > 0 && current !== "idle" ? "refreshing" : "loading");
|
setSnapshot({
|
||||||
setError(null);
|
selectionKey, jobs,
|
||||||
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, { signal: request.signal })
|
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
|
||||||
.then((next) => {
|
});
|
||||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
|
||||||
setJobs(next);
|
definitionSha256, signal: request.signal,
|
||||||
setState("ready");
|
}).then((next) => {
|
||||||
})
|
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||||
.catch((caught: unknown) => {
|
const pending = next.find((job) => OPEN_STATES.has(job.state));
|
||||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
if (pending) observedJobId.current = pending.jobId;
|
||||||
setState("error");
|
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
|
||||||
setError(caught instanceof Error && caught.message.trim()
|
}).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
|
? caught.message
|
||||||
: "Очередь расчётов недоступна.");
|
: "Очередь расчётов недоступна.",
|
||||||
});
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
if (requestRef.current === request) requestRef.current = null;
|
||||||
|
});
|
||||||
return () => request.abort();
|
return () => request.abort();
|
||||||
}, [revision, selectionKey]);
|
}, [revision, selectionKey]);
|
||||||
|
|
||||||
useEffect(() => () => requestRef.current?.abort(), []);
|
useEffect(() => () => requestRef.current?.abort(), []);
|
||||||
|
|
||||||
const latestJob = jobs[0] ?? null;
|
const latestJob = jobs[0] ?? null;
|
||||||
const activeJob = useMemo(
|
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
|
||||||
() => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null,
|
|
||||||
[jobs],
|
|
||||||
);
|
|
||||||
const publicationPending = jobs.some((job) => job.publication.state === "pending");
|
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(() => {
|
useEffect(() => {
|
||||||
if (state !== "ready" || (!activeJob && !publicationPending)) return;
|
if (state !== "ready" || (!activeJob && !publicationPending)) return;
|
||||||
@@ -93,80 +116,76 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
|
|||||||
readonly checkSha256: string;
|
readonly checkSha256: string;
|
||||||
} | null = null,
|
} | null = null,
|
||||||
): Promise<ObservatoryRecordedJob | 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;
|
if (activeJob) return activeJob;
|
||||||
requestRef.current?.abort();
|
requestRef.current?.abort();
|
||||||
const request = new AbortController();
|
const request = new AbortController();
|
||||||
requestRef.current = request;
|
requestRef.current = request;
|
||||||
const key = idempotencyKeys.current.get(selectionKey)
|
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
|
||||||
?? createIdempotencyKey();
|
|
||||||
idempotencyKeys.current.set(selectionKey, key);
|
idempotencyKeys.current.set(selectionKey, key);
|
||||||
setState("submitting");
|
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const job = await submitObservatoryRecordedJob(
|
const job = await submitObservatoryRecordedJob(
|
||||||
sourceSessionId,
|
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
|
||||||
setupId,
|
|
||||||
key,
|
|
||||||
portableBinding,
|
|
||||||
{ signal: request.signal },
|
|
||||||
);
|
);
|
||||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
setJobs((current) => [job, ...current.filter((candidate) => candidate.jobId !== job.jobId)]);
|
observedJobId.current = job.jobId;
|
||||||
setState("ready");
|
setSnapshot({
|
||||||
|
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||||
|
state: "ready", error: null,
|
||||||
|
});
|
||||||
return job;
|
return job;
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
setState("error");
|
setSnapshot({
|
||||||
setError(caught instanceof Error && caught.message.trim()
|
selectionKey, jobs, state: "error",
|
||||||
? caught.message
|
error: caught instanceof Error && caught.message.trim()
|
||||||
: "Не удалось поставить расчёт в очередь.");
|
? caught.message
|
||||||
|
: "Не удалось поставить расчёт в очередь.",
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (requestRef.current === request) requestRef.current = null;
|
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> => {
|
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
|
||||||
if (!latestJob || latestJob.publication.state !== "failed") return null;
|
if (!latestJob || latestJob.publication.state !== "failed"
|
||||||
if (state === "retrying-publication") return null;
|
|| state === "retrying-publication") return null;
|
||||||
requestRef.current?.abort();
|
requestRef.current?.abort();
|
||||||
const request = new AbortController();
|
const request = new AbortController();
|
||||||
requestRef.current = request;
|
requestRef.current = request;
|
||||||
setState("retrying-publication");
|
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
|
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
});
|
});
|
||||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
setJobs((current) => [
|
setSnapshot({
|
||||||
job,
|
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||||
...current.filter((candidate) => candidate.jobId !== job.jobId),
|
state: "ready", error: null,
|
||||||
]);
|
});
|
||||||
setState("ready");
|
|
||||||
return job;
|
return job;
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||||
setState("error");
|
setSnapshot({
|
||||||
setError(caught instanceof Error && caught.message.trim()
|
selectionKey, jobs, state: "error",
|
||||||
? caught.message
|
error: caught instanceof Error && caught.message.trim()
|
||||||
: "Не удалось повторить публикацию результата.");
|
? caught.message
|
||||||
|
: "Не удалось повторить публикацию результата.",
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (requestRef.current === request) requestRef.current = null;
|
if (requestRef.current === request) requestRef.current = null;
|
||||||
}
|
}
|
||||||
}, [latestJob, state]);
|
}, [jobs, latestJob, selectionKey, state]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobs,
|
jobs, latestJob, activeJob, publicationPending, computationFailed,
|
||||||
latestJob,
|
state, error, refresh, submit, retryPublication,
|
||||||
activeJob,
|
|
||||||
state,
|
|
||||||
error,
|
|
||||||
refresh,
|
|
||||||
submit,
|
|
||||||
retryPublication,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
container-name: observatory-workspace;
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-lead,
|
.observatory-lead,
|
||||||
@@ -306,13 +308,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-evidence__bounded-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
font-size: var(--nodedc-font-size-xs);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observatory-notice {
|
.observatory-notice {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
@@ -373,7 +368,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@container observatory-workspace (max-width: 920px) {
|
||||||
.observatory-lead,
|
.observatory-lead,
|
||||||
.observatory-catalog-bar,
|
.observatory-catalog-bar,
|
||||||
.observatory-catalog-bar__controls,
|
.observatory-catalog-bar__controls,
|
||||||
@@ -382,12 +377,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-replay__header,
|
|
||||||
.observatory-replay-state {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.observatory-catalog-bar__controls {
|
.observatory-catalog-bar__controls {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
}
|
}
|
||||||
@@ -401,5 +390,12 @@
|
|||||||
.observatory-catalog-bar__run {
|
.observatory-catalog-bar__run {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.observatory-replay__header,
|
||||||
|
.observatory-replay-state {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ import {
|
|||||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||||
import type { ObservatoryRecordedJobState } from "../../core/observatory/recordedJobs";
|
|
||||||
import type { WorkspaceDefinition } from "../../productModel";
|
import type { WorkspaceDefinition } from "../../productModel";
|
||||||
|
|
||||||
const MAX_PRESENTED_EVIDENCE = 6;
|
|
||||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||||
type ObservatoryRecordedRunReview = Awaited<
|
type ObservatoryRecordedRunReview = Awaited<
|
||||||
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
||||||
@@ -88,27 +86,6 @@ const modalityLabel: Record<string, string> = {
|
|||||||
telemetry: "Телеметрия",
|
telemetry: "Телеметрия",
|
||||||
};
|
};
|
||||||
|
|
||||||
const recordedJobStatus: Record<
|
|
||||||
ObservatoryRecordedJobState,
|
|
||||||
{
|
|
||||||
readonly label: string;
|
|
||||||
readonly tone: "success" | "accent" | "warning" | "danger" | "neutral";
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
accepted: { label: "Принят", tone: "neutral" },
|
|
||||||
queued: { label: "Ждёт Worker", tone: "neutral" },
|
|
||||||
claimed: { label: "Назначен Worker", tone: "accent" },
|
|
||||||
running: { label: "Выполняется", tone: "accent" },
|
|
||||||
paused: { label: "Пауза: live-поток", tone: "warning" },
|
|
||||||
"preemption-pending": {
|
|
||||||
label: "Ждём подтверждения остановки",
|
|
||||||
tone: "warning",
|
|
||||||
},
|
|
||||||
succeeded: { label: "Вычислено · ждёт публикации", tone: "warning" },
|
|
||||||
failed: { label: "Ошибка расчёта", tone: "danger" },
|
|
||||||
"reconciliation-required": { label: "Нужна сверка", tone: "warning" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function statusTone(
|
function statusTone(
|
||||||
status: ObservationSessionStatus,
|
status: ObservationSessionStatus,
|
||||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||||
@@ -135,15 +112,6 @@ function formatDuration(seconds: number): string {
|
|||||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function catalogStateLabel(
|
|
||||||
state: ReturnType<typeof useObservatoryCatalog>["state"],
|
|
||||||
): string {
|
|
||||||
if (state === "ready") return "Срез актуален";
|
|
||||||
if (state === "refreshing") return "Обновляем каталог";
|
|
||||||
if (state === "error") return "Каталог недоступен";
|
|
||||||
return "Читаем каталог";
|
|
||||||
}
|
|
||||||
|
|
||||||
function mutationErrorMessage(error: unknown): string {
|
function mutationErrorMessage(error: unknown): string {
|
||||||
return error instanceof Error && error.message.trim()
|
return error instanceof Error && error.message.trim()
|
||||||
? error.message
|
? error.message
|
||||||
@@ -166,6 +134,7 @@ export function ObservatoryWorkspace({
|
|||||||
const recordedJobsController = useObservatoryRecordedJobs(
|
const recordedJobsController = useObservatoryRecordedJobs(
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
setupController.selectedSetupId,
|
setupController.selectedSetupId,
|
||||||
|
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
|
||||||
);
|
);
|
||||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||||
@@ -202,30 +171,19 @@ export function ObservatoryWorkspace({
|
|||||||
const selectedSession = items.find(
|
const selectedSession = items.find(
|
||||||
(item) => item.source.id === selectedSessionId,
|
(item) => item.source.id === selectedSessionId,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
const presentedEvidence = selectedSession?.evidence.slice(
|
const presentedEvidence = selectedSession?.evidence ?? [];
|
||||||
0,
|
|
||||||
MAX_PRESENTED_EVIDENCE,
|
|
||||||
) ?? [];
|
|
||||||
const options = useMemo(() => items.map(({ source, evidence }) => ({
|
const options = useMemo(() => items.map(({ source, evidence }) => ({
|
||||||
value: source.id,
|
value: source.id,
|
||||||
label: source.label,
|
label: source.label,
|
||||||
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||||
})), [items]);
|
})), [items]);
|
||||||
const setupOptions = useMemo(() => (
|
const setupOptions = useMemo(() => (
|
||||||
setupController.catalog?.setups.map((setup) => ({
|
setupController.selectableSetups.map((setup) => ({
|
||||||
value: setup.setupId,
|
value: setup.setupId,
|
||||||
label: setup.displayName,
|
label: setup.displayName,
|
||||||
description: setup.compatibility.compatible
|
description: setup.description,
|
||||||
? setup.origin === "existing-result"
|
}))
|
||||||
? "Готовый результат"
|
), [setupController.selectableSetups]);
|
||||||
: setup.origin === "portable-definition"
|
|
||||||
? setup.executor.state === "ready"
|
|
||||||
? "Запись совместима · Worker готов к проверке"
|
|
||||||
: "Запись совместима · Worker не установлен"
|
|
||||||
: "Совместимый архивный сетап"
|
|
||||||
: "Несовместим с выбранной сессией",
|
|
||||||
})) ?? []
|
|
||||||
), [setupController.catalog]);
|
|
||||||
const preflightCandidate = setupController.preflight.kind === "ready"
|
const preflightCandidate = setupController.preflight.kind === "ready"
|
||||||
? setupController.preflight.value
|
? setupController.preflight.value
|
||||||
: null;
|
: null;
|
||||||
@@ -242,29 +200,29 @@ export function ObservatoryWorkspace({
|
|||||||
&& runPreflight?.outcome === "queueable"
|
&& runPreflight?.outcome === "queueable"
|
||||||
&& runPreflight.submissionAllowed,
|
&& runPreflight.submissionAllowed,
|
||||||
);
|
);
|
||||||
const presentedJob = recordedJobsController.activeJob
|
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
|
||||||
?? recordedJobsController.latestJob;
|
const publicationFailed = presentedJob?.publication.state === "failed";
|
||||||
const presentedJobStatus = presentedJob
|
const calculationPending = recordedJobsController.activeJob !== null
|
||||||
? presentedJob.state === "succeeded"
|
|| recordedJobsController.publicationPending
|
||||||
? presentedJob.publication.state === "published"
|
|
||||||
? { label: "Результат опубликован", tone: "success" as const }
|
|
||||||
: presentedJob.publication.state === "failed"
|
|
||||||
? { label: "Ошибка публикации", tone: "danger" as const }
|
|
||||||
: presentedJob.publication.state === "not-required"
|
|
||||||
? { label: "Расчёт завершён", tone: "success" as const }
|
|
||||||
: recordedJobStatus.succeeded
|
|
||||||
: recordedJobStatus[presentedJob.state]
|
|
||||||
: null;
|
|
||||||
const queueStatusError = setupController.preflight.kind === "error"
|
|
||||||
? setupController.preflight.message
|
|
||||||
: recordedJobsController.error;
|
|
||||||
const queueStateBusy = recordedJobsController.state === "loading"
|
|
||||||
|| recordedJobsController.state === "refreshing"
|
|
||||||
|| recordedJobsController.state === "submitting"
|
|| recordedJobsController.state === "submitting"
|
||||||
|| recordedJobsController.state === "retrying-publication";
|
|| recordedJobsController.state === "retrying-publication";
|
||||||
|
const showCalculate = setupController.selectedSetup !== null
|
||||||
|
&& !calculationPending && !publicationFailed;
|
||||||
const canSubmitRecordedJob = queueSubmissionAllowed
|
const canSubmitRecordedJob = queueSubmissionAllowed
|
||||||
|
&& setupController.state === "ready"
|
||||||
&& recordedJobsController.state === "ready"
|
&& recordedJobsController.state === "ready"
|
||||||
&& recordedJobsController.activeJob === null;
|
&& !calculationPending && !publicationFailed;
|
||||||
|
const queueStatusError = setupController.preflight.kind === "error"
|
||||||
|
? setupController.preflight.message
|
||||||
|
: recordedJobsController.error
|
||||||
|
?? (publicationFailed
|
||||||
|
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
|
||||||
|
: recordedJobsController.computationFailed
|
||||||
|
? "Не удалось выполнить расчёт. Можно повторить запуск."
|
||||||
|
: runPreflight?.outcome === "blocked"
|
||||||
|
? runPreflight.checks.filter((check) => check.outcome === "fail")
|
||||||
|
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
|
||||||
|
: null);
|
||||||
const initialLoading = !controller.catalog
|
const initialLoading = !controller.catalog
|
||||||
&& ["idle", "loading"].includes(controller.state);
|
&& ["idle", "loading"].includes(controller.state);
|
||||||
const unavailable = !controller.catalog && controller.state === "error";
|
const unavailable = !controller.catalog && controller.state === "error";
|
||||||
@@ -316,7 +274,8 @@ export function ObservatoryWorkspace({
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
}, [controller.catalog, mutationPending, mutationReconciliation]);
|
if (mutationReconciliation.kind === "delete") setupController.refresh();
|
||||||
|
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
|
||||||
|
|
||||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||||
const attempt = replayCoordinatorRef.current.begin();
|
const attempt = replayCoordinatorRef.current.begin();
|
||||||
@@ -417,6 +376,7 @@ export function ObservatoryWorkspace({
|
|||||||
}
|
}
|
||||||
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||||
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||||
|
setupController.refresh();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
void controller.refresh();
|
void controller.refresh();
|
||||||
@@ -437,7 +397,7 @@ export function ObservatoryWorkspace({
|
|||||||
} finally {
|
} finally {
|
||||||
setMutationPending(null);
|
setMutationPending(null);
|
||||||
}
|
}
|
||||||
}, [closeReplay, controller, deleteTarget, mutationPending, replay]);
|
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -454,11 +414,6 @@ export function ObservatoryWorkspace({
|
|||||||
визуализатора и без доступа к управлению аппаратом.
|
визуализатора и без доступа к управлению аппаратом.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge
|
|
||||||
tone={controller.state === "error" ? "danger" : controller.state === "ready" ? "success" : "neutral"}
|
|
||||||
>
|
|
||||||
{catalogStateLabel(controller.state)}
|
|
||||||
</StatusBadge>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
||||||
@@ -486,7 +441,7 @@ export function ObservatoryWorkspace({
|
|||||||
disabled={!selectedSessionId || setupOptions.length === 0}
|
disabled={!selectedSessionId || setupOptions.length === 0}
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder="Поиск по сетапам"
|
searchPlaceholder="Поиск по сетапам"
|
||||||
emptyLabel={setupController.state === "error" ? "Каталог сетапов недоступен" : "Сетап не найден"}
|
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
|
||||||
minMenuWidth={360}
|
minMenuWidth={360}
|
||||||
menuWidth={500}
|
menuWidth={500}
|
||||||
onChange={setupController.selectSetup}
|
onChange={setupController.selectSetup}
|
||||||
@@ -509,47 +464,15 @@ export function ObservatoryWorkspace({
|
|||||||
>
|
>
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<div className="observatory-catalog-bar__run" aria-live="polite">
|
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
||||||
{queueStatusError ? (
|
{calculationPending ? (
|
||||||
<StatusBadge tone="danger" title={queueStatusError}>
|
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
|
||||||
Очередь недоступна
|
|
||||||
</StatusBadge>
|
|
||||||
) : recordedJobsController.state === "submitting" ? (
|
|
||||||
<StatusBadge tone="neutral">Ставим в очередь</StatusBadge>
|
|
||||||
) : recordedJobsController.state === "retrying-publication" ? (
|
|
||||||
<StatusBadge tone="neutral">Повторяем публикацию</StatusBadge>
|
|
||||||
) : presentedJobStatus ? (
|
|
||||||
<StatusBadge
|
|
||||||
tone={presentedJobStatus.tone}
|
|
||||||
title={presentedJob?.publication.error
|
|
||||||
?? presentedJob?.terminalMessage
|
|
||||||
?? undefined}
|
|
||||||
>
|
|
||||||
{presentedJobStatus.label}
|
|
||||||
</StatusBadge>
|
|
||||||
) : setupController.preflight.kind === "checking" ? (
|
|
||||||
<StatusBadge tone="neutral">Проверяем сетап</StatusBadge>
|
|
||||||
) : queueStateBusy ? (
|
|
||||||
<StatusBadge tone="neutral">Читаем очередь</StatusBadge>
|
|
||||||
) : setupController.selectedSetup?.origin === "portable-definition"
|
|
||||||
&& runPreflight ? (
|
|
||||||
<StatusBadge
|
|
||||||
tone={setupController.selectedSetup.compatibility.compatible
|
|
||||||
? "warning"
|
|
||||||
: "danger"}
|
|
||||||
title={setupController.selectedSetup.preflight.reason}
|
|
||||||
>
|
|
||||||
{setupController.selectedSetup.compatibility.compatible
|
|
||||||
? setupController.selectedSetup.executor.state === "not-installed"
|
|
||||||
? "Worker-профиль не установлен"
|
|
||||||
: "Запуск профиля недоступен"
|
|
||||||
: "Запись несовместима"}
|
|
||||||
</StatusBadge>
|
|
||||||
) : null}
|
) : null}
|
||||||
{canSubmitRecordedJob ? (
|
{showCalculate ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
disabled={!canSubmitRecordedJob}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void recordedJobsController.submit(
|
void recordedJobsController.submit(
|
||||||
setupController.selectedSetup?.origin === "portable-definition"
|
setupController.selectedSetup?.origin === "portable-definition"
|
||||||
@@ -566,20 +489,26 @@ export function ObservatoryWorkspace({
|
|||||||
Рассчитать
|
Рассчитать
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{presentedJob?.publication.state === "failed"
|
|
||||||
&& recordedJobsController.state !== "retrying-publication" ? (
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => void recordedJobsController.retryPublication()}
|
|
||||||
>
|
|
||||||
Повторить публикацию
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
|
{queueStatusError ? (
|
||||||
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
|
<span className="observatory-notice__copy">{queueStatusError}</span>
|
||||||
|
{publicationFailed ? (
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={recordedJobsController.state === "retrying-publication"}
|
||||||
|
onClick={() => void recordedJobsController.retryPublication()}
|
||||||
|
>
|
||||||
|
Повторить публикацию
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</GlassSurface>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{controller.error && controller.catalog ? (
|
{controller.error && controller.catalog ? (
|
||||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
|
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
|
||||||
@@ -588,9 +517,8 @@ export function ObservatoryWorkspace({
|
|||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{setupController.error && setupController.catalog ? (
|
{setupController.error ? (
|
||||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
<StatusBadge tone="warning">Показан последний каталог сетапов</StatusBadge>
|
|
||||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
@@ -700,9 +628,6 @@ export function ObservatoryWorkspace({
|
|||||||
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{evidence.recordedRun ? (
|
|
||||||
<StatusBadge tone="accent">Записанный разбор</StatusBadge>
|
|
||||||
) : null}
|
|
||||||
<div className="observatory-evidence-card__actions">
|
<div className="observatory-evidence-card__actions">
|
||||||
{evidence.recordedRun ? (
|
{evidence.recordedRun ? (
|
||||||
<>
|
<>
|
||||||
@@ -748,12 +673,6 @@ export function ObservatoryWorkspace({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedSession.evidence.length > presentedEvidence.length ? (
|
|
||||||
<p className="observatory-evidence__bounded-note">
|
|
||||||
Показаны {presentedEvidence.length} последних из {selectedSession.evidence.length}
|
|
||||||
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import React from "react";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let useObservatoryRecordedJobs;
|
||||||
|
let useObservatoryLaboratorySetups;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom", logLevel: "silent", server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({ useObservatoryRecordedJobs } = await server.ssrLoadModule("/src/core/observatory/useObservatoryRecordedJobs.ts"));
|
||||||
|
({ useObservatoryLaboratorySetups } = await server.ssrLoadModule("/src/core/observatory/useObservatoryLaboratorySetups.ts"));
|
||||||
|
});
|
||||||
|
after(async () => { await server?.close(); });
|
||||||
|
|
||||||
|
// Deliberately inspect the render BEFORE effects/cleanup: stale state must already be hidden.
|
||||||
|
function renderBeforeEffects(hook, args, stateSlots) {
|
||||||
|
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||||
|
const previous = internals.H;
|
||||||
|
let stateIndex = 0;
|
||||||
|
internals.H = {
|
||||||
|
useState: (initial) => [
|
||||||
|
stateIndex < stateSlots.length ? stateSlots[stateIndex++]
|
||||||
|
: typeof initial === "function" ? initial() : initial,
|
||||||
|
() => {},
|
||||||
|
],
|
||||||
|
useRef: (value) => ({ current: value }),
|
||||||
|
useMemo: (factory) => factory(),
|
||||||
|
useCallback: (fn) => fn,
|
||||||
|
useEffect: () => {},
|
||||||
|
};
|
||||||
|
try { return hook(...args); } finally { internals.H = previous; }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("queue data and errors cannot survive a source/setup/version change for one render", () => {
|
||||||
|
const original = ["source-a", "profile-a", "a".repeat(64)];
|
||||||
|
const activeJob = { state: "running", publication: { state: "not-required" } };
|
||||||
|
const snapshot = {
|
||||||
|
selectionKey: JSON.stringify(original), jobs: [activeJob], state: "ready", error: "old error",
|
||||||
|
};
|
||||||
|
assert.equal(renderBeforeEffects(useObservatoryRecordedJobs, original, [snapshot]).activeJob, activeJob);
|
||||||
|
for (const next of [
|
||||||
|
["source-b", original[1], original[2]],
|
||||||
|
[original[0], "profile-b", original[2]],
|
||||||
|
[original[0], original[1], "b".repeat(64)],
|
||||||
|
["", "", ""],
|
||||||
|
]) {
|
||||||
|
const view = renderBeforeEffects(useObservatoryRecordedJobs, next, [snapshot]);
|
||||||
|
assert.deepEqual(view.jobs, []);
|
||||||
|
assert.equal(view.activeJob, null);
|
||||||
|
assert.equal(view.latestJob, null);
|
||||||
|
assert.equal(view.error, null);
|
||||||
|
assert.notEqual(view.state, "ready");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("published, incompatible and previous-source profiles have no effective selection", () => {
|
||||||
|
const profile = {
|
||||||
|
setupId: "profile-a", origin: "portable-definition",
|
||||||
|
compatibility: { compatible: true },
|
||||||
|
runDefinition: { definitionSha256: "a".repeat(64) },
|
||||||
|
preflight: { outcome: "ready" },
|
||||||
|
};
|
||||||
|
function render(sourceId, setups) {
|
||||||
|
return renderBeforeEffects(useObservatoryLaboratorySetups, [sourceId], [
|
||||||
|
{ sourceSessionId: "source-a", setups }, "ready", null, "profile-a", 0, { kind: "idle" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
assert.equal(render("source-a", [profile]).selectedSetupId, "profile-a");
|
||||||
|
for (const view of [
|
||||||
|
render("source-b", [profile]),
|
||||||
|
render("source-a", [{ ...profile, preflight: { outcome: "existing" } }]),
|
||||||
|
render("source-a", [{ ...profile, compatibility: { compatible: false } }]),
|
||||||
|
render("source-a", []),
|
||||||
|
]) {
|
||||||
|
assert.equal(view.selectedSetupId, "");
|
||||||
|
assert.equal(view.selectedSetup, null);
|
||||||
|
assert.deepEqual(view.selectableSetups, []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("historical failures are not presented as errors of a new operator action", () => {
|
||||||
|
const selection = ["source-a", "profile-a", "a".repeat(64)];
|
||||||
|
const snapshot = {
|
||||||
|
selectionKey: JSON.stringify(selection),
|
||||||
|
jobs: [{ jobId: "old-run", state: "failed", publication: { state: "not-required" } }],
|
||||||
|
state: "ready", error: null,
|
||||||
|
};
|
||||||
|
const view = renderBeforeEffects(useObservatoryRecordedJobs, selection, [snapshot]);
|
||||||
|
assert.equal(view.computationFailed, false);
|
||||||
|
assert.equal(view.error, null);
|
||||||
|
assert.equal(view.activeJob, null);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ let server;
|
|||||||
let fetchObservatoryLaboratorySetups;
|
let fetchObservatoryLaboratorySetups;
|
||||||
let fetchObservatoryPortableLaboratorySetups;
|
let fetchObservatoryPortableLaboratorySetups;
|
||||||
let preflightObservatoryLaboratorySetup;
|
let preflightObservatoryLaboratorySetup;
|
||||||
|
let selectableObservatorySetups;
|
||||||
let ObservatoryLaboratorySetupContractError;
|
let ObservatoryLaboratorySetupContractError;
|
||||||
|
|
||||||
const authority = {
|
const authority = {
|
||||||
@@ -168,6 +169,7 @@ before(async () => {
|
|||||||
fetchObservatoryLaboratorySetups,
|
fetchObservatoryLaboratorySetups,
|
||||||
fetchObservatoryPortableLaboratorySetups,
|
fetchObservatoryPortableLaboratorySetups,
|
||||||
preflightObservatoryLaboratorySetup,
|
preflightObservatoryLaboratorySetup,
|
||||||
|
selectableObservatorySetups,
|
||||||
ObservatoryLaboratorySetupContractError,
|
ObservatoryLaboratorySetupContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
||||||
});
|
});
|
||||||
@@ -396,6 +398,38 @@ test("portable exact cached result is readable without an installed executor", a
|
|||||||
assert.equal(setup.preservedResults[0].access, "observatory");
|
assert.equal(setup.preservedResults[0].access, "observatory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("selector contains only compatible uncalculated portable definitions", async () => {
|
||||||
|
const uncached = (await fetchCachedPortable(portableSetup())).setups[0];
|
||||||
|
const cached = (await fetchCachedPortable(cachedPortableSetup())).setups[0];
|
||||||
|
const currentVersion = {
|
||||||
|
...uncached,
|
||||||
|
runDefinition: { ...uncached.runDefinition, version: 2, definitionSha256: "f".repeat(64) },
|
||||||
|
};
|
||||||
|
const catalog = (setups) => ({ sourceSessionId: "source-a", setups });
|
||||||
|
assert.deepEqual(selectableObservatorySetups(null), []);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([])), []);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([cached])), []);
|
||||||
|
// Same display label and setup ID, but no verified cache hit for the new definition.
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([currentVersion])), [currentVersion]);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([
|
||||||
|
cached,
|
||||||
|
{ ...uncached, setupId: "remaining-profile" },
|
||||||
|
])).map((s) => s.setupId), ["remaining-profile"]);
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([
|
||||||
|
{ ...uncached, compatibility: { compatible: false, reasons: [] } },
|
||||||
|
{ ...uncached, origin: "archived-definition" },
|
||||||
|
{ ...uncached, origin: "existing-result" },
|
||||||
|
{ ...uncached, runDefinition: null },
|
||||||
|
])), []);
|
||||||
|
// Worker availability does not masquerade as completed computation.
|
||||||
|
assert.equal(uncached.executor.state, "not-installed");
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([uncached])), [uncached]);
|
||||||
|
// A missing/deleted/unpublished projection is not a completed result.
|
||||||
|
assert.deepEqual(selectableObservatorySetups(catalog([{
|
||||||
|
...uncached, preservedResults: cached.preservedResults,
|
||||||
|
}])).map((s) => s.setupId), [uncached.setupId]);
|
||||||
|
});
|
||||||
|
|
||||||
for (const [label, change] of [
|
for (const [label, change] of [
|
||||||
["another source", (s) => { s.existing_results[0].identity.source_session_id = "source-b"; }],
|
["another source", (s) => { s.existing_results[0].identity.source_session_id = "source-b"; }],
|
||||||
["another setup", (s) => { s.existing_results[0].identity.setup_id = "another-profile"; }],
|
["another setup", (s) => { s.existing_results[0].identity.setup_id = "another-profile"; }],
|
||||||
|
|||||||
@@ -245,3 +245,33 @@ test("publication retry never submits a second compute request", async () => {
|
|||||||
assert.equal(request.init.body, undefined);
|
assert.equal(request.init.body, undefined);
|
||||||
assert.equal(retried.publication.state, "published");
|
assert.equal(retried.publication.state, "published");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("queue query and response are fenced to the selected definition", async () => {
|
||||||
|
const oldVersion = job("succeeded");
|
||||||
|
oldVersion.setup.definition_sha256 = "f".repeat(64);
|
||||||
|
const otherSource = job("running");
|
||||||
|
otherSource.source.session_id = "source-b";
|
||||||
|
const otherSetup = job("running");
|
||||||
|
otherSetup.setup.setup_id = "other-setup";
|
||||||
|
let url;
|
||||||
|
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||||
|
definitionSha256: "8".repeat(64),
|
||||||
|
fetcher: async (input) => {
|
||||||
|
url = new URL(String(input), "http://localhost");
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||||
|
items: [oldVersion, otherSource, otherSetup, job("queued")], authority,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(url.searchParams.get("definition_sha256"), "8".repeat(64));
|
||||||
|
assert.deepEqual(jobs.map((j) => j.state), ["queued"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portable submit rejects a successful response for an old definition", async () => {
|
||||||
|
await assert.rejects(submitObservatoryRecordedJob(
|
||||||
|
"source-a", "m49-tgs", "test-request",
|
||||||
|
{ definitionSha256: "f".repeat(64), checkSha256: "e".repeat(64) },
|
||||||
|
{ fetcher: async () => new Response(JSON.stringify(job("succeeded"))) },
|
||||||
|
), ObservatoryRecordedJobContractError);
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
assert.match(workspace, /useObservatoryCatalog/);
|
assert.match(workspace, /useObservatoryCatalog/);
|
||||||
assert.match(workspace, /Связанных результатов нет/);
|
assert.match(workspace, /Связанных результатов нет/);
|
||||||
assert.match(workspace, /не является выводом о качестве/);
|
assert.match(workspace, /не является выводом о качестве/);
|
||||||
assert.match(workspace, /\.evidence\.slice\([\s\S]*MAX_PRESENTED_EVIDENCE/);
|
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
|
||||||
assert.match(workspace, /Полный архив остаётся в legacy LAB/);
|
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
|
||||||
assert.match(workspace, /Полнота исторических/);
|
assert.match(workspace, /Полнота исторических/);
|
||||||
assert.match(workspace, /вне текущего загруженного среза/);
|
assert.match(workspace, /вне текущего загруженного среза/);
|
||||||
assert.match(workspace, /observatory-notice__copy/);
|
assert.match(workspace, /observatory-notice__copy/);
|
||||||
@@ -136,7 +136,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
|
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
|
||||||
assert.match(
|
assert.match(
|
||||||
styles,
|
styles,
|
||||||
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
styles,
|
styles,
|
||||||
@@ -229,7 +229,7 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
|
|||||||
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
||||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||||
assert.match(workspace, /Показан последний каталог сетапов/);
|
assert.match(workspace, /setupController\.error \? \(/);
|
||||||
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
||||||
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
||||||
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
||||||
@@ -240,44 +240,31 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
workspace,
|
workspace,
|
||||||
/canSubmitRecordedJob \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
||||||
);
|
);
|
||||||
assert.match(workspace, /accepted: \{ label: "Принят"/);
|
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
|
||||||
assert.match(workspace, /queued: \{ label: "Ждёт Worker"/);
|
assert.match(workspace, /setupController\.selectableSetups\.map/);
|
||||||
assert.match(workspace, /claimed: \{ label: "Назначен Worker"/);
|
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
|
||||||
assert.match(workspace, /running: \{ label: "Выполняется"/);
|
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
|
||||||
assert.match(workspace, /paused: \{ label: "Пауза: live-поток"/);
|
assert.match(workspace, /aria-busy=\{calculationPending\}/);
|
||||||
assert.match(
|
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
|
||||||
workspace,
|
assert.doesNotMatch(runBar, /StatusBadge/);
|
||||||
/"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/,
|
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
|
||||||
);
|
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
|
||||||
assert.match(setupHook, /Promise\.all\(\[legacyResult, portableResult\]\)/);
|
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
|
||||||
assert.match(
|
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
|
||||||
setupHook,
|
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
|
||||||
/legacy\.status === "rejected"[\s\S]*portable\.status === "fulfilled"[\s\S]*publishSetupCatalog\(portable\.value/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
setupHook,
|
|
||||||
/legacy\.status === "fulfilled"[\s\S]*portable\.status === "rejected"[\s\S]*publishSetupCatalog\(legacy\.value/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
setupHook,
|
|
||||||
/portableSetupIds[\s\S]*legacy\.setups\.filter\([\s\S]*!portableSetupIds\.has\(setup\.setupId\)[\s\S]*\.\.\.portable\.setups/,
|
|
||||||
);
|
|
||||||
assert.match(setupHook, /selectedSetupId, sourceSessionId/);
|
|
||||||
assert.match(workspace, /Worker готов к проверке/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
workspace,
|
workspace,
|
||||||
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
||||||
);
|
);
|
||||||
assert.match(workspace, /succeeded: \{ label: "Вычислено · ждёт публикации"/);
|
|
||||||
assert.match(workspace, /failed: \{ label: "Ошибка расчёта"/);
|
|
||||||
assert.match(workspace, /"reconciliation-required": \{ label: "Нужна сверка"/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
jobsHook,
|
jobsHook,
|
||||||
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
||||||
);
|
);
|
||||||
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
|
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
|
||||||
|
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
|
||||||
|
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
|
||||||
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
|
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
|
||||||
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
|
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
|
||||||
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
|
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
|
||||||
@@ -295,7 +282,7 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
styles,
|
styles,
|
||||||
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
|
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
|
||||||
);
|
);
|
||||||
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
||||||
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
||||||
|
|||||||
@@ -2080,12 +2080,15 @@ class ObservatoryRecordedJobQueue:
|
|||||||
*,
|
*,
|
||||||
source_session_id: str | None = None,
|
source_session_id: str | None = None,
|
||||||
setup_id: str | None = None,
|
setup_id: str | None = None,
|
||||||
|
definition_sha256: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> tuple[ObservatoryRecordedJob, ...]:
|
) -> tuple[ObservatoryRecordedJob, ...]:
|
||||||
if source_session_id is not None:
|
if source_session_id is not None:
|
||||||
_validate_pattern(source_session_id, _SESSION_ID, "source session id")
|
_validate_pattern(source_session_id, _SESSION_ID, "source session id")
|
||||||
if setup_id is not None:
|
if setup_id is not None:
|
||||||
_validate_pattern(setup_id, _IDENTIFIER, "setup id")
|
_validate_pattern(setup_id, _IDENTIFIER, "setup id")
|
||||||
|
if definition_sha256 is not None:
|
||||||
|
_validate_pattern(definition_sha256, _SHA256, "definition sha256")
|
||||||
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 500:
|
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 500:
|
||||||
raise ValueError("recorded-job list limit is invalid")
|
raise ValueError("recorded-job list limit is invalid")
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
@@ -2096,6 +2099,9 @@ class ObservatoryRecordedJobQueue:
|
|||||||
if setup_id is not None:
|
if setup_id is not None:
|
||||||
clauses.append("setup_id = ?")
|
clauses.append("setup_id = ?")
|
||||||
parameters.append(setup_id)
|
parameters.append(setup_id)
|
||||||
|
if definition_sha256 is not None:
|
||||||
|
clauses.append("definition_sha256 = ?")
|
||||||
|
parameters.append(definition_sha256)
|
||||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
parameters.append(limit)
|
parameters.append(limit)
|
||||||
with self._read_connection() as connection:
|
with self._read_connection() as connection:
|
||||||
|
|||||||
@@ -1131,11 +1131,13 @@ def build_observatory_router(
|
|||||||
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
pattern=r"^[a-z][a-z0-9-]{2,95}$",
|
||||||
),
|
),
|
||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
definition_sha256: str | None = Query(default=None, pattern=r"^[a-f0-9]{64}$"),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
jobs = recorded_job_queue.list_jobs(
|
jobs = recorded_job_queue.list_jobs(
|
||||||
source_session_id=source_session_id,
|
source_session_id=source_session_id,
|
||||||
setup_id=setup_id,
|
setup_id=setup_id,
|
||||||
|
definition_sha256=definition_sha256,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
except (ObservatoryRecordedQueueError, ValueError) as exc:
|
except (ObservatoryRecordedQueueError, ValueError) as exc:
|
||||||
|
|||||||
@@ -202,6 +202,31 @@ def _reconciliation_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_filters_definition_before_page_limit(tmp_path: Path) -> None:
|
||||||
|
previous = _definitions().definitions[0]
|
||||||
|
current = replace(previous, definition_version=2, definition_sha256="0" * 64)
|
||||||
|
now = NOW
|
||||||
|
queue = ObservatoryRecordedJobQueue(
|
||||||
|
tmp_path, definitions=RecordedRunDefinitionRegistry((previous, current)),
|
||||||
|
clock=lambda: now,
|
||||||
|
)
|
||||||
|
first, _ = queue.submit(_intent())
|
||||||
|
now = "2026-08-30T21:01:00.000Z"
|
||||||
|
queue.submit(
|
||||||
|
replace(
|
||||||
|
_intent(idempotency_key="new-definition"),
|
||||||
|
definition_sha256=current.definition_sha256,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert queue.list_jobs(limit=1)[0].definition_sha256 == current.definition_sha256
|
||||||
|
assert queue.list_jobs(
|
||||||
|
source_session_id=first.source_session_id, setup_id=previous.setup_id,
|
||||||
|
definition_sha256=previous.definition_sha256, limit=1,
|
||||||
|
) == (first,)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
queue.list_jobs(definition_sha256="not-a-digest")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("enqueue", [False, True])
|
@pytest.mark.parametrize("enqueue", [False, True])
|
||||||
def test_portable_duplicate_guard_preserves_original_request(
|
def test_portable_duplicate_guard_preserves_original_request(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -228,6 +228,25 @@ def test_exact_m49_preflight_is_queueable_and_submit_is_idempotently_queued(
|
|||||||
]
|
]
|
||||||
assert queue.admission_gate().blocked is False
|
assert queue.admission_gate().blocked is False
|
||||||
|
|
||||||
|
for digest, count in ((definition.definition_sha256, 1), ("f" * 64, 0)):
|
||||||
|
exact = client.get(
|
||||||
|
"/api/v1/observatory/runs",
|
||||||
|
params={
|
||||||
|
"source_session_id": RAV00_SESSION_ID, "setup_id": SETUP_ID,
|
||||||
|
"definition_sha256": digest,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert exact.status_code == 200
|
||||||
|
assert len(exact.json()["items"]) == count
|
||||||
|
malformed = client.get(
|
||||||
|
"/api/v1/observatory/runs",
|
||||||
|
params={
|
||||||
|
"source_session_id": RAV00_SESSION_ID, "setup_id": SETUP_ID,
|
||||||
|
"definition_sha256": "not-a-digest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert malformed.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_recorded_run_routes_fail_closed_when_queue_initialization_failed() -> None:
|
def test_recorded_run_routes_fail_closed_when_queue_initialization_failed() -> None:
|
||||||
registry = LaboratorySetupRegistry.from_file(
|
registry = LaboratorySetupRegistry.from_file(
|
||||||
|
|||||||
Reference in New Issue
Block a user