refactor(lab): canonize selected evidence reports
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded";
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
export interface LaboratoryEvidenceArtifact {
|
||||
kind: string | null;
|
||||
path: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
schemaVersion: string | null;
|
||||
mediaType: string | null;
|
||||
verified: true;
|
||||
}
|
||||
|
||||
export interface LaboratoryEvidenceReport {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1";
|
||||
workId: string;
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
access: "read-only";
|
||||
proof: {
|
||||
documentSchemaVersion: string;
|
||||
documentSha256: string;
|
||||
identitySha256: string;
|
||||
reportSchemaVersion: string | null;
|
||||
reportSha256: string | null;
|
||||
artifactCount: number;
|
||||
verifiedArtifactCount: number;
|
||||
};
|
||||
completeness: Readonly<Record<string, LaboratoryEvidenceCompleteness>>;
|
||||
identity: Record<string, JsonValue>;
|
||||
source: Record<string, JsonValue> | null;
|
||||
configuration: Record<string, JsonValue> | null;
|
||||
method: Record<string, JsonValue> | null;
|
||||
execution: Record<string, JsonValue> | null;
|
||||
resources: Record<string, JsonValue> | null;
|
||||
metrics: Record<string, JsonValue> | null;
|
||||
gates: Record<string, JsonValue> | null;
|
||||
decision: JsonValue | undefined;
|
||||
limitations: JsonValue | undefined;
|
||||
authority: Record<string, JsonValue> | null;
|
||||
artifacts: readonly LaboratoryEvidenceArtifact[];
|
||||
visualEvidence: Record<string, JsonValue>;
|
||||
rawReport: Record<string, JsonValue>;
|
||||
canonicalJson: Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
export class LaboratoryEvidenceReportContractError extends Error {}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown, label: string): Record<string, JsonValue> | null {
|
||||
return value === null ? null : jsonRecord(value, label);
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown, label: string): Record<string, JsonValue> {
|
||||
const document = record(value, label);
|
||||
for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`);
|
||||
return document as Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
function validateJson(value: unknown, label: string): asserts value is JsonValue {
|
||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => validateJson(item, `${label}[${index}]`));
|
||||
return;
|
||||
}
|
||||
const document = record(value, label);
|
||||
Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`));
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, label: string): string | null {
|
||||
return value === null ? null : text(value, label);
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseArtifact(value: unknown): LaboratoryEvidenceArtifact {
|
||||
const artifact = record(value, "LAB artifact");
|
||||
if (artifact.verified !== true) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён.");
|
||||
}
|
||||
return {
|
||||
kind: nullableText(artifact.kind, "LAB artifact kind"),
|
||||
path: text(artifact.path, "LAB artifact path"),
|
||||
byteLength: integer(artifact.byte_length, "LAB artifact byte_length"),
|
||||
sha256: text(artifact.sha256, "LAB artifact sha256"),
|
||||
schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"),
|
||||
mediaType: nullableText(artifact.media_type, "LAB artifact media_type"),
|
||||
verified: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport {
|
||||
const payload = jsonRecord(value, "LAB evidence report") as Record<string, unknown>;
|
||||
if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема.");
|
||||
}
|
||||
if (payload.access !== "read-only") {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only.");
|
||||
}
|
||||
const proof = record(payload.proof, "LAB evidence proof");
|
||||
const completenessPayload = record(payload.completeness, "LAB evidence completeness");
|
||||
const completeness: Record<string, LaboratoryEvidenceCompleteness> = {};
|
||||
for (const [key, state] of Object.entries(completenessPayload)) {
|
||||
if (state !== "recorded" && state !== "not-recorded") {
|
||||
throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`);
|
||||
}
|
||||
completeness[key] = state;
|
||||
}
|
||||
if (!Array.isArray(payload.artifacts)) {
|
||||
throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.laboratory-evidence-report/v1",
|
||||
workId: text(payload.work_id, "LAB work_id"),
|
||||
resultId: text(payload.result_id, "LAB result_id"),
|
||||
createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"),
|
||||
access: "read-only",
|
||||
proof: {
|
||||
documentSchemaVersion: text(proof.document_schema_version, "document schema"),
|
||||
documentSha256: text(proof.document_sha256, "document sha256"),
|
||||
identitySha256: text(proof.identity_sha256, "identity sha256"),
|
||||
reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"),
|
||||
reportSha256: nullableText(proof.report_sha256, "report sha256"),
|
||||
artifactCount: integer(proof.artifact_count, "artifact_count"),
|
||||
verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"),
|
||||
},
|
||||
completeness,
|
||||
identity: jsonRecord(payload.identity, "LAB identity"),
|
||||
source: nullableRecord(payload.source, "LAB source"),
|
||||
configuration: nullableRecord(payload.configuration, "LAB configuration"),
|
||||
method: nullableRecord(payload.method, "LAB method"),
|
||||
execution: nullableRecord(payload.execution, "LAB execution"),
|
||||
resources: nullableRecord(payload.resources, "LAB resources"),
|
||||
metrics: nullableRecord(payload.metrics, "LAB metrics"),
|
||||
gates: nullableRecord(payload.gates, "LAB gates"),
|
||||
decision: payload.decision as JsonValue | undefined,
|
||||
limitations: payload.limitations as JsonValue | undefined,
|
||||
authority: nullableRecord(payload.authority, "LAB authority"),
|
||||
artifacts: payload.artifacts.map(parseArtifact),
|
||||
visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"),
|
||||
rawReport: jsonRecord(payload.raw_report, "LAB raw report"),
|
||||
canonicalJson: payload as Record<string, JsonValue>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryEvidenceReport({
|
||||
workId,
|
||||
resultId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
workId: string;
|
||||
resultId: string;
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<LaboratoryEvidenceReport> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryEvidenceReportContractError(
|
||||
response.status === 404
|
||||
? "Для этой LAB канонический evidence-report пока не опубликован."
|
||||
: `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const report = parseLaboratoryEvidenceReport(await response.json());
|
||||
if (report.workId !== workId || report.resultId !== resultId) {
|
||||
throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity.");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export type LaboratoryValueSignal = "progress" | "retained" | "failed";
|
||||
export type LaboratoryValueLifecycle = "current" | "legacy";
|
||||
export type LaboratoryVisualEvidence = "available" | "partial" | "missing";
|
||||
|
||||
export interface LaboratoryValueReviewEntry {
|
||||
catalogId: string;
|
||||
evidenceId: string;
|
||||
signal: LaboratoryValueSignal;
|
||||
lifecycle: LaboratoryValueLifecycle;
|
||||
visualEvidence: LaboratoryVisualEvidence;
|
||||
}
|
||||
|
||||
export interface LaboratoryValueReviewIndex {
|
||||
reviewedAtUtc: string;
|
||||
items: readonly LaboratoryValueReviewEntry[];
|
||||
}
|
||||
|
||||
export class LaboratoryValueReviewContractError extends Error {}
|
||||
|
||||
const ENTRY_KEYS = [
|
||||
"access",
|
||||
"catalog_id",
|
||||
"evidence_id",
|
||||
"lifecycle",
|
||||
"signal",
|
||||
"visual_evidence",
|
||||
] as const;
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
if (actual.join("\0") !== [...expected].sort().join("\0")) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: нарушен состав полей.`);
|
||||
}
|
||||
}
|
||||
|
||||
function textValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim() || value !== value.trim()) {
|
||||
throw new LaboratoryValueReviewContractError(`${label}: ожидалась непустая строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEntry(value: unknown): LaboratoryValueReviewEntry {
|
||||
const item = objectValue(value, "LAB value-review item");
|
||||
exactKeys(item, ENTRY_KEYS, "LAB value-review item");
|
||||
if (item.access !== "read-only") {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: доступ не read-only.");
|
||||
}
|
||||
if (!(["progress", "retained", "failed"] as const).includes(
|
||||
item.signal as LaboratoryValueSignal,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный signal.");
|
||||
}
|
||||
if (!(["current", "legacy"] as const).includes(
|
||||
item.lifecycle as LaboratoryValueLifecycle,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный lifecycle.");
|
||||
}
|
||||
if (!(["available", "partial", "missing"] as const).includes(
|
||||
item.visual_evidence as LaboratoryVisualEvidence,
|
||||
)) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review item: неизвестный visual_evidence.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
catalogId: textValue(item.catalog_id, "LAB value-review catalog_id"),
|
||||
evidenceId: textValue(item.evidence_id, "LAB value-review evidence_id"),
|
||||
signal: item.signal as LaboratoryValueSignal,
|
||||
lifecycle: item.lifecycle as LaboratoryValueLifecycle,
|
||||
visualEvidence: item.visual_evidence as LaboratoryVisualEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLaboratoryValueReviewIndex({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: typeof fetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<LaboratoryValueReviewIndex> {
|
||||
const response = await fetcher("/api/v1/laboratory/value-review-index", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
`Value-review индекс LAB недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const payload = objectValue(await response.json(), "LAB value-review index");
|
||||
exactKeys(
|
||||
payload,
|
||||
["access", "items", "reviewed_at_utc", "schema_version"],
|
||||
"LAB value-review index",
|
||||
);
|
||||
if (
|
||||
payload.schema_version !== "missioncore.laboratory-value-review-index/v1"
|
||||
|| payload.access !== "read-only"
|
||||
|| !Array.isArray(payload.items)
|
||||
|| payload.items.length > 128
|
||||
) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review index: нарушен контракт.",
|
||||
);
|
||||
}
|
||||
const items = payload.items.map(parseEntry);
|
||||
if (new Set(items.map((item) => item.catalogId)).size !== items.length) {
|
||||
throw new LaboratoryValueReviewContractError(
|
||||
"LAB value-review index: catalog_id продублирован.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
reviewedAtUtc: textValue(payload.reviewed_at_utc, "LAB value-review reviewed_at_utc"),
|
||||
items,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user