feat: add Polygon UI-0 run view
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
export type PolygonRunState =
|
||||
| "admitted"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "stopping"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted";
|
||||
|
||||
export interface PolygonRunSummary {
|
||||
runId: string;
|
||||
episodeId: string;
|
||||
kind: string;
|
||||
state: PolygonRunState;
|
||||
createdAtUtc: string;
|
||||
startedAtUtc: string | null;
|
||||
endedAtUtc: string | null;
|
||||
terminalReason: string | null;
|
||||
scenarioGeneration: string;
|
||||
profileGeneration: string;
|
||||
missionCoreCommit: string;
|
||||
hostProfileId: string;
|
||||
reproducibilityTier: "R0" | "R1" | "R2";
|
||||
clockDomain: string;
|
||||
revision: number;
|
||||
providerIds: string[];
|
||||
artifactCount: number;
|
||||
}
|
||||
|
||||
export interface PolygonProviderPin {
|
||||
identifier: string;
|
||||
version: string;
|
||||
revision: string;
|
||||
digest: string | null;
|
||||
}
|
||||
|
||||
export interface PolygonAuthority {
|
||||
generation: number;
|
||||
commandTtlMaxNs: number;
|
||||
heartbeatTimeoutMonotonicNs: number;
|
||||
simulationOrShadowOnly: boolean;
|
||||
actuatorAuthority: boolean;
|
||||
navigationOrSafetyAccepted: boolean;
|
||||
directActuatorSetpointsAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface PolygonRun extends PolygonRunSummary {
|
||||
scenarioSha256: string;
|
||||
profileSha256: string;
|
||||
hostProfileSha256: string;
|
||||
seed: number;
|
||||
parentRunId: string | null;
|
||||
providers: PolygonProviderPin[];
|
||||
authority: PolygonAuthority;
|
||||
}
|
||||
|
||||
export interface PolygonRunEvent {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
eventType: string;
|
||||
observedAtUtc: string;
|
||||
hostMonotonicNs: number;
|
||||
simTimeNs: number | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PolygonRunArtifact {
|
||||
artifactId: string;
|
||||
kind: string;
|
||||
relativePath: string;
|
||||
sha256: string;
|
||||
byteLength: number;
|
||||
sourceOfRecord: boolean;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface PolygonRunCatalog {
|
||||
items: PolygonRunSummary[];
|
||||
total: number;
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface PolygonRunDetail {
|
||||
run: PolygonRun;
|
||||
events: PolygonRunEvent[];
|
||||
eventsTotal: number;
|
||||
eventsTruncated: boolean;
|
||||
commandCount: number;
|
||||
artifacts: PolygonRunArtifact[];
|
||||
limitations: string[];
|
||||
}
|
||||
|
||||
export interface PolygonRunRoute {
|
||||
active: boolean;
|
||||
runId: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class PolygonRunContractError extends Error {}
|
||||
|
||||
export class PolygonRunApiError extends Error {
|
||||
constructor(message: string, readonly status: number | null = null) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type PolygonRunFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const GIT_REVISION = /^[a-f0-9]{7,64}$/;
|
||||
const ISO_WITH_TIMEZONE = /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
const RUN_STATES = new Set<PolygonRunState>([
|
||||
"admitted",
|
||||
"starting",
|
||||
"running",
|
||||
"paused",
|
||||
"stopping",
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
]);
|
||||
const REPRODUCIBILITY_TIERS = new Set(["R0", "R1", "R2"]);
|
||||
const SUMMARY_KEYS = new Set([
|
||||
"run_id",
|
||||
"episode_id",
|
||||
"kind",
|
||||
"state",
|
||||
"created_at_utc",
|
||||
"started_at_utc",
|
||||
"ended_at_utc",
|
||||
"terminal_reason",
|
||||
"scenario_generation",
|
||||
"profile_generation",
|
||||
"mission_core_commit",
|
||||
"host_profile_id",
|
||||
"reproducibility_tier",
|
||||
"clock_domain",
|
||||
"revision",
|
||||
"provider_ids",
|
||||
"artifact_count",
|
||||
]);
|
||||
const RUN_KEYS = new Set([
|
||||
...SUMMARY_KEYS,
|
||||
"scenario_sha256",
|
||||
"profile_sha256",
|
||||
"host_profile_sha256",
|
||||
"seed",
|
||||
"parent_run_id",
|
||||
"providers",
|
||||
"authority",
|
||||
]);
|
||||
const PROVIDER_KEYS = new Set(["identifier", "version", "revision", "digest"]);
|
||||
const AUTHORITY_KEYS = new Set([
|
||||
"generation",
|
||||
"command_ttl_max_ns",
|
||||
"heartbeat_timeout_monotonic_ns",
|
||||
"simulation_or_shadow_only",
|
||||
"actuator_authority",
|
||||
"navigation_or_safety_accepted",
|
||||
"direct_actuator_setpoints_allowed",
|
||||
]);
|
||||
const EVENT_KEYS = new Set([
|
||||
"schema_version",
|
||||
"run_id",
|
||||
"sequence",
|
||||
"event_type",
|
||||
"observed_at_utc",
|
||||
"host_monotonic_ns",
|
||||
"sim_time_ns",
|
||||
"payload",
|
||||
]);
|
||||
const ARTIFACT_KEYS = new Set([
|
||||
"artifact_id",
|
||||
"kind",
|
||||
"relative_path",
|
||||
"sha256",
|
||||
"byte_length",
|
||||
"source_of_record",
|
||||
"sequence",
|
||||
]);
|
||||
const CATALOG_KEYS = new Set([
|
||||
"schema_version",
|
||||
"access",
|
||||
"items",
|
||||
"total",
|
||||
"limitations",
|
||||
]);
|
||||
const DETAIL_KEYS = new Set([
|
||||
"schema_version",
|
||||
"access",
|
||||
"run",
|
||||
"events",
|
||||
"events_total",
|
||||
"events_truncated",
|
||||
"commands",
|
||||
"artifacts",
|
||||
"limitations",
|
||||
]);
|
||||
const COMMAND_KEYS = new Set(["count", "content_exposed"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
label: string,
|
||||
) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
||||
throw new PolygonRunContractError(`${label} содержит неизвестные или отсутствующие поля.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string, maximum = 512): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new PolygonRunContractError(`Поле ${field} должно быть строкой.`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > maximum) {
|
||||
throw new PolygonRunContractError(`Поле ${field} имеет недопустимую длину.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireId(value: unknown, field: string): string {
|
||||
const identifier = requireString(value, field, 128);
|
||||
if (!SAFE_ID.test(identifier)) {
|
||||
throw new PolygonRunContractError(`Поле ${field} содержит небезопасный идентификатор.`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function requireInteger(value: unknown, field: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum
|
||||
) {
|
||||
throw new PolygonRunContractError(`Поле ${field} должно быть безопасным целым числом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireTimestamp(value: unknown, field: string): string {
|
||||
const timestamp = requireString(value, field, 64);
|
||||
if (!ISO_WITH_TIMEZONE.test(timestamp) || !Number.isFinite(Date.parse(timestamp))) {
|
||||
throw new PolygonRunContractError(`Поле ${field} должно быть ISO-датой с часовым поясом.`);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value: unknown, field: string): string | null {
|
||||
return value === null ? null : requireTimestamp(value, field);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string, maximum = 512): string | null {
|
||||
return value === null ? null : requireString(value, field, maximum);
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new PolygonRunContractError(`Поле ${field} должно быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireSha256(value: unknown, field: string): string {
|
||||
const digest = requireString(value, field, 64);
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new PolygonRunContractError(`Поле ${field} должно содержать SHA-256.`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function decodeLimitations(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > 16) {
|
||||
throw new PolygonRunContractError("Ограничения UI-0 должны быть ограниченным массивом.");
|
||||
}
|
||||
return value.map((entry, index) => requireString(entry, `limitations[${index}]`, 1_000));
|
||||
}
|
||||
|
||||
function decodeSummary(
|
||||
value: unknown,
|
||||
expectedKeys: ReadonlySet<string> = SUMMARY_KEYS,
|
||||
): PolygonRunSummary {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonRunContractError("Сводка прогона должна быть объектом.");
|
||||
}
|
||||
assertExactKeys(value, expectedKeys, "Сводка прогона");
|
||||
const stateValue = requireString(value.state, "state", 32);
|
||||
if (!RUN_STATES.has(stateValue as PolygonRunState)) {
|
||||
throw new PolygonRunContractError("Прогон содержит неизвестное состояние.");
|
||||
}
|
||||
const tier = requireString(value.reproducibility_tier, "reproducibility_tier", 2);
|
||||
if (!REPRODUCIBILITY_TIERS.has(tier)) {
|
||||
throw new PolygonRunContractError("Прогон содержит неизвестный уровень воспроизводимости.");
|
||||
}
|
||||
const commit = requireString(value.mission_core_commit, "mission_core_commit", 64);
|
||||
if (!GIT_REVISION.test(commit)) {
|
||||
throw new PolygonRunContractError("Прогон не содержит допустимую ревизию Mission Core.");
|
||||
}
|
||||
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
|
||||
throw new PolygonRunContractError("Поле provider_ids должно быть ограниченным массивом.");
|
||||
}
|
||||
const providerIds = value.provider_ids.map((entry, index) =>
|
||||
requireId(entry, `provider_ids[${index}]`));
|
||||
if (new Set(providerIds).size !== providerIds.length) {
|
||||
throw new PolygonRunContractError("Поле provider_ids содержит повторения.");
|
||||
}
|
||||
const startedAtUtc = optionalTimestamp(value.started_at_utc, "started_at_utc");
|
||||
const endedAtUtc = optionalTimestamp(value.ended_at_utc, "ended_at_utc");
|
||||
if (startedAtUtc && endedAtUtc && Date.parse(endedAtUtc) < Date.parse(startedAtUtc)) {
|
||||
throw new PolygonRunContractError("Прогон завершился раньше времени запуска.");
|
||||
}
|
||||
return {
|
||||
runId: requireId(value.run_id, "run_id"),
|
||||
episodeId: requireId(value.episode_id, "episode_id"),
|
||||
kind: requireId(value.kind, "kind"),
|
||||
state: stateValue as PolygonRunState,
|
||||
createdAtUtc: requireTimestamp(value.created_at_utc, "created_at_utc"),
|
||||
startedAtUtc,
|
||||
endedAtUtc,
|
||||
terminalReason: optionalString(value.terminal_reason, "terminal_reason"),
|
||||
scenarioGeneration: requireId(value.scenario_generation, "scenario_generation"),
|
||||
profileGeneration: requireId(value.profile_generation, "profile_generation"),
|
||||
missionCoreCommit: commit,
|
||||
hostProfileId: requireId(value.host_profile_id, "host_profile_id"),
|
||||
reproducibilityTier: tier as "R0" | "R1" | "R2",
|
||||
clockDomain: requireString(value.clock_domain, "clock_domain", 128),
|
||||
revision: requireInteger(value.revision, "revision"),
|
||||
providerIds,
|
||||
artifactCount: requireInteger(value.artifact_count, "artifact_count"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeProvider(value: unknown, index: number): PolygonProviderPin {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonRunContractError(`providers[${index}] должен быть объектом.`);
|
||||
}
|
||||
assertExactKeys(value, PROVIDER_KEYS, `providers[${index}]`);
|
||||
const digest = value.digest === null ? null : requireSha256(value.digest, `providers[${index}].digest`);
|
||||
return {
|
||||
identifier: requireId(value.identifier, `providers[${index}].identifier`),
|
||||
version: requireString(value.version, `providers[${index}].version`, 128),
|
||||
revision: requireString(value.revision, `providers[${index}].revision`, 128),
|
||||
digest,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeAuthority(value: unknown): PolygonAuthority {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonRunContractError("authority должен быть объектом.");
|
||||
}
|
||||
assertExactKeys(value, AUTHORITY_KEYS, "authority");
|
||||
return {
|
||||
generation: requireInteger(value.generation, "authority.generation", 1),
|
||||
commandTtlMaxNs: requireInteger(value.command_ttl_max_ns, "authority.command_ttl_max_ns", 1),
|
||||
heartbeatTimeoutMonotonicNs: requireInteger(
|
||||
value.heartbeat_timeout_monotonic_ns,
|
||||
"authority.heartbeat_timeout_monotonic_ns",
|
||||
1,
|
||||
),
|
||||
simulationOrShadowOnly: requireBoolean(
|
||||
value.simulation_or_shadow_only,
|
||||
"authority.simulation_or_shadow_only",
|
||||
),
|
||||
actuatorAuthority: requireBoolean(value.actuator_authority, "authority.actuator_authority"),
|
||||
navigationOrSafetyAccepted: requireBoolean(
|
||||
value.navigation_or_safety_accepted,
|
||||
"authority.navigation_or_safety_accepted",
|
||||
),
|
||||
directActuatorSetpointsAllowed: requireBoolean(
|
||||
value.direct_actuator_setpoints_allowed,
|
||||
"authority.direct_actuator_setpoints_allowed",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRun(value: unknown): PolygonRun {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonRunContractError("Прогон должен быть объектом.");
|
||||
}
|
||||
const summary = decodeSummary(value, RUN_KEYS);
|
||||
if (!Array.isArray(value.providers) || value.providers.length > 32) {
|
||||
throw new PolygonRunContractError("providers должен быть ограниченным массивом.");
|
||||
}
|
||||
const providers = value.providers.map(decodeProvider);
|
||||
if (
|
||||
providers.length !== summary.providerIds.length ||
|
||||
providers.some((provider, index) => provider.identifier !== summary.providerIds[index])
|
||||
) {
|
||||
throw new PolygonRunContractError("Провайдеры прогона не совпадают со сводкой.");
|
||||
}
|
||||
const parentRunId = value.parent_run_id === null
|
||||
? null
|
||||
: requireId(value.parent_run_id, "parent_run_id");
|
||||
return {
|
||||
...summary,
|
||||
scenarioSha256: requireSha256(value.scenario_sha256, "scenario_sha256"),
|
||||
profileSha256: requireSha256(value.profile_sha256, "profile_sha256"),
|
||||
hostProfileSha256: requireSha256(value.host_profile_sha256, "host_profile_sha256"),
|
||||
seed: requireInteger(value.seed, "seed"),
|
||||
parentRunId,
|
||||
providers,
|
||||
authority: decodeAuthority(value.authority),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeEvent(value: unknown, index: number, runId: string): PolygonRunEvent {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonRunContractError(`events[${index}] должен быть объектом.`);
|
||||
}
|
||||
assertExactKeys(value, EVENT_KEYS, `events[${index}]`);
|
||||
if (value.schema_version !== "missioncore.qualification-event/v1") {
|
||||
throw new PolygonRunContractError(`events[${index}] содержит неизвестную схему.`);
|
||||
}
|
||||
if (!isRecord(value.payload)) {
|
||||
throw new PolygonRunContractError(`events[${index}].payload должен быть объектом.`);
|
||||
}
|
||||
const eventRunId = requireId(value.run_id, `events[${index}].run_id`);
|
||||
if (eventRunId !== runId) {
|
||||
throw new PolygonRunContractError(`events[${index}] относится к другому прогону.`);
|
||||
}
|
||||
return {
|
||||
runId: eventRunId,
|
||||
sequence: requireInteger(value.sequence, `events[${index}].sequence`, 1),
|
||||
eventType: requireId(value.event_type, `events[${index}].event_type`),
|
||||
observedAtUtc: requireTimestamp(value.observed_at_utc, `events[${index}].observed_at_utc`),
|
||||
hostMonotonicNs: requireInteger(
|
||||
value.host_monotonic_ns,
|
||||
`events[${index}].host_monotonic_ns`,
|
||||
),
|
||||
simTimeNs: value.sim_time_ns === null
|
||||
? null
|
||||
: requireInteger(value.sim_time_ns, `events[${index}].sim_time_ns`),
|
||||
payload: value.payload,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeArtifact(value: unknown, index: number): PolygonRunArtifact {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonRunContractError(`artifacts[${index}] должен быть объектом.`);
|
||||
}
|
||||
assertExactKeys(value, ARTIFACT_KEYS, `artifacts[${index}]`);
|
||||
const relativePath = requireString(value.relative_path, `artifacts[${index}].relative_path`, 512);
|
||||
const segments = relativePath.split("/");
|
||||
if (
|
||||
relativePath.startsWith("/") ||
|
||||
relativePath.includes("\\") ||
|
||||
segments.some((segment) => !segment || segment === "." || segment === "..")
|
||||
) {
|
||||
throw new PolygonRunContractError(`artifacts[${index}] содержит небезопасный путь.`);
|
||||
}
|
||||
return {
|
||||
artifactId: requireId(value.artifact_id, `artifacts[${index}].artifact_id`),
|
||||
kind: requireId(value.kind, `artifacts[${index}].kind`),
|
||||
relativePath,
|
||||
sha256: requireSha256(value.sha256, `artifacts[${index}].sha256`),
|
||||
byteLength: requireInteger(value.byte_length, `artifacts[${index}].byte_length`),
|
||||
sourceOfRecord: requireBoolean(
|
||||
value.source_of_record,
|
||||
`artifacts[${index}].source_of_record`,
|
||||
),
|
||||
sequence: requireInteger(value.sequence, `artifacts[${index}].sequence`, 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePolygonRunCatalog(payload: unknown): PolygonRunCatalog {
|
||||
if (!isRecord(payload)) {
|
||||
throw new PolygonRunContractError("Каталог прогонов должен быть объектом.");
|
||||
}
|
||||
assertExactKeys(payload, CATALOG_KEYS, "Каталог прогонов");
|
||||
if (
|
||||
payload.schema_version !== "missioncore.polygon-run-catalog/v1" ||
|
||||
payload.access !== "read-only"
|
||||
) {
|
||||
throw new PolygonRunContractError("Каталог не является поддерживаемым read-only контрактом.");
|
||||
}
|
||||
if (!Array.isArray(payload.items) || payload.items.length > 100) {
|
||||
throw new PolygonRunContractError("Каталог должен содержать ограниченный массив items.");
|
||||
}
|
||||
const items = payload.items.map((item) => decodeSummary(item));
|
||||
if (new Set(items.map(({ runId }) => runId)).size !== items.length) {
|
||||
throw new PolygonRunContractError("Каталог содержит повторяющиеся прогоны.");
|
||||
}
|
||||
const total = requireInteger(payload.total, "total");
|
||||
if (total < items.length) {
|
||||
throw new PolygonRunContractError("Размер каталога меньше числа возвращённых прогонов.");
|
||||
}
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
limitations: decodeLimitations(payload.limitations),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePolygonRunDetail(payload: unknown): PolygonRunDetail {
|
||||
if (!isRecord(payload)) {
|
||||
throw new PolygonRunContractError("Детали прогона должны быть объектом.");
|
||||
}
|
||||
assertExactKeys(payload, DETAIL_KEYS, "Детали прогона");
|
||||
if (
|
||||
payload.schema_version !== "missioncore.polygon-run-detail/v1" ||
|
||||
payload.access !== "read-only"
|
||||
) {
|
||||
throw new PolygonRunContractError("Детали не являются поддерживаемым read-only контрактом.");
|
||||
}
|
||||
const run = decodeRun(payload.run);
|
||||
if (!Array.isArray(payload.events) || payload.events.length > 500) {
|
||||
throw new PolygonRunContractError("Журнал событий должен быть ограниченным массивом.");
|
||||
}
|
||||
const events = payload.events.map((event, index) => decodeEvent(event, index, run.runId));
|
||||
for (let index = 1; index < events.length; index += 1) {
|
||||
if (events[index].sequence !== events[index - 1].sequence + 1) {
|
||||
throw new PolygonRunContractError("Возвращённый фрагмент событий не является непрерывным.");
|
||||
}
|
||||
}
|
||||
const eventsTotal = requireInteger(payload.events_total, "events_total");
|
||||
const eventsTruncated = requireBoolean(payload.events_truncated, "events_truncated");
|
||||
if (eventsTotal < events.length || eventsTruncated !== (eventsTotal !== events.length)) {
|
||||
throw new PolygonRunContractError("Метаданные усечения событий противоречат журналу.");
|
||||
}
|
||||
if (!isRecord(payload.commands)) {
|
||||
throw new PolygonRunContractError("commands должен быть объектом.");
|
||||
}
|
||||
assertExactKeys(payload.commands, COMMAND_KEYS, "commands");
|
||||
if (payload.commands.content_exposed !== false) {
|
||||
throw new PolygonRunContractError("UI-0 не принимает содержимое команд.");
|
||||
}
|
||||
if (!Array.isArray(payload.artifacts) || payload.artifacts.length > 500) {
|
||||
throw new PolygonRunContractError("Артефакты должны быть ограниченным массивом.");
|
||||
}
|
||||
const artifacts = payload.artifacts.map(decodeArtifact);
|
||||
return {
|
||||
run,
|
||||
events,
|
||||
eventsTotal,
|
||||
eventsTruncated,
|
||||
commandCount: requireInteger(payload.commands.count, "commands.count"),
|
||||
artifacts,
|
||||
limitations: decodeLimitations(payload.limitations),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePolygonRunRoute(search: string): PolygonRunRoute {
|
||||
const parameters = new URLSearchParams(search);
|
||||
const workspaceValues = parameters.getAll("workspace");
|
||||
if (workspaceValues.length !== 1 || workspaceValues[0] !== "polygon-run") {
|
||||
return { active: false, runId: null, error: null };
|
||||
}
|
||||
const runValues = parameters.getAll("run");
|
||||
if (runValues.length === 0) {
|
||||
return { active: true, runId: null, error: null };
|
||||
}
|
||||
if (runValues.length !== 1 || !SAFE_ID.test(runValues[0])) {
|
||||
return {
|
||||
active: true,
|
||||
runId: null,
|
||||
error: "Прямая ссылка содержит некорректный идентификатор прогона.",
|
||||
};
|
||||
}
|
||||
return { active: true, runId: runValues[0], error: null };
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().startsWith("application/json")) {
|
||||
throw new PolygonRunContractError("Polygon API вернул ответ не в формате JSON.");
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new PolygonRunContractError("Polygon API вернул повреждённый JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function apiErrorMessage(body: unknown, fallback: string): string {
|
||||
if (!isRecord(body) || typeof body.detail !== "string") return fallback;
|
||||
const detail = body.detail.trim();
|
||||
return detail && detail.length <= 1_000 ? detail : fallback;
|
||||
}
|
||||
|
||||
async function getJson(
|
||||
url: string,
|
||||
fallback: string,
|
||||
signal: AbortSignal | undefined,
|
||||
fetcher: PolygonRunFetch,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new PolygonRunApiError(fallback);
|
||||
}
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) {
|
||||
throw new PolygonRunApiError(
|
||||
apiErrorMessage(body, `${fallback} HTTP ${response.status}.`),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function fetchPolygonRunCatalog({
|
||||
signal,
|
||||
limit = 20,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
limit?: number;
|
||||
fetcher?: PolygonRunFetch;
|
||||
} = {}): Promise<PolygonRunCatalog> {
|
||||
const admittedLimit = Number.isInteger(limit) && limit >= 1 && limit <= 100 ? limit : 20;
|
||||
const body = await getJson(
|
||||
`/api/v1/polygon/runs?limit=${admittedLimit}`,
|
||||
"Не удалось загрузить каталог прогонов Полигона.",
|
||||
signal,
|
||||
fetcher,
|
||||
);
|
||||
return decodePolygonRunCatalog(body);
|
||||
}
|
||||
|
||||
export async function fetchPolygonRunDetail(
|
||||
runId: string,
|
||||
{
|
||||
signal,
|
||||
eventLimit = 200,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
eventLimit?: number;
|
||||
fetcher?: PolygonRunFetch;
|
||||
} = {},
|
||||
): Promise<PolygonRunDetail> {
|
||||
if (!SAFE_ID.test(runId)) {
|
||||
throw new PolygonRunContractError("Идентификатор прогона имеет недопустимый формат.");
|
||||
}
|
||||
const admittedLimit = Number.isInteger(eventLimit) && eventLimit >= 1 && eventLimit <= 500
|
||||
? eventLimit
|
||||
: 200;
|
||||
const body = await getJson(
|
||||
`/api/v1/polygon/runs/${encodeURIComponent(runId)}?event_limit=${admittedLimit}`,
|
||||
"Не удалось загрузить доказательства прогона.",
|
||||
signal,
|
||||
fetcher,
|
||||
);
|
||||
return decodePolygonRunDetail(body);
|
||||
}
|
||||
Reference in New Issue
Block a user