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:
@@ -1,4 +1,4 @@
|
||||
export interface ObservationLabReplayCapability {
|
||||
export interface ObservationCanonicalLabReplayCapability {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1";
|
||||
kind: "canonical-recorded-rerun";
|
||||
viewerProfile: "recorded-session";
|
||||
@@ -7,6 +7,19 @@ export interface ObservationLabReplayCapability {
|
||||
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 {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -31,6 +44,19 @@ const CANONICAL_PROVENANCE_KEYS = new Set([
|
||||
"authority",
|
||||
"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([
|
||||
"commands_enabled",
|
||||
"navigation_or_safety_accepted",
|
||||
@@ -80,32 +106,53 @@ export function decodeLabReplayCapability(
|
||||
}
|
||||
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
value.schema_version !== "missioncore.observation-lab-replay-capability/v1"
|
||||
|| value.kind !== "canonical-recorded-rerun"
|
||||
|| value.viewer_profile !== "recorded-session"
|
||||
|| value.timeline !== "session_time"
|
||||
|| value.activation !== "explicit"
|
||||
value.activation !== "explicit"
|
||||
|| value.commands_enabled !== false
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
|
||||
);
|
||||
}
|
||||
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/v1"
|
||||
&& value.kind === "canonical-recorded-rerun"
|
||||
&& value.viewer_profile === "recorded-session"
|
||||
&& value.timeline === "session_time"
|
||||
) {
|
||||
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(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
): ObservationLabReplayCapability {
|
||||
): ObservationCanonicalLabReplayCapability {
|
||||
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
|
||||
@@ -176,7 +223,7 @@ export function decodeCanonicalLabReplayCapabilityProvenance(
|
||||
);
|
||||
}
|
||||
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||
if (capability === null) {
|
||||
if (capability === null || capability.kind !== "canonical-recorded-rerun") {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
|
||||
);
|
||||
@@ -184,6 +231,44 @@ export function decodeCanonicalLabReplayCapabilityProvenance(
|
||||
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. */
|
||||
export function decodeRollingCanonicalLabReplayCapability(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
|
||||
@@ -115,11 +115,18 @@ export async function deleteObservatoryLabProjection(
|
||||
|
||||
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||
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 (
|
||||
binding.kind !== "canonical-recorded-rerun"
|
||||
!admittedViewer
|
||||
|| binding.activation !== "explicit"
|
||||
|| binding.viewerProfile !== "recorded-session"
|
||||
|| binding.timeline !== "session_time"
|
||||
|| binding.resultId !== sessionId
|
||||
|| binding.sourceSessionId === sessionId
|
||||
|| !SAFE_SESSION_ID.test(sessionId)
|
||||
|
||||
@@ -14,6 +14,12 @@ export type ObservatoryRecordedJobState =
|
||||
| "failed"
|
||||
| "reconciliation-required";
|
||||
|
||||
export type ObservatoryRecordedJobPublicationState =
|
||||
| "not-required"
|
||||
| "pending"
|
||||
| "failed"
|
||||
| "published";
|
||||
|
||||
export interface ObservatoryRecordedJob {
|
||||
readonly jobId: string;
|
||||
readonly idempotencyKey: string;
|
||||
@@ -24,6 +30,12 @@ export interface ObservatoryRecordedJob {
|
||||
readonly restartFromZero: boolean;
|
||||
readonly resultId: 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 updatedAtUtc: string;
|
||||
}
|
||||
@@ -117,12 +129,42 @@ export async function submitObservatoryRecordedJob(
|
||||
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 {
|
||||
const row = record(value, "расчёт");
|
||||
exactKeys(row, [
|
||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||
"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",
|
||||
"updated_at_utc",
|
||||
], "расчёт");
|
||||
@@ -161,6 +203,21 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
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");
|
||||
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 {
|
||||
jobId: text(row.job_id, "job_id"),
|
||||
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
|
||||
@@ -173,6 +230,12 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
terminalMessage: terminal === null || terminal.message === null
|
||||
? null
|
||||
: 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"),
|
||||
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
|
||||
};
|
||||
|
||||
@@ -6,23 +6,62 @@ import {
|
||||
} from "../laboratory/vegetationShadow";
|
||||
import type { ObservationLabInstance } from "../observation/sessionArchive";
|
||||
import {
|
||||
decodeLabReplayCapability,
|
||||
decodeCanonicalLabReplayCapabilityProvenance,
|
||||
decodePortableLabReplayCapabilityProvenance,
|
||||
ObservationLabReplayCapabilityContractError,
|
||||
} from "../observation/labReplayCapability";
|
||||
|
||||
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
|
||||
|
||||
export interface ObservatoryRecordedRunBinding {
|
||||
readonly kind: "canonical-recorded-rerun";
|
||||
interface ObservatoryRecordedRunBindingBase {
|
||||
readonly evidenceSessionId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly resultId: string;
|
||||
readonly viewerProfile: "recorded-session";
|
||||
readonly timeline: "session_time";
|
||||
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 {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -36,6 +75,44 @@ export function observatoryRecordedRunBinding(
|
||||
): ObservatoryRecordedRunBinding | null {
|
||||
const capability = lab.replayCapability;
|
||||
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 {
|
||||
decodeCanonicalLabReplayCapabilityProvenance(
|
||||
lab.provenance,
|
||||
@@ -87,25 +164,31 @@ export async function fetchObservatoryRecordedRunReview(
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<VegetationFullRouteReview> {
|
||||
): Promise<ObservatoryRecordedRunReview> {
|
||||
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запуск не связан с выбранной исходной сессией.",
|
||||
);
|
||||
}
|
||||
if (binding.kind === "portable-result-review") {
|
||||
return fetchPortableResultReview(binding, { fetcher, signal });
|
||||
}
|
||||
const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
return admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
selectedSourceSessionId,
|
||||
result,
|
||||
);
|
||||
return {
|
||||
kind: "canonical-recorded-rerun",
|
||||
review: admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
selectedSourceSessionId,
|
||||
result,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function admitObservatoryRecordedRunReview(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
binding: ObservatoryCanonicalRecordedRunBinding,
|
||||
selectedSourceSessionId: string,
|
||||
result: VegetationShadowResult,
|
||||
): VegetationFullRouteReview {
|
||||
@@ -127,3 +210,126 @@ export function admitObservatoryRecordedRunReview(
|
||||
}
|
||||
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");
|
||||
setError(null);
|
||||
setPreflight({ kind: "idle" });
|
||||
const portableResult = fetchObservatoryPortableLaboratorySetups(
|
||||
sourceSessionId,
|
||||
{ signal: request.signal },
|
||||
).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason: unknown) => ({ status: "rejected" as const, reason }),
|
||||
const legacyResult = settled(
|
||||
fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal }),
|
||||
);
|
||||
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||
.then(async (legacyCatalog) => {
|
||||
const portableResult = settled(
|
||||
fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal }),
|
||||
);
|
||||
void Promise.all([legacyResult, portableResult])
|
||||
.then(([legacy, portable]) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
publishSetupCatalog(
|
||||
legacyCatalog,
|
||||
setCatalog,
|
||||
setSelectedSetupId,
|
||||
{ preserveUnknownSelection: true },
|
||||
);
|
||||
setState("ready");
|
||||
const optionalPortable = await portableResult;
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
if (optionalPortable.status === "fulfilled") {
|
||||
if (legacy.status === "fulfilled" && portable.status === "fulfilled") {
|
||||
try {
|
||||
publishSetupCatalog(
|
||||
mergeSetupCatalogs(legacyCatalog, optionalPortable.value),
|
||||
mergeSetupCatalogs(legacy.value, portable.value),
|
||||
setCatalog,
|
||||
setSelectedSetupId,
|
||||
);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
|
||||
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;
|
||||
}
|
||||
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
|
||||
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
|
||||
: "Каталог сетапов недоступен.");
|
||||
setState("ready");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
@@ -188,6 +198,13 @@ function catalogErrorMessage(caught: unknown, fallback: string): string {
|
||||
: 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,
|
||||
@@ -195,13 +212,9 @@ function mergeSetupCatalogs(
|
||||
if (legacy.sourceSessionId !== portable.sourceSessionId) {
|
||||
throw new Error("Каталоги сетапов относятся к разным исходным сессиям.");
|
||||
}
|
||||
const portableProfileNames = new Set(
|
||||
portable.setups.map((setup) => setup.displayName),
|
||||
);
|
||||
const portableSetupIds = new Set(portable.setups.map((setup) => setup.setupId));
|
||||
const setups = [
|
||||
...legacy.setups.filter(
|
||||
(setup) => !portableProfileNames.has(setup.displayName),
|
||||
),
|
||||
...legacy.setups.filter((setup) => !portableSetupIds.has(setup.setupId)),
|
||||
...portable.setups,
|
||||
];
|
||||
if (new Set(setups.map((setup) => setup.setupId)).size !== setups.length) {
|
||||
|
||||
@@ -2,16 +2,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryRecordedJobs,
|
||||
retryObservatoryRecordedJobPublication,
|
||||
submitObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} 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([
|
||||
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||
"reconciliation-required",
|
||||
]);
|
||||
const POLL_INTERVAL_MS = 1_500;
|
||||
|
||||
export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) {
|
||||
const [jobs, setJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
|
||||
@@ -60,6 +69,16 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
|
||||
() => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null,
|
||||
[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(() => {
|
||||
if (!latestJob || activeJob) return;
|
||||
@@ -108,7 +127,47 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
|
||||
}
|
||||
}, [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 {
|
||||
|
||||
@@ -178,6 +178,7 @@ export function ObservatoryWorkspace({
|
||||
>(null);
|
||||
const overviewRef = useRef<HTMLElement | null>(null);
|
||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||
const refreshedPublishedJobRef = useRef<string | null>(null);
|
||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||
|
||||
const closeReplay = useCallback(() => {
|
||||
@@ -244,14 +245,23 @@ export function ObservatoryWorkspace({
|
||||
const presentedJob = recordedJobsController.activeJob
|
||||
?? recordedJobsController.latestJob;
|
||||
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;
|
||||
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";
|
||||
const canSubmitRecordedJob = queueSubmissionAllowed
|
||||
&& recordedJobsController.state === "ready"
|
||||
&& recordedJobsController.activeJob === null;
|
||||
@@ -267,6 +277,16 @@ export function ObservatoryWorkspace({
|
||||
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||
) ?? 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(() => {
|
||||
if (
|
||||
replayEvidenceId === null
|
||||
@@ -496,10 +516,14 @@ export function ObservatoryWorkspace({
|
||||
</StatusBadge>
|
||||
) : recordedJobsController.state === "submitting" ? (
|
||||
<StatusBadge tone="neutral">Ставим в очередь</StatusBadge>
|
||||
) : recordedJobsController.state === "retrying-publication" ? (
|
||||
<StatusBadge tone="neutral">Повторяем публикацию</StatusBadge>
|
||||
) : presentedJobStatus ? (
|
||||
<StatusBadge
|
||||
tone={presentedJobStatus.tone}
|
||||
title={presentedJob?.terminalMessage ?? undefined}
|
||||
title={presentedJob?.publication.error
|
||||
?? presentedJob?.terminalMessage
|
||||
?? undefined}
|
||||
>
|
||||
{presentedJobStatus.label}
|
||||
</StatusBadge>
|
||||
@@ -542,6 +566,16 @@ export function ObservatoryWorkspace({
|
||||
Рассчитать
|
||||
</Button>
|
||||
) : null}
|
||||
{presentedJob?.publication.state === "failed"
|
||||
&& recordedJobsController.state !== "retrying-publication" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => void recordedJobsController.retryPublication()}
|
||||
>
|
||||
Повторить публикацию
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
@@ -610,6 +644,20 @@ export function ObservatoryWorkspace({
|
||||
<dt>Чтение</dt>
|
||||
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
|
||||
</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>
|
||||
<div className="observatory-session-summary__end">
|
||||
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
||||
@@ -738,12 +786,20 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "ready" ? (
|
||||
<section className="observatory-replay" aria-label="Канонический визуальный разбор">
|
||||
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ</span>
|
||||
<span className="section-eyebrow">
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
|
||||
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
|
||||
</span>
|
||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||
<p>Записанный маршрут синхронизирован по общей временной шкале.</p>
|
||||
<p>
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||
: "Показан проверенный документ результата и связанные с ним артефакты."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -754,10 +810,24 @@ export function ObservatoryWorkspace({
|
||||
Закрыть разбор
|
||||
</Button>
|
||||
</header>
|
||||
<CanonicalVegetationRerunReplay
|
||||
resultId={replay.binding.resultId}
|
||||
review={replay.review}
|
||||
/>
|
||||
{replay.review.kind === "canonical-recorded-rerun" ? (
|
||||
<CanonicalVegetationRerunReplay
|
||||
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>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user