feat(observatory): add portable calculation profiles
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user