feat(observatory): add portable calculation profiles

This commit is contained in:
DCCONSTRUCTIONS
2026-08-31 15:42:56 +03:00
parent d1b75efcea
commit 9beb534108
75 changed files with 24419 additions and 348 deletions
@@ -0,0 +1,140 @@
export const OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA =
"missioncore.observatory-calculation-profile/v1" as const;
export type ObservationLabCalculationProfileOrigin =
| "archived-definition"
| "existing-result";
export interface ObservationLabCalculationProfile {
readonly schemaVersion: typeof OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA;
readonly setupId: string;
readonly displayName: string;
readonly origin: ObservationLabCalculationProfileOrigin;
readonly definitionId: string | null;
readonly definitionVersion: number | null;
readonly definitionSha256: string | null;
}
export class ObservationLabCalculationProfileContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationLabCalculationProfileContractError";
}
}
const PROFILE_KEYS = new Set([
"schema_version",
"setup_id",
"display_name",
"origin",
"definition_id",
"definition_version",
"definition_sha256",
]);
const IDENTIFIER = /^[a-z][a-z0-9-]{2,95}$/;
const SHA256 = /^[a-f0-9]{64}$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireText(value: unknown, field: string, maximum: number): string {
if (
typeof value !== "string"
|| value.length === 0
|| value.length > maximum
|| value !== value.trim()
) {
throw new ObservationLabCalculationProfileContractError(
`Поле calculation_profile.${field} должно быть непустой строкой.`,
);
}
return value;
}
function requireIdentifier(value: unknown, field: string): string {
const identifier = requireText(value, field, 96);
if (!IDENTIFIER.test(identifier)) {
throw new ObservationLabCalculationProfileContractError(
`Поле calculation_profile.${field} содержит некорректный идентификатор.`,
);
}
return identifier;
}
export function decodeObservationLabCalculationProfile(
value: unknown,
sessionId: string,
): ObservationLabCalculationProfile | null {
if (value === null) return null;
if (!isRecord(value) || Object.keys(value).some((key) => !PROFILE_KEYS.has(key))) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} нарушил контракт полей.`,
);
}
if (
Object.keys(value).length !== PROFILE_KEYS.size
|| value.schema_version !== OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестную версию.`,
);
}
const origin = value.origin;
if (origin !== "archived-definition" && origin !== "existing-result") {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестное происхождение.`,
);
}
const definitionId = value.definition_id === null
? null
: requireIdentifier(value.definition_id, "definition_id");
const definitionVersion = value.definition_version === null
? null
: value.definition_version;
if (
definitionVersion !== null
&& (
typeof definitionVersion !== "number"
|| !Number.isInteger(definitionVersion)
|| definitionVersion < 1
)
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} содержит некорректную версию определения.`,
);
}
const definitionSha256 = value.definition_sha256 === null
? null
: requireText(value.definition_sha256, "definition_sha256", 64);
if (definitionSha256 !== null && !SHA256.test(definitionSha256)) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} не содержит SHA-256 определения.`,
);
}
const definitionFieldCount = [
definitionId,
definitionVersion,
definitionSha256,
].filter((entry) => entry !== null).length;
if (
(origin === "existing-result" && definitionFieldCount !== 0)
|| (origin === "archived-definition" && definitionFieldCount !== 3)
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} содержит неполную идентичность определения.`,
);
}
return {
schemaVersion: OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA,
setupId: requireIdentifier(value.setup_id, "setup_id"),
displayName: requireText(value.display_name, "display_name", 256),
origin,
definitionId,
definitionVersion,
definitionSha256,
};
}
@@ -7,8 +7,14 @@ import {
ObservationLabReplayCapabilityContractError,
type ObservationLabReplayCapability,
} from "./labReplayCapability";
import {
decodeObservationLabCalculationProfile,
ObservationLabCalculationProfileContractError,
type ObservationLabCalculationProfile,
} from "./labCalculationProfile";
export type { ObservationLabReplayCapability } from "./labReplayCapability";
export type { ObservationLabCalculationProfile } from "./labCalculationProfile";
export type ObservationSessionStatus =
| "recording"
@@ -29,6 +35,7 @@ export interface ObservationLabInstance {
runCreatedAtUtc: string;
publishedAtUtc: string;
replayCapability: ObservationLabReplayCapability | null;
calculationProfile: ObservationLabCalculationProfile | null;
provenance: Readonly<Record<string, unknown>>;
}
@@ -175,7 +182,11 @@ const LEGACY_LAB_KEYS = new Set([
"published_at_utc",
"provenance",
]);
const LAB_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]);
const LAB_V2_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]);
const LAB_V3_KEYS = new Set([
...LAB_V2_KEYS,
"calculation_profile",
]);
const CATALOG_PREPARATION_KEYS = new Set([
"preparation_id",
"state",
@@ -408,9 +419,17 @@ function decodeLabInstance(
value,
"replay_capability",
);
const hasCalculationProfile = Object.prototype.hasOwnProperty.call(
value,
"calculation_profile",
);
assertExactKeys(
value,
hasTypedCapability ? LAB_KEYS : LEGACY_LAB_KEYS,
hasCalculationProfile
? LAB_V3_KEYS
: hasTypedCapability
? LAB_V2_KEYS
: LEGACY_LAB_KEYS,
`LAB-привязка сессии ${sessionId}`,
);
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
@@ -459,6 +478,17 @@ function decodeLabInstance(
}
throw error;
}
let calculationProfile: ObservationLabCalculationProfile | null;
try {
calculationProfile = hasCalculationProfile
? decodeObservationLabCalculationProfile(value.calculation_profile, sessionId)
: null;
} catch (error) {
if (error instanceof ObservationLabCalculationProfileContractError) {
throw new ObservationSessionContractError(error.message);
}
throw error;
}
return {
labId,
sourceSessionId,
@@ -475,6 +505,7 @@ function decodeLabInstance(
`lab(${sessionId}).published_at_utc`,
),
replayCapability,
calculationProfile,
provenance: value.provenance,
};
}
@@ -1211,7 +1242,7 @@ export async function fetchObservationSessionCatalog({
queryParameters.set("limit", String(Number(limit)));
}
if (scope !== "all") queryParameters.set("scope", scope);
if (scope === "laboratory") queryParameters.set("lab_contract", "v2");
if (scope === "laboratory") queryParameters.set("lab_contract", "v3");
const serializedQuery = queryParameters.toString();
const query = serializedQuery ? `?${serializedQuery}` : "";
let response: Response;
@@ -1,5 +1,3 @@
import { preflightObservatoryPortableLaboratorySetup } from "./portableLaboratorySetups";
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
@@ -14,6 +12,7 @@ export type ObservatoryLaboratorySetupOrigin =
export type ObservatoryLaboratorySetupAction =
| "open-existing"
| "open-legacy"
| "check"
| "blocked";
export interface ObservatoryLaboratoryRunDefinition {
@@ -62,7 +61,7 @@ export interface ObservatoryLaboratorySetup {
};
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
readonly preflight: {
readonly outcome: "existing" | "blocked";
readonly outcome: "existing" | "ready" | "blocked";
readonly action: ObservatoryLaboratorySetupAction;
readonly reason: string;
readonly submissionAllowed: boolean;
@@ -79,6 +78,7 @@ export interface ObservatoryLaboratoryRunPreflight {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string | null;
readonly checkSha256: string | null;
readonly outcome: "existing" | "queueable" | "blocked";
readonly submissionAllowed: boolean;
readonly checks: readonly {
@@ -142,9 +142,6 @@ export async function preflightObservatoryLaboratorySetup(
fetcher?: ObservatoryLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratoryRunPreflight> {
if (setup.origin === "portable-definition") {
return preflightObservatoryPortableLaboratorySetup(sourceSessionId, setup);
}
const response = await request(
fetcher,
"/api/v1/observatory/run-preflights",
@@ -297,29 +294,55 @@ function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedRe
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
const row = record(value, "preflight");
exactKeys(row, [
const baseKeys = [
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
], "preflight");
] as const;
const hasPortableCheck = Object.hasOwn(row, "check_sha256");
exactKeys(
row,
hasPortableCheck ? [...baseKeys, "check_sha256"] : baseKeys,
"preflight",
);
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
observationAuthority(row.authority);
const digest = row.definition_sha256;
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
}
const checkDigest = hasPortableCheck ? row.check_sha256 : null;
if (
checkDigest !== null
&& (typeof checkDigest !== "string" || !SHA256.test(checkDigest))
) {
throw new ObservatoryLaboratorySetupContractError(
"Некорректный check digest preflight.",
);
}
const outcome = oneOf(
row.outcome,
["existing", "queueable", "blocked"] as const,
"preflight outcome",
);
const submissionAllowed = boolean(
row.submission_allowed,
"preflight submission_allowed",
);
if (
submissionAllowed !== (outcome === "queueable")
|| (hasPortableCheck && outcome === "queueable" && checkDigest === null)
) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight содержит противоречивое разрешение постановки в очередь.",
);
}
return {
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", "queueable", "blocked"] as const,
"preflight outcome",
),
submissionAllowed: boolean(
row.submission_allowed,
"preflight submission_allowed",
),
checkSha256: checkDigest,
outcome,
submissionAllowed,
checks: array(row.checks, "preflight checks").map((item) => {
const check = record(item, "preflight check");
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
@@ -55,7 +55,11 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
const executor = record(row.executor, "portable executor");
exactKeys(executor, ["contour_id", "ready", "reason", "state"], "portable executor");
exactKeys(
executor,
["contour_id", "ready", "reason", "reason_code", "state"],
"portable executor",
);
const executorState = oneOf(
executor.state,
["not-installed", "ready"] as const,
@@ -70,20 +74,43 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
const executorReason = executor.reason === null
? "Исполнитель установлен."
: text(executor.reason, "portable executor reason");
const executorReasonCode = executor.reason_code === null
? "portable-executor-ready"
: text(executor.reason_code, "portable executor reason_code");
if (
(executorReady && (executor.reason !== null || executor.reason_code !== null))
|| (!executorReady && (executor.reason === null || executor.reason_code === null))
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable executor: причина недоступности противоречит состоянию.",
);
}
const preflight = record(row.preflight, "portable preflight");
exactKeys(preflight, [
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
], "portable preflight");
exact(preflight.outcome, "blocked", "portable preflight outcome");
exact(preflight.action, "blocked", "portable preflight action");
const preflightOutcome = oneOf(
preflight.outcome,
["ready", "blocked"] as const,
"portable preflight outcome",
);
const preflightAction = oneOf(
preflight.action,
["check", "blocked"] as const,
"portable preflight action",
);
const submissionAllowed = boolean(
preflight.submission_allowed,
"portable preflight submission_allowed",
);
if (submissionAllowed) {
if (
submissionAllowed !== (preflightOutcome === "ready")
|| (preflightOutcome === "ready" && preflightAction !== "check")
|| (preflightOutcome === "blocked" && preflightAction !== "blocked")
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable preflight: постановка в очередь ещё не поддерживается.",
"Portable preflight: состояние запуска противоречиво.",
);
}
const existingResults = array(row.existing_results, "portable existing_results");
@@ -112,15 +139,13 @@ function decodePortableSetup(value: unknown): ObservatoryLaboratorySetup {
executor: {
contourId: text(executor.contour_id, "portable executor contour_id"),
state: executorState,
reasonCode: executorReady
? "portable-executor-ready"
: "portable-executor-not-installed",
reasonCode: executorReasonCode,
reason: executorReason,
},
preservedResults: [],
preflight: {
outcome: "blocked",
action: "blocked",
outcome: preflightOutcome,
action: preflightAction,
reason: text(preflight.reason, "portable preflight reason"),
submissionAllowed,
existingResultIds: [],
@@ -1,7 +1,5 @@
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
import type {
ObservatoryLaboratoryRunPreflight,
ObservatoryLaboratorySetup,
ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
@@ -46,61 +44,6 @@ export async function fetchObservatoryPortableLaboratorySetups(
return catalog;
}
export function preflightObservatoryPortableLaboratorySetup(
sourceSessionId: string,
setup: ObservatoryLaboratorySetup,
): ObservatoryLaboratoryRunPreflight {
const definitionSha256 = setup.runDefinition?.definitionSha256 ?? null;
if (setup.origin !== "portable-definition" || definitionSha256 === null) {
throw new ObservatoryPortableLaboratorySetupContractError(
"Portable-сетап не содержит RunDefinition.",
);
}
if (
setup.preflight.outcome !== "blocked"
|| setup.preflight.action !== "blocked"
|| setup.preflight.existingResultIds.length > 0
|| setup.preservedResults.length > 0
) {
throw new ObservatoryPortableLaboratorySetupContractError(
"Portable-result ещё не имеет проверяемой привязки к RunDefinition.",
);
}
return {
sourceSessionId,
setupId: setup.setupId,
definitionSha256,
outcome: "blocked",
submissionAllowed: false,
checks: [
{
checkId: "source-compatibility",
outcome: setup.compatibility.compatible ? "pass" : "fail",
reasonCode: setup.compatibility.compatible
? "source-capability-admitted"
: "source-capability-blocked",
message: setup.compatibility.compatible
? "Запись соответствует portable-профилю LAB V1."
: setup.compatibility.reasons[0]?.message
?? "Запись не соответствует portable-профилю LAB V1.",
},
{
checkId: "executor",
outcome: setup.executor.state === "ready" ? "pass" : "fail",
reasonCode: setup.executor.reasonCode,
message: setup.executor.reason,
},
{
checkId: "durable-queue",
outcome: "fail",
reasonCode: "portable-dispatch-unavailable",
message: setup.preflight.reason,
},
],
existingResultIds: [],
};
}
async function request(
fetcher: ObservatoryPortableLaboratorySetupFetch,
input: string,
@@ -79,6 +79,10 @@ export async function submitObservatoryRecordedJob(
sourceSessionId: string,
setupId: string,
idempotencyKey: string,
portableBinding: {
readonly definitionSha256: string;
readonly checkSha256: string;
} | null,
{
signal,
fetcher = globalThis.fetch,
@@ -95,6 +99,10 @@ export async function submitObservatoryRecordedJob(
idempotency_key: idempotencyKey,
source_session_id: sourceSessionId,
setup_id: setupId,
...(portableBinding === null ? {} : {
definition_sha256: portableBinding.definitionSha256,
check_sha256: portableBinding.checkSha256,
}),
}),
signal,
});
@@ -112,7 +120,7 @@ export async function submitObservatoryRecordedJob(
function decodeJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "created_at_utc", "executor",
"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",
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
@@ -137,6 +145,18 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"succeeded", "failed", "reconciliation-required",
] as const, "state");
if (row.claim_lease !== null) {
const lease = record(row.claim_lease, "claim_lease");
exactKeys(
lease,
["claimed_at_utc", "expires_at_utc", "heartbeat_at_utc", "renewal_count"],
"claim_lease",
);
text(lease.claimed_at_utc, "claim_lease.claimed_at_utc");
text(lease.expires_at_utc, "claim_lease.expires_at_utc");
text(lease.heartbeat_at_utc, "claim_lease.heartbeat_at_utc");
nonNegativeInteger(lease.renewal_count, "claim_lease.renewal_count");
}
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");
@@ -220,6 +240,13 @@ function boolean(value: unknown, label: string): boolean {
return value;
}
function nonNegativeInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 0) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидалось целое число.`);
}
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
return expected;
@@ -73,7 +73,7 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
caught,
"Portable-каталог LAB V1 нарушил локальный контракт.",
"Portable-каталог профилей нарушил локальный контракт.",
));
}
return;
@@ -81,7 +81,7 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
publishSetupCatalog(legacyCatalog, setCatalog, setSelectedSetupId);
setError(catalogErrorMessage(
optionalPortable.reason,
"Portable-каталог LAB V1 недоступен.",
"Portable-каталог профилей недоступен.",
));
})
.catch((caught: unknown) => {
@@ -195,7 +195,15 @@ function mergeSetupCatalogs(
if (legacy.sourceSessionId !== portable.sourceSessionId) {
throw new Error("Каталоги сетапов относятся к разным исходным сессиям.");
}
const setups = [...legacy.setups, ...portable.setups];
const portableProfileNames = new Set(
portable.setups.map((setup) => setup.displayName),
);
const setups = [
...legacy.setups.filter(
(setup) => !portableProfileNames.has(setup.displayName),
),
...portable.setups,
];
if (new Set(setups.map((setup) => setup.setupId)).size !== setups.length) {
throw new Error("Каталоги сетапов содержат повторяющиеся идентификаторы.");
}
@@ -68,7 +68,12 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const submit = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
const submit = useCallback(async (
portableBinding: {
readonly definitionSha256: string;
readonly checkSha256: string;
} | null = null,
): Promise<ObservatoryRecordedJob | null> => {
if (!sourceSessionId || !setupId || state === "submitting") return null;
if (activeJob) return activeJob;
requestRef.current?.abort();
@@ -80,9 +85,13 @@ export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: str
setState("submitting");
setError(null);
try {
const job = await submitObservatoryRecordedJob(sourceSessionId, setupId, key, {
signal: request.signal,
});
const job = await submitObservatoryRecordedJob(
sourceSessionId,
setupId,
key,
portableBinding,
{ signal: request.signal },
);
if (request.signal.aborted || requestRef.current !== request) return null;
setJobs((current) => [job, ...current.filter((candidate) => candidate.jobId !== job.jobId)]);
setState("ready");
@@ -150,6 +150,11 @@ function mutationErrorMessage(error: unknown): string {
: "Не удалось изменить лабораторный результат в Обсерватории.";
}
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
const profileName = evidence.lab.calculationProfile?.displayName;
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
}
export function ObservatoryWorkspace({
definition,
}: {
@@ -214,7 +219,7 @@ export function ObservatoryWorkspace({
? "Готовый результат"
: setup.origin === "portable-definition"
? setup.executor.state === "ready"
? "Запись совместима · Worker установлен, запуск закрыт"
? "Запись совместима · Worker готов к проверке"
: "Запись совместима · Worker не установлен"
: "Совместимый архивный сетап"
: "Несовместим с выбранной сессией",
@@ -512,8 +517,8 @@ export function ObservatoryWorkspace({
>
{setupController.selectedSetup.compatibility.compatible
? setupController.selectedSetup.executor.state === "not-installed"
? "Worker LAB V1 не установлен"
: "Запуск LAB V1 недоступен"
? "Worker-профиль не установлен"
: "Запуск профиля недоступен"
: "Запись несовместима"}
</StatusBadge>
) : null}
@@ -521,7 +526,18 @@ export function ObservatoryWorkspace({
<Button
size="compact"
variant="primary"
onClick={() => { void recordedJobsController.submit(); }}
onClick={() => {
void recordedJobsController.submit(
setupController.selectedSetup?.origin === "portable-definition"
&& runPreflight?.definitionSha256
&& runPreflight.checkSha256
? {
definitionSha256: runPreflight.definitionSha256,
checkSha256: runPreflight.checkSha256,
}
: null,
);
}}
>
Рассчитать
</Button>
@@ -631,7 +647,7 @@ export function ObservatoryWorkspace({
</span>
<div className="observatory-evidence-card__copy">
<strong>{evidence.lab.labId}</strong>
<span>{evidence.label}</span>
<span>{evidenceResultSubtitle(evidence)}</span>
<small>
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
</small>