feat(observatory): add durable recorded compute queue

This commit is contained in:
DCCONSTRUCTIONS
2026-08-31 01:04:25 +03:00
parent e72609120f
commit d67e86176e
23 changed files with 6300 additions and 369 deletions
@@ -61,8 +61,8 @@ export interface ObservatoryLaboratoryRunPreflight {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string | null;
readonly outcome: "existing" | "blocked";
readonly submissionAllowed: false;
readonly outcome: "existing" | "queueable" | "blocked";
readonly submissionAllowed: boolean;
readonly checks: readonly {
readonly checkId: string;
readonly outcome: "pass" | "fail" | "not-applicable";
@@ -287,8 +287,15 @@ function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
setupId: text(row.setup_id, "preflight setup_id"),
definitionSha256: digest,
outcome: oneOf(row.outcome, ["existing", "blocked"] as const, "preflight outcome"),
submissionAllowed: exact(row.submission_allowed, false, "preflight submission_allowed"),
outcome: oneOf(
row.outcome,
["existing", "queueable", "blocked"] as const,
"preflight outcome",
),
submissionAllowed: boolean(
row.submission_allowed,
"preflight submission_allowed",
),
checks: array(row.checks, "preflight checks").map((item) => {
const check = record(item, "preflight check");
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
@@ -0,0 +1,241 @@
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
const SHA256 = /^[a-f0-9]{64}$/;
export type ObservatoryRecordedJobState =
| "accepted"
| "queued"
| "claimed"
| "running"
| "paused"
| "preemption-pending"
| "succeeded"
| "failed"
| "reconciliation-required";
export interface ObservatoryRecordedJob {
readonly jobId: string;
readonly idempotencyKey: string;
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string;
readonly state: ObservatoryRecordedJobState;
readonly restartFromZero: boolean;
readonly resultId: string | null;
readonly terminalMessage: string | null;
readonly createdAtUtc: string;
readonly updatedAtUtc: string;
}
export type ObservatoryRecordedJobFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export class ObservatoryRecordedJobContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryRecordedJobContractError";
this.status = status;
}
}
export async function fetchObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({
source_session_id: sourceSessionId,
setup_id: setupId,
limit: "20",
});
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "список расчётов");
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
));
}
export async function submitObservatoryRecordedJob(
sourceSessionId: string,
setupId: string,
idempotencyKey: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(fetcher, "/api/v1/observatory/runs", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: SUBMIT_SCHEMA,
idempotency_key: idempotencyKey,
source_session_id: sourceSessionId,
setup_id: setupId,
}),
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId) {
throw new ObservatoryRecordedJobContractError(
"Расчёт относится к другой сессии или сетапу.",
);
}
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "created_at_utc", "executor",
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
"preemption_requested", "priority", "request_sha256", "restart_from_zero", "result",
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
"updated_at_utc",
], "расчёт");
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
observationAuthority(row.authority);
digest(row.request_sha256, "request_sha256");
digest(row.identity_sha256, "identity_sha256");
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
const source = record(row.source, "source");
exactKeys(source, [
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
], "source");
digest(source.catalog_sha256, "source.catalog_sha256");
digest(source.bundle_sha256, "source.bundle_sha256");
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
const setup = record(row.setup, "setup");
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
digest(setup.definition_sha256, "setup.definition_sha256");
const state = oneOf(row.state, [
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"succeeded", "failed", "reconciliation-required",
] as const, "state");
const result = row.result === null ? null : record(row.result, "result");
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
return {
jobId: text(row.job_id, "job_id"),
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
sourceSessionId: text(source.session_id, "source.session_id"),
setupId: text(setup.setup_id, "setup.setup_id"),
definitionSha256: String(setup.definition_sha256),
state,
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
resultId: result === null ? null : text(result.result_id, "result.result_id"),
terminalMessage: terminal === null || terminal.message === null
? null
: text(terminal.message, "terminal.message"),
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
exactKeys(row, keys, "authority");
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
}
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
}
}
async function responseBody(response: Response): Promise<unknown> {
const body = await response.text();
if (!body) return undefined;
try { return JSON.parse(body) as unknown; } catch { return body; }
}
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryRecordedJobContractError(
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
status,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
}
return value;
}
function digest(value: unknown, label: string): string {
const valueText = text(value, label);
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
return valueText;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
return value;
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
return expected;
}
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
}
return value as T[number];
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
}
}
@@ -111,6 +111,11 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
}
}, [preflight.kind, selectedSetup, sourceSessionId]);
useEffect(() => {
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
void check();
}, [check, preflight.kind, selectedSetup, state]);
return {
catalog: activeCatalog,
state,
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservatoryRecordedJobs,
submitObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
type RecordedJobsState = "idle" | "loading" | "ready" | "refreshing" | "submitting" | "error";
const OPEN_STATES = new Set([
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"reconciliation-required",
]);
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);
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}`;
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (!sourceSessionId || !setupId) {
setJobs([]);
setState("idle");
setError(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()
? caught.message
: "Очередь расчётов недоступна.");
});
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],
);
useEffect(() => {
if (!latestJob || activeJob) return;
idempotencyKeys.current.delete(selectionKey);
}, [activeJob, latestJob, selectionKey]);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const submit = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
if (!sourceSessionId || !setupId || state === "submitting") return null;
if (activeJob) return activeJob;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
const key = idempotencyKeys.current.get(selectionKey)
?? createIdempotencyKey();
idempotencyKeys.current.set(selectionKey, key);
setState("submitting");
setError(null);
try {
const job = await submitObservatoryRecordedJob(sourceSessionId, setupId, key, {
signal: request.signal,
});
if (request.signal.aborted || requestRef.current !== request) return null;
setJobs((current) => [job, ...current.filter((candidate) => candidate.jobId !== job.jobId)]);
setState("ready");
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось поставить расчёт в очередь.");
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [activeJob, selectionKey, setupId, sourceSessionId, state]);
return { jobs, latestJob, activeJob, state, error, refresh, submit };
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
return `observatory-ui:${entropy}`;
}
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
+32 -165
View File
@@ -20,8 +20,6 @@
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
flex-wrap: wrap;
}
@@ -35,14 +33,12 @@
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-setup-detail h3,
.observatory-evidence h3 {
margin: 0.3rem 0 0;
}
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-setup-detail h3,
.observatory-evidence h3 {
font-size: var(--nodedc-font-size-lg);
letter-spacing: -0.02em;
@@ -50,7 +46,6 @@
}
.observatory-lead p,
.observatory-catalog-bar p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
@@ -67,17 +62,37 @@
}
.observatory-catalog-bar__copy {
flex: 1 1 auto;
display: grid;
flex: 0 0 auto;
align-content: center;
gap: 0.1rem;
}
.observatory-catalog-bar h3 {
margin-top: 0.1rem;
}
.observatory-catalog-bar__controls {
flex: 2 1 48rem;
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
justify-content: flex-end;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 1 1 18rem;
min-width: min(22rem, 100%);
min-width: 0;
}
.observatory-catalog-bar__controls > .nodedc-button {
flex: 0 0 auto;
}
.observatory-catalog-bar__run {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.55rem;
}
.observatory-state {
@@ -105,9 +120,9 @@
.observatory-session-summary {
display: grid;
grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto);
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
align-items: center;
gap: 1rem;
gap: 0.75rem;
}
.observatory-session-summary__identity {
@@ -131,7 +146,7 @@
.observatory-session-summary__facts {
display: grid;
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
gap: 1rem;
gap: 0.75rem;
margin: 0;
}
@@ -159,7 +174,7 @@
display: grid;
min-width: 0;
justify-items: end;
gap: 0.6rem;
gap: 0.4rem;
}
.observatory-modalities {
@@ -171,135 +186,6 @@
color: var(--nodedc-text-muted);
}
.observatory-setup-detail {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.85rem 1rem;
container-name: observatory-setup;
container-type: inline-size;
}
.observatory-setup-detail__identity {
min-width: 0;
}
.observatory-setup-detail__identity h3 {
margin: 0.15rem 0 0;
}
.observatory-setup-detail__identity p {
max-width: 68rem;
margin: 0.3rem 0 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-detail__badges,
.observatory-setup-detail__actions,
.observatory-setup-detail__reason {
display: flex;
align-items: center;
gap: 0.55rem;
}
.observatory-setup-detail__badges {
align-self: start;
justify-content: flex-end;
flex-wrap: wrap;
}
.observatory-setup-detail__facts {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.observatory-setup-detail__facts > div {
display: grid;
min-width: 0;
gap: 0.2rem;
padding: 0.65rem 0.75rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-setup-detail__facts dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-setup-detail__facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-results {
display: grid;
grid-column: 1 / -1;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.observatory-setup-results li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
min-height: 3.15rem;
padding: 0.5rem 0.7rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
}
.observatory-setup-results span {
display: grid;
min-width: 0;
gap: 0.1rem;
}
.observatory-setup-results strong,
.observatory-setup-results small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.observatory-setup-results small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-detail__footer {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding-top: 0.1rem;
}
.observatory-setup-detail__reason {
min-width: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.4;
}
.observatory-setup-detail__actions {
flex: 0 0 auto;
}
.observatory-evidence {
display: grid;
gap: 0.65rem;
@@ -454,7 +340,7 @@
font-size: var(--nodedc-font-size-sm);
}
@container observatory-session (max-width: 56rem) {
@container observatory-session (max-width: 46rem) {
.observatory-session-summary {
grid-template-columns: minmax(0, 1fr);
align-items: start;
@@ -467,29 +353,6 @@
}
}
@container observatory-setup (max-width: 48rem) {
.observatory-setup-detail {
grid-template-columns: minmax(0, 1fr);
}
.observatory-setup-detail__badges {
justify-content: flex-start;
}
.observatory-setup-detail__facts {
grid-template-columns: minmax(0, 1fr);
}
.observatory-setup-detail__footer {
align-items: stretch;
flex-direction: column;
}
.observatory-setup-detail__actions {
flex-wrap: wrap;
}
}
@container observatory-session (max-width: 48rem) {
.observatory-evidence-card {
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -535,4 +398,8 @@
min-width: 0;
}
.observatory-catalog-bar__run {
justify-content: flex-end;
}
}
@@ -1,139 +0,0 @@
import {
ActivityIndicator,
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
import type { ObservatoryEvidence } from "../../core/observatory/catalog";
import type { ObservatoryLaboratorySetup } from "../../core/observatory/laboratorySetups";
import type { ObservatoryRecordedRunBinding } from "../../core/observatory/recordedRun";
import type { SetupPreflightState } from "../../core/observatory/useObservatoryLaboratorySetups";
const relationLabel: Readonly<Record<string, string>> = {
"primary-visual": "Основной визуальный разбор",
"compute-successor": "Связанное вычислительное доказательство",
"canonical-projection": "Готовый записанный разбор",
};
function workerLabel(value: string): string {
const match = /^worker-(\d+)$/.exec(value);
return match ? `Worker ${match[1]}` : value;
}
export function ObservatorySetupDetail({
setup,
evidence,
preflight,
onCheck,
onOpenExisting,
}: {
setup: ObservatoryLaboratorySetup;
evidence: readonly ObservatoryEvidence[];
preflight: SetupPreflightState;
onCheck: () => void;
onOpenExisting: (binding: ObservatoryRecordedRunBinding) => void;
}) {
const exactExisting = evidence.find(
(candidate) => candidate.recordedRun
&& setup.preflight.existingResultIds.includes(candidate.recordedRun.resultId),
)?.recordedRun ?? null;
const displayedReason = preflight.kind === "ready"
? preflight.value.checks.find((check) => check.checkId === "executor")?.message
?? setup.executor.reason
: preflight.kind === "error"
? preflight.message
: setup.preflight.reason;
return (
<GlassSurface className="observatory-setup-detail" padding="md">
<div className="observatory-setup-detail__identity">
<span className="section-eyebrow">СЕТАП ЛАБОРАТОРИИ</span>
<h3>{setup.displayName}</h3>
<p>{setup.description}</p>
</div>
<div className="observatory-setup-detail__badges">
<StatusBadge tone={setup.compatibility.compatible ? "success" : "warning"}>
{setup.compatibility.compatible ? "Совместим" : "Другая сессия"}
</StatusBadge>
<StatusBadge tone={setup.origin === "existing-result" ? "accent" : "neutral"}>
{setup.origin === "existing-result" ? "Готовый результат" : "Архивный сетап"}
</StatusBadge>
</div>
<dl className="observatory-setup-detail__facts">
<div>
<dt>Идентичность</dt>
<dd>
{setup.runDefinition
? `Версия ${setup.runDefinition.version} · конфигурация зафиксирована`
: "Готовый записанный результат без повторно запускаемой конфигурации"}
</dd>
</div>
<div>
<dt>Исполнитель</dt>
<dd>
{setup.preflight.outcome === "existing"
? "Не требуется для просмотра"
: `${workerLabel(setup.executor.contourId)} · повторный запуск не подключён`}
</dd>
</div>
<div>
<dt>Сохранено</dt>
<dd>{setup.preservedResults.length} неизменяемых результатов</dd>
</div>
</dl>
<ol className="observatory-setup-results" aria-label="Сохранённые результаты сетапа">
{setup.preservedResults.map((result) => (
<li key={result.resultId}>
<Icon name={result.access === "evidence-only" ? "database" : "clipboard"} size={15} />
<span>
<strong>{relationLabel[result.relation] ?? "Сохранённый результат"}</strong>
<small>
{result.access === "observatory"
? "Доступен в Обсерватории"
: result.access === "legacy-lab"
? "Сохранён в legacy LAB"
: "Сохранено как связанное доказательство"}
</small>
</span>
<StatusBadge tone={result.observatoryProjectionAvailable ? "success" : "neutral"}>
{result.access === "observatory"
? result.observatoryProjectionAvailable ? "Готов" : "Сохранён"
: result.access === "legacy-lab" ? "Legacy LAB" : "Evidence"}
</StatusBadge>
</li>
))}
</ol>
<div className="observatory-setup-detail__footer">
<div className="observatory-setup-detail__reason" role={preflight.kind === "error" ? "alert" : "status"}>
<Icon name={preflight.kind === "error" ? "alert" : "activity"} size={16} />
<span>{displayedReason}</span>
</div>
<div className="observatory-setup-detail__actions">
{exactExisting ? (
<Button
size="compact"
variant="secondary"
icon={<Icon name="eye" size={15} />}
onClick={() => onOpenExisting(exactExisting)}
>
Открыть готовый результат
</Button>
) : (
<Button
size="compact"
variant="secondary"
disabled={preflight.kind === "checking"}
onClick={onCheck}
>
{preflight.kind === "checking"
? <ActivityIndicator size="compact" label="Проверяем совместимость" />
: "Проверить совместимость"}
</Button>
)}
</div>
</div>
</GlassSurface>
);
}
@@ -32,8 +32,9 @@ import {
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import type { ObservatoryRecordedJobState } from "../../core/observatory/recordedJobs";
import type { WorkspaceDefinition } from "../../productModel";
import { ObservatorySetupDetail } from "./ObservatorySetupDetail";
const MAX_PRESENTED_EVIDENCE = 6;
const EMPTY_OBSERVATORY_ITEMS = [] as const;
@@ -87,6 +88,27 @@ const modalityLabel: Record<string, string> = {
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: "success" },
failed: { label: "Ошибка расчёта", tone: "danger" },
"reconciliation-required": { label: "Нужна сверка", tone: "warning" },
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
@@ -136,6 +158,10 @@ export function ObservatoryWorkspace({
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [renameValue, setRenameValue] = useState("");
@@ -188,6 +214,29 @@ export function ObservatoryWorkspace({
: "Несовместим с выбранной сессией",
})) ?? []
), [setupController.catalog]);
const runPreflight = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
&& runPreflight?.outcome === "queueable"
&& runPreflight.submissionAllowed,
);
const presentedJob = recordedJobsController.activeJob
?? recordedJobsController.latestJob;
const presentedJobStatus = presentedJob
? 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";
const canSubmitRecordedJob = queueSubmissionAllowed
&& recordedJobsController.state === "ready"
&& recordedJobsController.activeJob === null;
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
@@ -374,11 +423,10 @@ export function ObservatoryWorkspace({
</StatusBadge>
</section>
<GlassSurface className="observatory-catalog-bar" padding="md">
<GlassSurface className="observatory-catalog-bar" padding="sm">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
<p>Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.</p>
</div>
<div className="observatory-catalog-bar__controls">
<Select
@@ -418,10 +466,40 @@ export function ObservatoryWorkspace({
onClick={() => {
void controller.refresh();
setupController.refresh();
recordedJobsController.refresh();
}}
>
Обновить
</Button>
<div className="observatory-catalog-bar__run" aria-live="polite">
{queueStatusError ? (
<StatusBadge tone="danger" title={queueStatusError}>
Очередь недоступна
</StatusBadge>
) : recordedJobsController.state === "submitting" ? (
<StatusBadge tone="neutral">Ставим в очередь</StatusBadge>
) : presentedJobStatus ? (
<StatusBadge
tone={presentedJobStatus.tone}
title={presentedJob?.terminalMessage ?? undefined}
>
{presentedJobStatus.label}
</StatusBadge>
) : setupController.preflight.kind === "checking" ? (
<StatusBadge tone="neutral">Проверяем сетап</StatusBadge>
) : queueStateBusy ? (
<StatusBadge tone="neutral">Читаем очередь</StatusBadge>
) : null}
{canSubmitRecordedJob ? (
<Button
size="compact"
variant="primary"
onClick={() => { void recordedJobsController.submit(); }}
>
Рассчитать
</Button>
) : null}
</div>
</div>
</GlassSurface>
@@ -476,7 +554,7 @@ export function ObservatoryWorkspace({
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="md">
<GlassSurface className="observatory-session-summary" padding="sm">
<div className="observatory-session-summary__identity">
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
@@ -506,22 +584,6 @@ export function ObservatoryWorkspace({
</div>
</GlassSurface>
{setupController.selectedSetup ? (
<ObservatorySetupDetail
setup={setupController.selectedSetup}
evidence={selectedSession.evidence}
preflight={setupController.preflight}
onCheck={() => { void setupController.check(); }}
onOpenExisting={openReplay}
/>
) : setupController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Сетапы недоступны</StatusBadge>
<span className="observatory-notice__copy">{setupController.error}</span>
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
</GlassSurface>
) : null}
<section className="observatory-evidence">
<header>
<div>