feat(observatory-ui): review portable results and retry publication

Checkpoint the existing generic result-viewing and publication lifecycle UI. Focused architecture and Observatory tests: 62 passed; no new visual changes or rebuild in this checkpoint.
This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 00:59:21 +03:00
parent 62d5520c7a
commit 5a78c997ac
10 changed files with 748 additions and 86 deletions
@@ -1,4 +1,4 @@
export interface ObservationLabReplayCapability { export interface ObservationCanonicalLabReplayCapability {
schemaVersion: "missioncore.observation-lab-replay-capability/v1"; schemaVersion: "missioncore.observation-lab-replay-capability/v1";
kind: "canonical-recorded-rerun"; kind: "canonical-recorded-rerun";
viewerProfile: "recorded-session"; viewerProfile: "recorded-session";
@@ -7,6 +7,19 @@ export interface ObservationLabReplayCapability {
commandsEnabled: false; commandsEnabled: false;
} }
export interface ObservationPortableLabReplayCapability {
schemaVersion: "missioncore.observation-lab-replay-capability/v2";
kind: "portable-result-review";
viewerProfile: "portable-result";
timeline: "result-defined";
activation: "explicit";
commandsEnabled: false;
}
export type ObservationLabReplayCapability =
| ObservationCanonicalLabReplayCapability
| ObservationPortableLabReplayCapability;
export class ObservationLabReplayCapabilityContractError extends Error { export class ObservationLabReplayCapabilityContractError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@@ -31,6 +44,19 @@ const CANONICAL_PROVENANCE_KEYS = new Set([
"authority", "authority",
"method", "method",
]); ]);
const PORTABLE_PROVENANCE_KEYS = new Set([
"schema_version",
"authority",
"calculation_profile",
"calculation_profile_sha256",
"job",
"source",
"run_definition",
"result_package",
"replay_capability",
"storage",
"method",
]);
const AUTHORITY_KEYS = new Set([ const AUTHORITY_KEYS = new Set([
"commands_enabled", "commands_enabled",
"navigation_or_safety_accepted", "navigation_or_safety_accepted",
@@ -80,32 +106,53 @@ export function decodeLabReplayCapability(
} }
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`); assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
if ( if (
value.schema_version !== "missioncore.observation-lab-replay-capability/v1" value.activation !== "explicit"
|| value.kind !== "canonical-recorded-rerun"
|| value.viewer_profile !== "recorded-session"
|| value.timeline !== "session_time"
|| value.activation !== "explicit"
|| value.commands_enabled !== false || value.commands_enabled !== false
) { ) {
throw new ObservationLabReplayCapabilityContractError( throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`, `Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
); );
} }
return { if (
schemaVersion: "missioncore.observation-lab-replay-capability/v1", value.schema_version === "missioncore.observation-lab-replay-capability/v1"
kind: "canonical-recorded-rerun", && value.kind === "canonical-recorded-rerun"
viewerProfile: "recorded-session", && value.viewer_profile === "recorded-session"
timeline: "session_time", && value.timeline === "session_time"
activation: "explicit", ) {
commandsEnabled: false, return {
}; schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
};
}
if (
value.schema_version === "missioncore.observation-lab-replay-capability/v2"
&& value.kind === "portable-result-review"
&& value.viewer_profile === "portable-result"
&& value.timeline === "result-defined"
) {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commandsEnabled: false,
};
}
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} содержит неизвестный viewer capability.`,
);
} }
export function decodeCanonicalLabReplayCapabilityProvenance( export function decodeCanonicalLabReplayCapabilityProvenance(
provenance: Readonly<Record<string, unknown>>, provenance: Readonly<Record<string, unknown>>,
sessionId: string, sessionId: string,
resultId: string, resultId: string,
): ObservationLabReplayCapability { ): ObservationCanonicalLabReplayCapability {
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`); assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
if ( if (
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1" provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
@@ -176,7 +223,7 @@ export function decodeCanonicalLabReplayCapabilityProvenance(
); );
} }
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId); const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
if (capability === null) { if (capability === null || capability.kind !== "canonical-recorded-rerun") {
throw new ObservationLabReplayCapabilityContractError( throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`, `Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
); );
@@ -184,6 +231,44 @@ export function decodeCanonicalLabReplayCapabilityProvenance(
return capability; return capability;
} }
export function decodePortableLabReplayCapabilityProvenance(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
sourceSessionId: string,
definitionSha256: string,
): ObservationPortableLabReplayCapability {
assertExactKeys(
provenance,
PORTABLE_PROVENANCE_KEYS,
`Portable provenance LAB-сессии ${sessionId}`,
);
if (
provenance.schema_version !== "missioncore.observatory-portable-result-publication/v1"
|| !isRecord(provenance.source)
|| provenance.source.session_id !== sourceSessionId
|| !isRecord(provenance.run_definition)
|| provenance.run_definition.definition_sha256 !== definitionSha256
|| !isRecord(provenance.result_package)
|| typeof provenance.result_package.manifest_sha256 !== "string"
|| !SHA256.test(provenance.result_package.manifest_sha256)
|| typeof provenance.result_package.artifact_manifest_id !== "string"
|| !SHA256.test(provenance.result_package.artifact_manifest_id)
|| sessionId !== resultId
) {
throw new ObservationLabReplayCapabilityContractError(
`Portable provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
);
}
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
if (capability === null || capability.kind !== "portable-result-review") {
throw new ObservationLabReplayCapabilityContractError(
`Portable provenance LAB-сессии ${sessionId} не содержит viewer capability.`,
);
}
return capability;
}
/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */ /** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */
export function decodeRollingCanonicalLabReplayCapability( export function decodeRollingCanonicalLabReplayCapability(
provenance: Readonly<Record<string, unknown>>, provenance: Readonly<Record<string, unknown>>,
@@ -115,11 +115,18 @@ export async function deleteObservatoryLabProjection(
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string { function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
const sessionId = binding.evidenceSessionId; const sessionId = binding.evidenceSessionId;
const admittedViewer = (
binding.kind === "canonical-recorded-rerun"
&& binding.viewerProfile === "recorded-session"
&& binding.timeline === "session_time"
) || (
binding.kind === "portable-result-review"
&& binding.viewerProfile === "portable-result"
&& binding.timeline === "result-defined"
);
if ( if (
binding.kind !== "canonical-recorded-rerun" !admittedViewer
|| binding.activation !== "explicit" || binding.activation !== "explicit"
|| binding.viewerProfile !== "recorded-session"
|| binding.timeline !== "session_time"
|| binding.resultId !== sessionId || binding.resultId !== sessionId
|| binding.sourceSessionId === sessionId || binding.sourceSessionId === sessionId
|| !SAFE_SESSION_ID.test(sessionId) || !SAFE_SESSION_ID.test(sessionId)
@@ -14,6 +14,12 @@ export type ObservatoryRecordedJobState =
| "failed" | "failed"
| "reconciliation-required"; | "reconciliation-required";
export type ObservatoryRecordedJobPublicationState =
| "not-required"
| "pending"
| "failed"
| "published";
export interface ObservatoryRecordedJob { export interface ObservatoryRecordedJob {
readonly jobId: string; readonly jobId: string;
readonly idempotencyKey: string; readonly idempotencyKey: string;
@@ -24,6 +30,12 @@ export interface ObservatoryRecordedJob {
readonly restartFromZero: boolean; readonly restartFromZero: boolean;
readonly resultId: string | null; readonly resultId: string | null;
readonly terminalMessage: string | null; readonly terminalMessage: string | null;
readonly publication: {
readonly state: ObservatoryRecordedJobPublicationState;
readonly attempts: number;
readonly error: string | null;
readonly publishedAtUtc: string | null;
};
readonly createdAtUtc: string; readonly createdAtUtc: string;
readonly updatedAtUtc: string; readonly updatedAtUtc: string;
} }
@@ -117,12 +129,42 @@ export async function submitObservatoryRecordedJob(
return job; return job;
} }
export async function retryObservatoryRecordedJobPublication(
jobId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(
fetcher,
`/api/v1/observatory/runs/${encodeURIComponent(jobId)}/publication/retry`,
{
method: "POST",
headers: { Accept: "application/json" },
signal,
},
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.jobId !== jobId) {
throw new ObservatoryRecordedJobContractError(
"Повтор публикации вернул другой расчёт.",
);
}
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob { function decodeJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт"); const row = record(value, "расчёт");
exactKeys(row, [ exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor", "authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256", "idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
"preemption_requested", "priority", "request_sha256", "restart_from_zero", "result", "preemption_requested", "priority", "publication", "request_sha256", "restart_from_zero", "result",
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal", "schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
"updated_at_utc", "updated_at_utc",
], "расчёт"); ], "расчёт");
@@ -161,6 +203,21 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result"); if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
const terminal = row.terminal === null ? null : record(row.terminal, "terminal"); const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal"); if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
const publication = record(row.publication, "publication");
exactKeys(
publication,
["attempts", "error", "published_at_utc", "state"],
"publication",
);
const publicationState = oneOf(publication.state, [
"not-required", "pending", "failed", "published",
] as const, "publication.state");
const publicationError = publication.error === null
? null
: text(publication.error, "publication.error");
const publishedAtUtc = publication.published_at_utc === null
? null
: text(publication.published_at_utc, "publication.published_at_utc");
return { return {
jobId: text(row.job_id, "job_id"), jobId: text(row.job_id, "job_id"),
idempotencyKey: text(row.idempotency_key, "idempotency_key"), idempotencyKey: text(row.idempotency_key, "idempotency_key"),
@@ -173,6 +230,12 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
terminalMessage: terminal === null || terminal.message === null terminalMessage: terminal === null || terminal.message === null
? null ? null
: text(terminal.message, "terminal.message"), : text(terminal.message, "terminal.message"),
publication: {
state: publicationState,
attempts: nonNegativeInteger(publication.attempts, "publication.attempts"),
error: publicationError,
publishedAtUtc,
},
createdAtUtc: text(row.created_at_utc, "created_at_utc"), createdAtUtc: text(row.created_at_utc, "created_at_utc"),
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"), updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
}; };
@@ -6,23 +6,62 @@ import {
} from "../laboratory/vegetationShadow"; } from "../laboratory/vegetationShadow";
import type { ObservationLabInstance } from "../observation/sessionArchive"; import type { ObservationLabInstance } from "../observation/sessionArchive";
import { import {
decodeLabReplayCapability,
decodeCanonicalLabReplayCapabilityProvenance, decodeCanonicalLabReplayCapabilityProvenance,
decodePortableLabReplayCapabilityProvenance,
ObservationLabReplayCapabilityContractError, ObservationLabReplayCapabilityContractError,
} from "../observation/labReplayCapability"; } from "../observation/labReplayCapability";
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/; const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live"; const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
export interface ObservatoryRecordedRunBinding { interface ObservatoryRecordedRunBindingBase {
readonly kind: "canonical-recorded-rerun";
readonly evidenceSessionId: string; readonly evidenceSessionId: string;
readonly sourceSessionId: string; readonly sourceSessionId: string;
readonly resultId: string; readonly resultId: string;
readonly viewerProfile: "recorded-session";
readonly timeline: "session_time";
readonly activation: "explicit"; readonly activation: "explicit";
} }
export interface ObservatoryCanonicalRecordedRunBinding
extends ObservatoryRecordedRunBindingBase {
readonly kind: "canonical-recorded-rerun";
readonly viewerProfile: "recorded-session";
readonly timeline: "session_time";
}
export interface ObservatoryPortableResultBinding
extends ObservatoryRecordedRunBindingBase {
readonly kind: "portable-result-review";
readonly viewerProfile: "portable-result";
readonly timeline: "result-defined";
readonly definitionSha256: string;
}
export type ObservatoryRecordedRunBinding =
| ObservatoryCanonicalRecordedRunBinding
| ObservatoryPortableResultBinding;
export interface ObservatoryPortableResultReview {
readonly kind: "portable-result";
readonly resultId: string;
readonly sourceSessionId: string;
readonly resultKind: string;
readonly definitionSha256: string;
readonly calculationProfile: Readonly<Record<string, unknown>> | null;
readonly artifactManifestId: string;
readonly artifacts: readonly {
readonly role: string;
readonly mediaType: string;
readonly sha256: string;
readonly byteLength: number;
}[];
readonly resultDocument: Readonly<Record<string, unknown>>;
}
export type ObservatoryRecordedRunReview =
| { readonly kind: "canonical-recorded-rerun"; readonly review: VegetationFullRouteReview }
| ObservatoryPortableResultReview;
export class ObservatoryRecordedRunContractError extends Error { export class ObservatoryRecordedRunContractError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@@ -36,6 +75,44 @@ export function observatoryRecordedRunBinding(
): ObservatoryRecordedRunBinding | null { ): ObservatoryRecordedRunBinding | null {
const capability = lab.replayCapability; const capability = lab.replayCapability;
if (!capability) return null; if (!capability) return null;
if (capability.kind === "portable-result-review") {
if (
evidenceSessionId !== lab.resultId
|| lab.sourceSessionId === evidenceSessionId
|| lab.configSha256 === null
|| !/^[a-f0-9]{64}$/.test(lab.configSha256)
|| capability.viewerProfile !== "portable-result"
|| capability.timeline !== "result-defined"
|| capability.activation !== "explicit"
|| capability.commandsEnabled !== false
) {
throw new ObservatoryRecordedRunContractError(
`Portable-результат ${lab.resultId} не допущен к универсальному viewer.`,
);
}
try {
decodePortableLabReplayCapabilityProvenance(
lab.provenance,
evidenceSessionId,
lab.resultId,
lab.sourceSessionId,
lab.configSha256,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
return {
kind: "portable-result-review",
evidenceSessionId,
sourceSessionId: lab.sourceSessionId,
resultId: lab.resultId,
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
definitionSha256: lab.configSha256,
};
}
try { try {
decodeCanonicalLabReplayCapabilityProvenance( decodeCanonicalLabReplayCapabilityProvenance(
lab.provenance, lab.provenance,
@@ -87,25 +164,31 @@ export async function fetchObservatoryRecordedRunReview(
fetcher?: LaboratoryFetch; fetcher?: LaboratoryFetch;
signal?: AbortSignal; signal?: AbortSignal;
}, },
): Promise<VegetationFullRouteReview> { ): Promise<ObservatoryRecordedRunReview> {
if (binding.sourceSessionId !== selectedSourceSessionId) { if (binding.sourceSessionId !== selectedSourceSessionId) {
throw new ObservatoryRecordedRunContractError( throw new ObservatoryRecordedRunContractError(
"Запуск не связан с выбранной исходной сессией.", "Запуск не связан с выбранной исходной сессией.",
); );
} }
if (binding.kind === "portable-result-review") {
return fetchPortableResultReview(binding, { fetcher, signal });
}
const result = await fetchVegetationShadowResultMetadata(binding.resultId, { const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
fetcher, fetcher,
signal, signal,
}); });
return admitObservatoryRecordedRunReview( return {
binding, kind: "canonical-recorded-rerun",
selectedSourceSessionId, review: admitObservatoryRecordedRunReview(
result, binding,
); selectedSourceSessionId,
result,
),
};
} }
export function admitObservatoryRecordedRunReview( export function admitObservatoryRecordedRunReview(
binding: ObservatoryRecordedRunBinding, binding: ObservatoryCanonicalRecordedRunBinding,
selectedSourceSessionId: string, selectedSourceSessionId: string,
result: VegetationShadowResult, result: VegetationShadowResult,
): VegetationFullRouteReview { ): VegetationFullRouteReview {
@@ -127,3 +210,126 @@ export function admitObservatoryRecordedRunReview(
} }
return review; return review;
} }
async function fetchPortableResultReview(
binding: ObservatoryPortableResultBinding,
{
fetcher,
signal,
}: {
fetcher: LaboratoryFetch;
signal?: AbortSignal;
},
): Promise<ObservatoryPortableResultReview> {
const response = await fetcher(
`/api/v1/observatory/portable-results/${encodeURIComponent(binding.resultId)}`,
{ headers: { Accept: "application/json" }, signal },
);
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservatoryRecordedRunContractError(
"Portable viewer получил некорректный ответ сервера.",
);
}
if (!response.ok) {
throw new ObservatoryRecordedRunContractError(
isRecord(body) && typeof body.detail === "string"
? body.detail
: `Portable viewer вернул HTTP ${response.status}.`,
);
}
return decodePortableResultReview(body, binding);
}
function decodePortableResultReview(
value: unknown,
binding: ObservatoryPortableResultBinding,
): ObservatoryPortableResultReview {
if (!isRecord(value)) {
throw new ObservatoryRecordedRunContractError("Portable viewer вернул не объект.");
}
const expected = new Set([
"schema_version",
"result_id",
"source_session_id",
"result_kind",
"definition_sha256",
"viewer_capability",
"calculation_profile",
"artifact_manifest_id",
"artifacts",
"result_document",
]);
if (Object.keys(value).some((key) => !expected.has(key)) || Object.keys(value).length !== expected.size) {
throw new ObservatoryRecordedRunContractError("Portable viewer изменил контракт ответа.");
}
let viewerCapability;
try {
viewerCapability = decodeLabReplayCapability(
value.viewer_capability,
binding.evidenceSessionId,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
if (
value.schema_version !== "missioncore.observatory-portable-result-view/v1"
|| value.result_id !== binding.resultId
|| value.source_session_id !== binding.sourceSessionId
|| value.definition_sha256 !== binding.definitionSha256
|| typeof value.result_kind !== "string"
|| typeof value.artifact_manifest_id !== "string"
|| !/^[a-f0-9]{64}$/.test(value.artifact_manifest_id)
|| !Array.isArray(value.artifacts)
|| !isRecord(value.result_document)
|| !isRecord(value.calculation_profile)
|| viewerCapability === null
|| viewerCapability.kind !== "portable-result-review"
|| viewerCapability.viewerProfile !== binding.viewerProfile
|| viewerCapability.timeline !== binding.timeline
) {
throw new ObservatoryRecordedRunContractError("Portable viewer потерял identity результата.");
}
const artifacts = value.artifacts.map((item) => {
if (
!isRecord(item)
|| Object.keys(item).length !== 4
|| !["role", "media_type", "sha256", "byte_length"].every(
(key) => Object.prototype.hasOwnProperty.call(item, key),
)
|| typeof item.role !== "string"
|| typeof item.media_type !== "string"
|| typeof item.sha256 !== "string"
|| !/^[a-f0-9]{64}$/.test(item.sha256)
|| typeof item.byte_length !== "number"
|| !Number.isSafeInteger(item.byte_length)
|| item.byte_length < 0
) {
throw new ObservatoryRecordedRunContractError("Portable viewer получил неверный artifact.");
}
return {
role: item.role,
mediaType: item.media_type,
sha256: item.sha256,
byteLength: item.byte_length,
};
});
return {
kind: "portable-result",
resultId: binding.resultId,
sourceSessionId: binding.sourceSessionId,
resultKind: value.result_kind,
definitionSha256: binding.definitionSha256,
calculationProfile: value.calculation_profile,
artifactManifestId: value.artifact_manifest_id,
artifacts,
resultDocument: value.result_document,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -42,54 +42,64 @@ 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 portableResult = fetchObservatoryPortableLaboratorySetups( const legacyResult = settled(
sourceSessionId, fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }),
{ signal: request.signal },
).then(
(value) => ({ status: "fulfilled" as const, value }),
(reason: unknown) => ({ status: "rejected" as const, reason }),
); );
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }) const portableResult = settled(
.then(async (legacyCatalog) => { 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;
publishSetupCatalog( if (legacy.status === "fulfilled" && portable.status === "fulfilled") {
legacyCatalog,
setCatalog,
setSelectedSetupId,
{ preserveUnknownSelection: true },
);
setState("ready");
const optionalPortable = await portableResult;
if (request.signal.aborted || requestSequence.current !== sequence) return;
if (optionalPortable.status === "fulfilled") {
try { try {
publishSetupCatalog( publishSetupCatalog(
mergeSetupCatalogs(legacyCatalog, optionalPortable.value), mergeSetupCatalogs(legacy.value, portable.value),
setCatalog, setCatalog,
setSelectedSetupId, setSelectedSetupId,
); );
setError(null); setError(null);
} catch (caught) { } catch (caught) {
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId); publishSetupCatalog(legacy.value, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage( setError(catalogErrorMessage(
caught, caught,
"Portable-каталог профилей нарушил локальный контракт.", "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; return;
} }
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId); setState("ready");
setError(catalogErrorMessage(
optionalPortable.reason,
"Portable-каталог профилей недоступен.",
));
})
.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]);
@@ -188,6 +198,13 @@ function catalogErrorMessage(caught: unknown, fallback: string): string {
: fallback; : fallback;
} }
function settled<T>(promise: Promise<T>): Promise<PromiseSettledResult<T>> {
return promise.then(
(value) => ({ status: "fulfilled", value }),
(reason: unknown) => ({ status: "rejected", reason }),
);
}
function mergeSetupCatalogs( function mergeSetupCatalogs(
legacy: ObservatoryLaboratorySetupCatalog, legacy: ObservatoryLaboratorySetupCatalog,
portable: ObservatoryLaboratorySetupCatalog, portable: ObservatoryLaboratorySetupCatalog,
@@ -195,13 +212,9 @@ function mergeSetupCatalogs(
if (legacy.sourceSessionId !== portable.sourceSessionId) { if (legacy.sourceSessionId !== portable.sourceSessionId) {
throw new Error("Каталоги сетапов относятся к разным исходным сессиям."); throw new Error("Каталоги сетапов относятся к разным исходным сессиям.");
} }
const portableProfileNames = new Set( const portableSetupIds = new Set(portable.setups.map((setup) => setup.setupId));
portable.setups.map((setup) => setup.displayName),
);
const setups = [ const setups = [
...legacy.setups.filter( ...legacy.setups.filter((setup) => !portableSetupIds.has(setup.setupId)),
(setup) => !portableProfileNames.has(setup.displayName),
),
...portable.setups, ...portable.setups,
]; ];
if (new Set(setups.map((setup) => setup.setupId)).size !== setups.length) { if (new Set(setups.map((setup) => setup.setupId)).size !== setups.length) {
@@ -2,16 +2,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
fetchObservatoryRecordedJobs, fetchObservatoryRecordedJobs,
retryObservatoryRecordedJobPublication,
submitObservatoryRecordedJob, submitObservatoryRecordedJob,
type ObservatoryRecordedJob, type ObservatoryRecordedJob,
} from "./recordedJobs"; } from "./recordedJobs";
type RecordedJobsState = "idle" | "loading" | "ready" | "refreshing" | "submitting" | "error"; type RecordedJobsState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "submitting"
| "retrying-publication"
| "error";
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;
export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) { export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) {
const [jobs, setJobs] = useState<readonly ObservatoryRecordedJob[]>([]); const [jobs, setJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
@@ -60,6 +69,16 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
() => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null, () => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null,
[jobs], [jobs],
); );
const publicationPending = jobs.some((job) => job.publication.state === "pending");
useEffect(() => {
if (state !== "ready" || (!activeJob && !publicationPending)) return;
const timer = globalThis.setTimeout(
() => setRevision((value) => value + 1),
POLL_INTERVAL_MS,
);
return () => globalThis.clearTimeout(timer);
}, [activeJob, publicationPending, revision, state]);
useEffect(() => { useEffect(() => {
if (!latestJob || activeJob) return; if (!latestJob || activeJob) return;
@@ -108,7 +127,47 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
} }
}, [activeJob, selectionKey, setupId, sourceSessionId, state]); }, [activeJob, selectionKey, setupId, sourceSessionId, state]);
return { jobs, latestJob, activeJob, state, error, refresh, submit }; const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
if (!latestJob || latestJob.publication.state !== "failed") return null;
if (state === "retrying-publication") return null;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
setState("retrying-publication");
setError(null);
try {
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
signal: request.signal,
});
if (request.signal.aborted || requestRef.current !== request) return null;
setJobs((current) => [
job,
...current.filter((candidate) => candidate.jobId !== job.jobId),
]);
setState("ready");
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;
}
}, [latestJob, state]);
return {
jobs,
latestJob,
activeJob,
state,
error,
refresh,
submit,
retryPublication,
};
} }
function createIdempotencyKey(): string { function createIdempotencyKey(): string {
@@ -178,6 +178,7 @@ export function ObservatoryWorkspace({
>(null); >(null);
const overviewRef = useRef<HTMLElement | null>(null); const overviewRef = useRef<HTMLElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator()); const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const refreshedPublishedJobRef = useRef<string | null>(null);
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS; const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
const closeReplay = useCallback(() => { const closeReplay = useCallback(() => {
@@ -244,14 +245,23 @@ export function ObservatoryWorkspace({
const presentedJob = recordedJobsController.activeJob const presentedJob = recordedJobsController.activeJob
?? recordedJobsController.latestJob; ?? recordedJobsController.latestJob;
const presentedJobStatus = presentedJob const presentedJobStatus = presentedJob
? recordedJobStatus[presentedJob.state] ? presentedJob.state === "succeeded"
? 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; : null;
const queueStatusError = setupController.preflight.kind === "error" const queueStatusError = setupController.preflight.kind === "error"
? setupController.preflight.message ? setupController.preflight.message
: recordedJobsController.error; : recordedJobsController.error;
const queueStateBusy = recordedJobsController.state === "loading" const queueStateBusy = recordedJobsController.state === "loading"
|| recordedJobsController.state === "refreshing" || recordedJobsController.state === "refreshing"
|| recordedJobsController.state === "submitting"; || recordedJobsController.state === "submitting"
|| recordedJobsController.state === "retrying-publication";
const canSubmitRecordedJob = queueSubmissionAllowed const canSubmitRecordedJob = queueSubmissionAllowed
&& recordedJobsController.state === "ready" && recordedJobsController.state === "ready"
&& recordedJobsController.activeJob === null; && recordedJobsController.activeJob === null;
@@ -267,6 +277,16 @@ export function ObservatoryWorkspace({
(evidence) => evidence.sessionId === replayEvidenceId, (evidence) => evidence.sessionId === replayEvidenceId,
) ?? null; ) ?? null;
useEffect(() => {
const publishedJob = recordedJobsController.jobs.find(
(job) => job.publication.state === "published" && job.resultId !== null,
);
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
refreshedPublishedJobRef.current = publishedJob.jobId;
void controller.refresh();
setupController.refresh();
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
useEffect(() => { useEffect(() => {
if ( if (
replayEvidenceId === null replayEvidenceId === null
@@ -496,10 +516,14 @@ export function ObservatoryWorkspace({
</StatusBadge> </StatusBadge>
) : recordedJobsController.state === "submitting" ? ( ) : recordedJobsController.state === "submitting" ? (
<StatusBadge tone="neutral">Ставим в очередь</StatusBadge> <StatusBadge tone="neutral">Ставим в очередь</StatusBadge>
) : recordedJobsController.state === "retrying-publication" ? (
<StatusBadge tone="neutral">Повторяем публикацию</StatusBadge>
) : presentedJobStatus ? ( ) : presentedJobStatus ? (
<StatusBadge <StatusBadge
tone={presentedJobStatus.tone} tone={presentedJobStatus.tone}
title={presentedJob?.terminalMessage ?? undefined} title={presentedJob?.publication.error
?? presentedJob?.terminalMessage
?? undefined}
> >
{presentedJobStatus.label} {presentedJobStatus.label}
</StatusBadge> </StatusBadge>
@@ -542,6 +566,16 @@ 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>
@@ -610,6 +644,20 @@ export function ObservatoryWorkspace({
<dt>Чтение</dt> <dt>Чтение</dt>
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd> <dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
</div> </div>
<div>
<dt>Оборудование</dt>
<dd>
{selectedSession.source.captureAttestation?.equipmentDisplayName
?? "Не аттестовано"}
</dd>
</div>
<div>
<dt>Профиль записи</dt>
<dd title={selectedSession.source.captureAttestation?.captureProfileSha256}>
{selectedSession.source.captureAttestation?.captureProfileId
?? "Не определён"}
</dd>
</div>
</dl> </dl>
<div className="observatory-session-summary__end"> <div className="observatory-session-summary__end">
<StatusBadge tone={statusTone(selectedSession.source.status)}> <StatusBadge tone={statusTone(selectedSession.source.status)}>
@@ -738,12 +786,20 @@ export function ObservatoryWorkspace({
</div> </div>
</GlassSurface> </GlassSurface>
) : replay.kind === "ready" ? ( ) : replay.kind === "ready" ? (
<section className="observatory-replay" aria-label="Канонический визуальный разбор"> <section className="observatory-replay" aria-label="Визуальный разбор результата">
<header className="observatory-replay__header"> <header className="observatory-replay__header">
<div> <div>
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span> <span className="section-eyebrow">
{replay.review.kind === "canonical-recorded-rerun"
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
</span>
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3> <h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
<p>Записанный маршрут синхронизирован по общей временной шкале.</p> <p>
{replay.review.kind === "canonical-recorded-rerun"
? "Записанный маршрут синхронизирован по общей временной шкале."
: "Показан проверенный документ результата и связанные с ним артефакты."}
</p>
</div> </div>
<Button <Button
size="compact" size="compact"
@@ -754,10 +810,24 @@ export function ObservatoryWorkspace({
Закрыть разбор Закрыть разбор
</Button> </Button>
</header> </header>
<CanonicalVegetationRerunReplay {replay.review.kind === "canonical-recorded-rerun" ? (
resultId={replay.binding.resultId} <CanonicalVegetationRerunReplay
review={replay.review} resultId={replay.binding.resultId}
/> review={replay.review.review}
/>
) : (
<GlassSurface className="observatory-replay-state" padding="lg">
<div>
<StatusBadge tone="success">Проверено</StatusBadge>
<h3>{replay.review.resultKind}</h3>
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
<details>
<summary>Документ результата</summary>
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
</details>
</div>
</GlassSurface>
)}
</section> </section>
) : null} ) : null}
@@ -5,6 +5,7 @@ import { createServer } from "vite";
let server; let server;
let fetchObservatoryRecordedJobs; let fetchObservatoryRecordedJobs;
let retryObservatoryRecordedJobPublication;
let submitObservatoryRecordedJob; let submitObservatoryRecordedJob;
let ObservatoryRecordedJobContractError; let ObservatoryRecordedJobContractError;
@@ -65,6 +66,12 @@ function job(state = "queued") {
result: state === "succeeded" result: state === "succeeded"
? { result_id: "m49-result", sha256: "d".repeat(64) } ? { result_id: "m49-result", sha256: "d".repeat(64) }
: null, : null,
publication: {
state: "not-required",
attempts: 0,
error: null,
published_at_utc: null,
},
terminal: state === "failed" terminal: state === "failed"
? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." } ? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." }
: null, : null,
@@ -82,6 +89,7 @@ before(async () => {
}); });
({ ({
fetchObservatoryRecordedJobs, fetchObservatoryRecordedJobs,
retryObservatoryRecordedJobPublication,
submitObservatoryRecordedJob, submitObservatoryRecordedJob,
ObservatoryRecordedJobContractError, ObservatoryRecordedJobContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts")); } = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts"));
@@ -212,3 +220,28 @@ test("portable submission carries the exact definition/check fence", async () =>
check_sha256: "f".repeat(64), check_sha256: "f".repeat(64),
}); });
}); });
test("publication retry never submits a second compute request", async () => {
let request;
const response = job("succeeded");
response.publication = {
state: "published",
attempts: 2,
error: null,
published_at_utc: "2026-09-01T12:00:00Z",
};
const retried = await retryObservatoryRecordedJobPublication(response.job_id, {
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify(response), { status: 200 });
},
});
assert.equal(
request.input,
`/api/v1/observatory/runs/${response.job_id}/publication/retry`,
);
assert.equal(request.init.method, "POST");
assert.equal(request.init.body, undefined);
assert.equal(retried.publication.state, "published");
});
@@ -6,6 +6,7 @@ import { createServer } from "vite";
let server; let server;
let admitObservatoryRecordedRunReview; let admitObservatoryRecordedRunReview;
let fetchObservatoryRecordedRunReview;
let observatoryRecordedRunBinding; let observatoryRecordedRunBinding;
let ObservatoryRecordedRunContractError; let ObservatoryRecordedRunContractError;
@@ -17,6 +18,7 @@ before(async () => {
}); });
({ ({
admitObservatoryRecordedRunReview, admitObservatoryRecordedRunReview,
fetchObservatoryRecordedRunReview,
observatoryRecordedRunBinding, observatoryRecordedRunBinding,
ObservatoryRecordedRunContractError, ObservatoryRecordedRunContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts")); } = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts"));
@@ -87,6 +89,55 @@ function lab(overrides = {}) {
}; };
} }
function portableCapability() {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commandsEnabled: false,
};
}
function portableCapabilityDocument() {
return {
schema_version: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewer_profile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commands_enabled: false,
};
}
function portableLab(portableResultId, definitionSha256) {
return lab({
labId: "M4.9",
resultId: portableResultId,
resultKind: "recorded-route-analysis",
sourceResultId: null,
configSha256: definitionSha256,
replayCapability: portableCapability(),
provenance: {
schema_version: "missioncore.observatory-portable-result-publication/v1",
authority: {},
calculation_profile: {},
calculation_profile_sha256: "b".repeat(64),
job: {},
source: { session_id: sourceSessionId },
run_definition: { definition_sha256: definitionSha256 },
result_package: {
manifest_sha256: "c".repeat(64),
artifact_manifest_id: "d".repeat(64),
},
replay_capability: portableCapabilityDocument(),
storage: {},
method: {},
},
});
}
test("Observatory admits only the exact typed canonical recorded run", () => { test("Observatory admits only the exact typed canonical recorded run", () => {
const binding = observatoryRecordedRunBinding(resultId, lab()); const binding = observatoryRecordedRunBinding(resultId, lab());
assert.deepEqual(binding, { assert.deepEqual(binding, {
@@ -167,6 +218,67 @@ test("Observatory rechecks the selected source and sealed review before mounting
); );
}); });
test("Observatory selects and strictly decodes a portable result viewer by capability", async () => {
const portableResultId = "portable-result-a";
const definitionSha256 = "e".repeat(64);
const binding = observatoryRecordedRunBinding(
portableResultId,
portableLab(portableResultId, definitionSha256),
);
assert.deepEqual(binding, {
kind: "portable-result-review",
evidenceSessionId: portableResultId,
sourceSessionId,
resultId: portableResultId,
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
definitionSha256,
});
let requestedUrl = null;
const review = await fetchObservatoryRecordedRunReview(binding, {
selectedSourceSessionId: sourceSessionId,
fetcher: async (url) => {
requestedUrl = url;
return {
ok: true,
status: 200,
json: async () => ({
schema_version: "missioncore.observatory-portable-result-view/v1",
result_id: portableResultId,
source_session_id: sourceSessionId,
result_kind: "recorded-route-analysis",
definition_sha256: definitionSha256,
viewer_capability: portableCapabilityDocument(),
calculation_profile: { name: "M4.9" },
artifact_manifest_id: "d".repeat(64),
artifacts: [{
role: "result-document",
media_type: "application/json",
sha256: "f".repeat(64),
byte_length: 42,
}],
result_document: { verdict: "reviewed" },
}),
};
},
});
assert.equal(
requestedUrl,
`/api/v1/observatory/portable-results/${portableResultId}`,
);
assert.equal(review.kind, "portable-result");
assert.deepEqual(review.resultDocument, { verdict: "reviewed" });
assert.deepEqual(review.artifacts, [{
role: "result-document",
mediaType: "application/json",
sha256: "f".repeat(64),
byteLength: 42,
}]);
});
test("Observatory run admission remains metadata-only until explicit UI activation", async () => { test("Observatory run admission remains metadata-only until explicit UI activation", async () => {
const source = await readFile( const source = await readFile(
new URL("../src/core/observatory/recordedRun.ts", import.meta.url), new URL("../src/core/observatory/recordedRun.ts", import.meta.url),
@@ -99,6 +99,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/); assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
assert.match(workspace, /Открыть визуальный разбор/); assert.match(workspace, /Открыть визуальный разбор/);
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/); assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
assert.match(workspace, /<summary>Документ результата<\/summary>/);
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
assert.match(workspace, /Проверяем точную связь результата/); assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/); assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/); assert.match(workspace, /Повторить/);
@@ -247,12 +251,18 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
workspace, workspace,
/"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/, /"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/,
); );
assert.doesNotMatch(setupHook, /Promise\.allSettled/); assert.match(setupHook, /Promise\.all\(\[legacyResult, portableResult\]\)/);
assert.match(setupHook, /publishSetupCatalog\(\s*legacyCatalog/);
assert.match(setupHook, /preserveUnknownSelection: true/);
assert.match( assert.match(
setupHook, setupHook,
/portableProfileNames[\s\S]*legacy\.setups\.filter\([\s\S]*!portableProfileNames\.has\(setup\.displayName\)[\s\S]*\.\.\.portable\.setups/, /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(setupHook, /selectedSetupId, sourceSessionId/);
assert.match(workspace, /Worker готов к проверке/); assert.match(workspace, /Worker готов к проверке/);
@@ -270,7 +280,11 @@ test("Observatory keeps one compact selector axis without the obsolete setup det
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/); assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
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.doesNotMatch(`${workspace}\n${jobsHook}`, /setInterval|setTimeout/); assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
assert.match(jobsHook, /globalThis\.setTimeout/);
assert.doesNotMatch(jobsHook, /setInterval/);
assert.match(workspace, /job\.publication\.state === "published"/);
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
assert.match( assert.match(
styles, styles,
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/, /\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,