113 lines
5.0 KiB
TypeScript
113 lines
5.0 KiB
TypeScript
import type { JobResponse, JobState } from "../api/types";
|
|
|
|
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
|
|
const forbiddenProjectionKeys = new Set(["leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket", "credential", "content"]);
|
|
|
|
export function parseSafeJobResponse(value: unknown): JobResponse {
|
|
if (!isRecord(value)) throw new Error("job projection must be an object");
|
|
rejectForbiddenKeys(value);
|
|
const state = requiredString(value, "state") as JobState;
|
|
if (!jobStates.has(state)) throw new Error("job state is invalid");
|
|
const progress = requiredRecord(value, "progress");
|
|
const retryPolicy = requiredRecord(value, "retryPolicy");
|
|
const parsed: JobResponse = {
|
|
id: requiredString(value, "id"),
|
|
serverInstanceId: optionalString(value, "serverInstanceId"),
|
|
runEndpointId: requiredString(value, "runEndpointId"),
|
|
capability: requiredString(value, "capability"),
|
|
targetKey: optionalString(value, "targetKey"),
|
|
inputRef: optionalString(value, "inputRef"),
|
|
idempotencyKey: requiredString(value, "idempotencyKey"),
|
|
state,
|
|
progress: { percent: requiredNumber(progress, "percent"), message: optionalString(progress, "message") },
|
|
resultRef: optionalString(value, "resultRef"),
|
|
executionResult: optionalExecutionResult(value, "executionResult"),
|
|
retryPolicy: {
|
|
maxAttempts: requiredNumber(retryPolicy, "maxAttempts"),
|
|
initialBackoffSeconds: requiredNumber(retryPolicy, "initialBackoffSeconds"),
|
|
maxBackoffSeconds: requiredNumber(retryPolicy, "maxBackoffSeconds")
|
|
},
|
|
attempt: requiredNumber(value, "attempt"),
|
|
nextAttemptAt: optionalString(value, "nextAttemptAt"),
|
|
ackDeadlineAt: optionalString(value, "ackDeadlineAt"),
|
|
leaseExpiresAt: optionalString(value, "leaseExpiresAt"),
|
|
cancelReason: optionalString(value, "cancelReason"),
|
|
cancelRequestedAt: optionalString(value, "cancelRequestedAt"),
|
|
cancelCompletedAt: optionalString(value, "cancelCompletedAt"),
|
|
terminalAt: optionalString(value, "terminalAt"),
|
|
lastReconciledAt: optionalString(value, "lastReconciledAt"),
|
|
reconcileCount: requiredNumber(value, "reconcileCount"),
|
|
reconcileOutcome: optionalString(value, "reconcileOutcome"),
|
|
createdAt: requiredString(value, "createdAt"),
|
|
updatedAt: requiredString(value, "updatedAt")
|
|
};
|
|
return parsed;
|
|
}
|
|
|
|
function optionalExecutionResult(value: Record<string, unknown>, key: string): JobResponse["executionResult"] {
|
|
const field = value[key];
|
|
if (field === undefined) return undefined;
|
|
if (!isRecord(field)) throw new Error(`${key} must be an object`);
|
|
rejectForbiddenKeys(field);
|
|
const result: NonNullable<JobResponse["executionResult"]> = {
|
|
kind: optionalString(field, "kind"),
|
|
processState: optionalString(field, "processState"),
|
|
exitClassification: optionalString(field, "exitClassification"),
|
|
exitCode: optionalSignedNumber(field, "exitCode"),
|
|
version: optionalNumber(field, "version"),
|
|
checksum: optionalString(field, "checksum"),
|
|
sizeBytes: optionalNumber(field, "sizeBytes"),
|
|
summary: optionalString(field, "summary")
|
|
};
|
|
return result;
|
|
}
|
|
|
|
function rejectForbiddenKeys(value: Record<string, unknown>): void {
|
|
for (const key of Object.keys(value)) {
|
|
if (forbiddenProjectionKeys.has(key)) throw new Error(`job projection contains forbidden field ${key}`);
|
|
}
|
|
}
|
|
|
|
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
const field = value[key];
|
|
if (!isRecord(field)) throw new Error(`${key} must be an object`);
|
|
rejectForbiddenKeys(field);
|
|
return field;
|
|
}
|
|
|
|
function requiredString(value: Record<string, unknown>, key: string): string {
|
|
const field = value[key];
|
|
if (typeof field !== "string" || field.trim() === "") throw new Error(`${key} must be a non-empty string`);
|
|
return field;
|
|
}
|
|
|
|
function optionalString(value: Record<string, unknown>, key: string): string | undefined {
|
|
const field = value[key];
|
|
if (field === undefined) return undefined;
|
|
if (typeof field !== "string") throw new Error(`${key} must be a string`);
|
|
return field;
|
|
}
|
|
|
|
function optionalSignedNumber(value: Record<string, unknown>, key: string): number | undefined {
|
|
const field = value[key];
|
|
if (field === undefined) return undefined;
|
|
if (typeof field !== "number" || !Number.isFinite(field)) throw new Error(`${key} must be a number`);
|
|
return field;
|
|
}
|
|
|
|
function optionalNumber(value: Record<string, unknown>, key: string): number | undefined {
|
|
const field = optionalSignedNumber(value, key);
|
|
if (field !== undefined && field < 0) throw new Error(`${key} must be non-negative`);
|
|
return field;
|
|
}
|
|
|
|
function requiredNumber(value: Record<string, unknown>, key: string): number {
|
|
const field = value[key];
|
|
if (typeof field !== "number" || !Number.isFinite(field) || field < 0) throw new Error(`${key} must be a non-negative number`);
|
|
return field;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|