import { createHash } from 'node:crypto'; import { approvedActionDispatchDigest, normalizeApprovedActionDispatchRecord, type ApprovedActionDispatchRecord, } from './approvedAction'; export const APPROVED_ACTION_EXECUTION_SCHEMA = 'qinglong/approved-action-execution@v1' as const; export const APPROVED_ACTION_EXECUTION_STATUSES = [ 'pending', 'leased', 'executing', 'retry_wait', 'succeeded', 'failed', 'blocked', ] as const; export type ApprovedActionExecutionStatus = (typeof APPROVED_ACTION_EXECUTION_STATUSES)[number]; export type ApprovedActionExecutionEffectiveStatus = | ApprovedActionExecutionStatus | 'recovery_required'; export interface ApprovedActionExecutionRecord { readonly schema: typeof APPROVED_ACTION_EXECUTION_SCHEMA; readonly dispatchId: string; readonly dispatchDigest: string; readonly projectId: string; readonly status: ApprovedActionExecutionStatus; readonly version: number; readonly attemptCount: number; readonly maxAttempts: number; readonly eligibleAtMs: number | null; readonly nextAttemptAtMs: number | null; readonly leaseOwner: string | null; readonly leaseToken: string | null; readonly leaseExpiresAtMs: number | null; readonly startedAtMs: number | null; readonly resultMutationId: string | null; readonly resultCode: string | null; readonly resultDigest: string | null; readonly completedAtMs: number | null; readonly createdAtMs: number; readonly updatedAtMs: number; readonly executionDigest: string; } export interface ApprovedActionExecutionSnapshot { readonly dispatch: Readonly; readonly execution: Readonly; } export interface ApprovedActionExecutionCursor { readonly eligibleAtMs: number; readonly dispatchId: string; } export interface ListDueApprovedActionExecutionsQuery { readonly nowMs: number; readonly limit: number; readonly actionTypes: readonly string[]; readonly cursor?: ApprovedActionExecutionCursor; } export interface ListDueApprovedActionExecutionsResult { readonly executions: readonly Readonly[]; readonly truncated: boolean; readonly nextCursor?: Readonly; } export interface ClaimApprovedActionExecutionCommand { readonly dispatchId: string; readonly owner: string; readonly leaseToken: string; readonly nowMs: number; readonly leaseDurationMs: number; } export type ClaimApprovedActionExecutionResult = | Readonly<{ status: 'claimed'; snapshot: Readonly; }> | Readonly<{ status: 'not_found' }> | Readonly<{ status: | 'not_due' | 'leased' | 'executing' | 'recovery_required' | 'succeeded' | 'failed' | 'blocked'; snapshot: Readonly; }>; export interface StartApprovedActionExecutionCommand { readonly dispatchId: string; readonly approvalRequestId: string; readonly actionDigest: string; readonly owner: string; readonly leaseToken: string; readonly expectedVersion: number; readonly startedAtMs: number; } export interface RenewApprovedActionExecutionCommand { readonly dispatchId: string; readonly owner: string; readonly leaseToken: string; readonly expectedVersion: number; readonly nowMs: number; readonly leaseDurationMs: number; } export interface ReleaseApprovedActionExecutionBeforeStartCommand { readonly dispatchId: string; readonly owner: string; readonly leaseToken: string; readonly expectedVersion: number; readonly resultMutationId: string; readonly resultCode: string; readonly atMs: number; readonly retryAtMs?: number; } export interface CompleteApprovedActionExecutionCommand { readonly dispatchId: string; readonly owner: string; readonly leaseToken: string; readonly expectedVersion: number; readonly resultMutationId: string; readonly outcome: 'succeeded' | 'failed' | 'indeterminate'; readonly resultCode: string; readonly resultDigest?: string; readonly completedAtMs: number; } export interface ApprovedActionExecutionRepository { findExecutionByDispatchId( dispatchId: string, ): Promise | null>; listDueExecutions( query: ListDueApprovedActionExecutionsQuery, ): Promise; claimExecution( command: ClaimApprovedActionExecutionCommand, ): Promise; startExecution( command: StartApprovedActionExecutionCommand, ): Promise>; renewExecution( command: RenewApprovedActionExecutionCommand, ): Promise>; releaseExecutionBeforeStart( command: ReleaseApprovedActionExecutionBeforeStartCommand, ): Promise>; completeExecution( command: CompleteApprovedActionExecutionCommand, ): Promise>; } export const DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS = 5; export const MAX_APPROVED_ACTION_ATTEMPTS = 16; export const MAX_APPROVED_ACTION_EXECUTION_VERSION = 2_147_483_647; export const MAX_APPROVED_ACTION_LEASE_ID_LENGTH = 128; export const MAX_APPROVED_ACTION_RESULT_CODE_LENGTH = 64; export const MAX_APPROVED_ACTION_EXECUTION_PAGE_SIZE = 64; export const MAX_APPROVED_ACTION_LEASE_DURATION_MS = 10 * 60 * 1000; const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const RESULT_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; const DIGEST_PATTERN = /^[0-9a-f]{64}$/; export class InvalidApprovedActionExecutionError extends TypeError { readonly code = 'APPROVED_ACTION_EXECUTION_INVALID'; constructor(message: string) { super(`Approved Action execution is invalid: ${message}`); this.name = 'InvalidApprovedActionExecutionError'; } } export class ApprovedActionExecutionFenceConflictError extends Error { readonly code = 'APPROVED_ACTION_EXECUTION_FENCE_CONFLICT'; constructor() { super('Approved Action execution fence changed'); this.name = 'ApprovedActionExecutionFenceConflictError'; } } export class ApprovedActionExecutionStateConflictError extends Error { readonly code = 'APPROVED_ACTION_EXECUTION_STATE_CONFLICT'; constructor() { super('Approved Action execution is not in the required state'); this.name = 'ApprovedActionExecutionStateConflictError'; } } export class ApprovedActionExecutionBindingConflictError extends Error { readonly code = 'APPROVED_ACTION_EXECUTION_BINDING_CONFLICT'; constructor() { super('Approved Action execution does not match its dispatch'); this.name = 'ApprovedActionExecutionBindingConflictError'; } } export class ApprovedActionExecutionUnavailableError extends Error { readonly code = 'APPROVED_ACTION_EXECUTION_UNAVAILABLE'; constructor(options?: ErrorOptions) { super('Approved Action execution authority is unavailable', options); this.name = 'ApprovedActionExecutionUnavailableError'; } } function dataRecord(value: unknown, label: string): Record { if ( !value || typeof value !== 'object' || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ) { throw new InvalidApprovedActionExecutionError(`${label} must be an object`); } return value as Record; } function exactKeys( value: object, expected: readonly string[], optional: readonly string[], label: string, ): void { const actual = Object.keys(value).sort(); const required = [...expected].sort(); const allowed = new Set([...expected, ...optional]); if ( required.some((key) => !Object.hasOwn(value, key)) || actual.some((key) => !allowed.has(key)) ) { throw new InvalidApprovedActionExecutionError(`${label} shape is invalid`); } } function identifier(value: unknown, label: string): string { if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { throw new InvalidApprovedActionExecutionError(`${label} is invalid`); } return value; } function resultCode(value: unknown): string { if (typeof value !== 'string' || !RESULT_CODE_PATTERN.test(value)) { throw new InvalidApprovedActionExecutionError('result code is invalid'); } return value; } function digest(value: unknown, label: string): string { if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { throw new InvalidApprovedActionExecutionError(`${label} is invalid`); } return value; } function integer( value: unknown, label: string, minimum: number, maximum: number, ): number { if ( !Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum ) { throw new InvalidApprovedActionExecutionError(`${label} is invalid`); } return value as number; } function timestamp(value: unknown, label: string): number { return integer(value, label, 0, Number.MAX_SAFE_INTEGER); } function nullableTimestamp(value: unknown, label: string): number | null { return value === null ? null : timestamp(value, label); } function nullableIdentifier(value: unknown, label: string): string | null { return value === null ? null : identifier(value, label); } function nullableResultCode(value: unknown): string | null { return value === null ? null : resultCode(value); } function nullableDigest(value: unknown, label: string): string | null { return value === null ? null : digest(value, label); } function contractDigest(domain: string, value: unknown): string { return createHash('sha256') .update(domain) .update('\0') .update(JSON.stringify(value)) .digest('hex'); } function withoutExecutionDigest( value: Omit, ): Omit { return Object.freeze(value); } function withExecutionDigest( value: Omit, ): Readonly { const normalized = withoutExecutionDigest(value); return Object.freeze({ ...normalized, executionDigest: contractDigest( 'qinglong/approved-action-execution-digest@v1', normalized, ), }); } function executionWithoutDigest( value: Readonly, ): Omit { const { executionDigest: _executionDigest, ...record } = value; return record; } function sameLease( record: Readonly, owner: string, leaseToken: string, expectedVersion: number, ): boolean { return ( record.leaseOwner === owner && record.leaseToken === leaseToken && record.version === expectedVersion ); } export function createApprovedActionExecution( dispatchValue: ApprovedActionDispatchRecord, maxAttempts = DEFAULT_APPROVED_ACTION_MAX_ATTEMPTS, ): Readonly { const dispatch = normalizeApprovedActionDispatchRecord(dispatchValue); return withExecutionDigest({ schema: APPROVED_ACTION_EXECUTION_SCHEMA, dispatchId: dispatch.id, dispatchDigest: approvedActionDispatchDigest(dispatch), projectId: dispatch.projectId, status: 'pending', version: 0, attemptCount: 0, maxAttempts: integer( maxAttempts, 'maximum attempts', 1, MAX_APPROVED_ACTION_ATTEMPTS, ), eligibleAtMs: dispatch.createdAtMs, nextAttemptAtMs: null, leaseOwner: null, leaseToken: null, leaseExpiresAtMs: null, startedAtMs: null, resultMutationId: null, resultCode: null, resultDigest: null, completedAtMs: null, createdAtMs: dispatch.createdAtMs, updatedAtMs: dispatch.createdAtMs, }); } export function normalizeApprovedActionExecutionRecord( value: ApprovedActionExecutionRecord, ): Readonly { const record = dataRecord(value, 'record'); exactKeys( record, [ 'schema', 'dispatchId', 'dispatchDigest', 'projectId', 'status', 'version', 'attemptCount', 'maxAttempts', 'eligibleAtMs', 'nextAttemptAtMs', 'leaseOwner', 'leaseToken', 'leaseExpiresAtMs', 'startedAtMs', 'resultMutationId', 'resultCode', 'resultDigest', 'completedAtMs', 'createdAtMs', 'updatedAtMs', 'executionDigest', ], [], 'record', ); if ( value.schema !== APPROVED_ACTION_EXECUTION_SCHEMA || !APPROVED_ACTION_EXECUTION_STATUSES.includes(value.status) ) { throw new InvalidApprovedActionExecutionError( 'schema or status is invalid', ); } const normalized = { schema: APPROVED_ACTION_EXECUTION_SCHEMA, dispatchId: identifier(value.dispatchId, 'dispatch id'), dispatchDigest: digest(value.dispatchDigest, 'dispatch digest'), projectId: identifier(value.projectId, 'project id'), status: value.status, version: integer( value.version, 'version', 0, MAX_APPROVED_ACTION_EXECUTION_VERSION, ), attemptCount: integer( value.attemptCount, 'attempt count', 0, MAX_APPROVED_ACTION_ATTEMPTS, ), maxAttempts: integer( value.maxAttempts, 'maximum attempts', 1, MAX_APPROVED_ACTION_ATTEMPTS, ), eligibleAtMs: nullableTimestamp(value.eligibleAtMs, 'eligible time'), nextAttemptAtMs: nullableTimestamp( value.nextAttemptAtMs, 'next attempt time', ), leaseOwner: nullableIdentifier(value.leaseOwner, 'lease owner'), leaseToken: nullableIdentifier(value.leaseToken, 'lease token'), leaseExpiresAtMs: nullableTimestamp( value.leaseExpiresAtMs, 'lease expiry', ), startedAtMs: nullableTimestamp(value.startedAtMs, 'start time'), resultMutationId: nullableIdentifier( value.resultMutationId, 'result mutation id', ), resultCode: nullableResultCode(value.resultCode), resultDigest: nullableDigest(value.resultDigest, 'result digest'), completedAtMs: nullableTimestamp(value.completedAtMs, 'completion time'), createdAtMs: timestamp(value.createdAtMs, 'creation time'), updatedAtMs: timestamp(value.updatedAtMs, 'update time'), } satisfies Omit; const executionDigest = digest(value.executionDigest, 'execution digest'); if ( normalized.attemptCount > normalized.maxAttempts || normalized.updatedAtMs < normalized.createdAtMs ) { throw new InvalidApprovedActionExecutionError( 'attempt or timestamp range is invalid', ); } const leaseValues = [ normalized.leaseOwner, normalized.leaseToken, normalized.leaseExpiresAtMs, ]; const hasLease = leaseValues.every((entry) => entry !== null); if ( leaseValues.some((entry) => entry !== null) !== hasLease || (hasLease && (normalized.leaseExpiresAtMs! <= normalized.updatedAtMs || normalized.leaseExpiresAtMs! - normalized.updatedAtMs > MAX_APPROVED_ACTION_LEASE_DURATION_MS)) ) { throw new InvalidApprovedActionExecutionError('lease tuple is invalid'); } const resultValues = [ normalized.resultMutationId, normalized.resultCode, ]; const hasResult = resultValues.every((entry) => entry !== null); if (resultValues.some((entry) => entry !== null) !== hasResult) { throw new InvalidApprovedActionExecutionError('result tuple is invalid'); } const terminal = ['succeeded', 'failed', 'blocked'].includes( normalized.status, ); if ( (normalized.status === 'pending' && (normalized.version !== 0 || normalized.attemptCount !== 0 || normalized.eligibleAtMs === null || normalized.nextAttemptAtMs !== null || hasLease || normalized.startedAtMs !== null || hasResult || normalized.resultDigest !== null || normalized.completedAtMs !== null)) || (normalized.status === 'leased' && (!hasLease || normalized.attemptCount < 1 || normalized.eligibleAtMs !== normalized.leaseExpiresAtMs || normalized.nextAttemptAtMs !== null || normalized.startedAtMs !== null || hasResult || normalized.resultDigest !== null || normalized.completedAtMs !== null)) || (normalized.status === 'executing' && (!hasLease || normalized.attemptCount < 1 || normalized.eligibleAtMs !== null || normalized.nextAttemptAtMs !== null || normalized.startedAtMs === null || hasResult || normalized.resultDigest !== null || normalized.completedAtMs !== null)) || (normalized.status === 'retry_wait' && (hasLease || normalized.attemptCount < 1 || normalized.attemptCount >= normalized.maxAttempts || normalized.eligibleAtMs === null || normalized.eligibleAtMs !== normalized.nextAttemptAtMs || normalized.startedAtMs !== null || !hasResult || normalized.resultDigest !== null || normalized.completedAtMs !== null)) || (terminal && (hasLease || normalized.eligibleAtMs !== null || normalized.nextAttemptAtMs !== null || !hasResult || normalized.completedAtMs === null)) ) { throw new InvalidApprovedActionExecutionError( `${normalized.status} tuple is invalid`, ); } if ( normalized.status === 'succeeded' && (normalized.startedAtMs === null || normalized.resultDigest === null) ) { throw new InvalidApprovedActionExecutionError( 'successful execution has no start barrier or result digest', ); } if ( normalized.status === 'failed' && normalized.startedAtMs === null ) { throw new InvalidApprovedActionExecutionError( 'failed execution has no start barrier', ); } if ( normalized.startedAtMs !== null && (normalized.startedAtMs < normalized.createdAtMs || normalized.startedAtMs > normalized.updatedAtMs) ) { throw new InvalidApprovedActionExecutionError( 'start timestamp is invalid', ); } if ( normalized.completedAtMs !== null && (normalized.completedAtMs < (normalized.startedAtMs ?? normalized.createdAtMs) || normalized.completedAtMs !== normalized.updatedAtMs) ) { throw new InvalidApprovedActionExecutionError( 'completion timestamp is invalid', ); } if ( executionDigest !== contractDigest( 'qinglong/approved-action-execution-digest@v1', normalized, ) ) { throw new InvalidApprovedActionExecutionError( 'execution digest does not match the record', ); } return Object.freeze({ ...normalized, executionDigest }); } export function normalizeApprovedActionExecutionSnapshot( value: ApprovedActionExecutionSnapshot, ): Readonly { const snapshot = dataRecord(value, 'snapshot'); exactKeys(snapshot, ['dispatch', 'execution'], [], 'snapshot'); const dispatch = normalizeApprovedActionDispatchRecord(value.dispatch); const execution = normalizeApprovedActionExecutionRecord(value.execution); if ( execution.dispatchId !== dispatch.id || execution.dispatchDigest !== approvedActionDispatchDigest(dispatch) || execution.projectId !== dispatch.projectId ) { throw new ApprovedActionExecutionBindingConflictError(); } return Object.freeze({ dispatch, execution }); } export function normalizeApprovedActionExecutionCursor( value: ApprovedActionExecutionCursor, ): Readonly { const cursor = dataRecord(value, 'cursor'); exactKeys(cursor, ['eligibleAtMs', 'dispatchId'], [], 'cursor'); return Object.freeze({ eligibleAtMs: timestamp(value.eligibleAtMs, 'cursor eligible time'), dispatchId: identifier(value.dispatchId, 'cursor dispatch id'), }); } export function approvedActionExecutionEffectiveStatus( recordValue: ApprovedActionExecutionRecord, nowMsValue: number, ): ApprovedActionExecutionEffectiveStatus { const record = normalizeApprovedActionExecutionRecord(recordValue); const nowMs = timestamp(nowMsValue, 'observation time'); if ( record.status === 'executing' && record.leaseExpiresAtMs !== null && nowMs >= record.leaseExpiresAtMs ) { return 'recovery_required'; } return record.status; } export function claimApprovedActionExecution( recordValue: ApprovedActionExecutionRecord, commandValue: Omit, ): Readonly { const record = normalizeApprovedActionExecutionRecord(recordValue); const command = dataRecord(commandValue, 'claim command'); exactKeys( command, ['owner', 'leaseToken', 'nowMs', 'leaseDurationMs'], [], 'claim command', ); const owner = identifier(commandValue.owner, 'lease owner'); const leaseToken = identifier(commandValue.leaseToken, 'lease token'); const nowMs = timestamp(commandValue.nowMs, 'claim time'); const leaseDurationMs = integer( commandValue.leaseDurationMs, 'lease duration', 1, MAX_APPROVED_ACTION_LEASE_DURATION_MS, ); const due = (record.status === 'pending' || record.status === 'retry_wait') && record.eligibleAtMs !== null && record.eligibleAtMs <= nowMs; const reclaimable = record.status === 'leased' && record.leaseExpiresAtMs !== null && record.leaseExpiresAtMs <= nowMs; if (!due && !reclaimable) { throw new ApprovedActionExecutionStateConflictError(); } if (record.attemptCount >= record.maxAttempts) { throw new ApprovedActionExecutionStateConflictError(); } const leaseExpiresAtMs = Math.min( Number.MAX_SAFE_INTEGER, nowMs + leaseDurationMs, ); return withExecutionDigest({ ...executionWithoutDigest(record), status: 'leased', version: record.version + 1, attemptCount: record.attemptCount + 1, eligibleAtMs: leaseExpiresAtMs, nextAttemptAtMs: null, leaseOwner: owner, leaseToken, leaseExpiresAtMs, startedAtMs: null, resultMutationId: null, resultCode: null, resultDigest: null, completedAtMs: null, updatedAtMs: nowMs, }); } export function startApprovedActionExecution( snapshotValue: ApprovedActionExecutionSnapshot, commandValue: StartApprovedActionExecutionCommand, ): Readonly { const snapshot = normalizeApprovedActionExecutionSnapshot(snapshotValue); const command = dataRecord(commandValue, 'start command'); exactKeys( command, [ 'dispatchId', 'approvalRequestId', 'actionDigest', 'owner', 'leaseToken', 'expectedVersion', 'startedAtMs', ], [], 'start command', ); const dispatchId = identifier(commandValue.dispatchId, 'dispatch id'); const approvalRequestId = identifier( commandValue.approvalRequestId, 'approval request id', ); const actionDigest = digest(commandValue.actionDigest, 'action digest'); const owner = identifier(commandValue.owner, 'lease owner'); const leaseToken = identifier(commandValue.leaseToken, 'lease token'); const expectedVersion = integer( commandValue.expectedVersion, 'expected version', 0, MAX_APPROVED_ACTION_EXECUTION_VERSION, ); const startedAtMs = timestamp(commandValue.startedAtMs, 'start time'); if ( snapshot.execution.status !== 'leased' || !sameLease(snapshot.execution, owner, leaseToken, expectedVersion) || dispatchId !== snapshot.dispatch.id || approvalRequestId !== snapshot.dispatch.approvalRequestId || actionDigest !== snapshot.dispatch.action.actionDigest || snapshot.execution.leaseExpiresAtMs === null || startedAtMs < snapshot.execution.updatedAtMs || startedAtMs >= snapshot.execution.leaseExpiresAtMs ) { throw new ApprovedActionExecutionFenceConflictError(); } return withExecutionDigest({ ...executionWithoutDigest(snapshot.execution), status: 'executing', version: snapshot.execution.version + 1, eligibleAtMs: null, startedAtMs, updatedAtMs: startedAtMs, }); } export function renewApprovedActionExecution( recordValue: ApprovedActionExecutionRecord, commandValue: Omit, ): Readonly { const record = normalizeApprovedActionExecutionRecord(recordValue); const command = dataRecord(commandValue, 'renew command'); exactKeys( command, ['owner', 'leaseToken', 'expectedVersion', 'nowMs', 'leaseDurationMs'], [], 'renew command', ); const owner = identifier(commandValue.owner, 'lease owner'); const leaseToken = identifier(commandValue.leaseToken, 'lease token'); const expectedVersion = integer( commandValue.expectedVersion, 'expected version', 0, MAX_APPROVED_ACTION_EXECUTION_VERSION, ); const nowMs = timestamp(commandValue.nowMs, 'renewal time'); const leaseDurationMs = integer( commandValue.leaseDurationMs, 'lease duration', 1, MAX_APPROVED_ACTION_LEASE_DURATION_MS, ); if ( (record.status !== 'leased' && record.status !== 'executing') || !sameLease(record, owner, leaseToken, expectedVersion) || record.leaseExpiresAtMs === null || nowMs < record.updatedAtMs || nowMs >= record.leaseExpiresAtMs ) { throw new ApprovedActionExecutionFenceConflictError(); } const leaseExpiresAtMs = Math.min( Number.MAX_SAFE_INTEGER, nowMs + leaseDurationMs, ); return withExecutionDigest({ ...executionWithoutDigest(record), version: record.version + 1, eligibleAtMs: record.status === 'leased' ? leaseExpiresAtMs : record.eligibleAtMs, leaseExpiresAtMs, updatedAtMs: nowMs, }); } export function releaseApprovedActionExecutionBeforeStart( recordValue: ApprovedActionExecutionRecord, commandValue: Omit< ReleaseApprovedActionExecutionBeforeStartCommand, 'dispatchId' >, ): Readonly { const record = normalizeApprovedActionExecutionRecord(recordValue); const command = dataRecord(commandValue, 'release command'); exactKeys( command, [ 'owner', 'leaseToken', 'expectedVersion', 'resultMutationId', 'resultCode', 'atMs', ], ['retryAtMs'], 'release command', ); const owner = identifier(commandValue.owner, 'lease owner'); const leaseToken = identifier(commandValue.leaseToken, 'lease token'); const expectedVersion = integer( commandValue.expectedVersion, 'expected version', 0, MAX_APPROVED_ACTION_EXECUTION_VERSION, ); const resultMutationId = identifier( commandValue.resultMutationId, 'result mutation id', ); const normalizedResultCode = resultCode(commandValue.resultCode); const atMs = timestamp(commandValue.atMs, 'release time'); const retryAtMs = commandValue.retryAtMs === undefined ? undefined : timestamp(commandValue.retryAtMs, 'retry time'); if ( record.status !== 'leased' || !sameLease(record, owner, leaseToken, expectedVersion) || record.startedAtMs !== null || atMs < record.updatedAtMs || (retryAtMs !== undefined && retryAtMs <= atMs) ) { throw new ApprovedActionExecutionFenceConflictError(); } const retry = retryAtMs !== undefined && record.attemptCount < record.maxAttempts; return withExecutionDigest({ ...executionWithoutDigest(record), status: retry ? 'retry_wait' : 'blocked', version: record.version + 1, eligibleAtMs: retry ? retryAtMs! : null, nextAttemptAtMs: retry ? retryAtMs! : null, leaseOwner: null, leaseToken: null, leaseExpiresAtMs: null, resultMutationId, resultCode: normalizedResultCode, resultDigest: null, completedAtMs: retry ? null : atMs, updatedAtMs: atMs, }); } export function completeApprovedActionExecution( recordValue: ApprovedActionExecutionRecord, commandValue: Omit, ): Readonly { const record = normalizeApprovedActionExecutionRecord(recordValue); const command = dataRecord(commandValue, 'complete command'); exactKeys( command, [ 'owner', 'leaseToken', 'expectedVersion', 'resultMutationId', 'outcome', 'resultCode', 'completedAtMs', ], ['resultDigest'], 'complete command', ); const owner = identifier(commandValue.owner, 'lease owner'); const leaseToken = identifier(commandValue.leaseToken, 'lease token'); const expectedVersion = integer( commandValue.expectedVersion, 'expected version', 0, MAX_APPROVED_ACTION_EXECUTION_VERSION, ); const resultMutationId = identifier( commandValue.resultMutationId, 'result mutation id', ); const normalizedResultCode = resultCode(commandValue.resultCode); const completedAtMs = timestamp(commandValue.completedAtMs, 'completion time'); const resultDigestValue = commandValue.resultDigest === undefined ? null : digest(commandValue.resultDigest, 'result digest'); if ( !['succeeded', 'failed', 'indeterminate'].includes(commandValue.outcome) || record.status !== 'executing' || !sameLease(record, owner, leaseToken, expectedVersion) || record.startedAtMs === null || completedAtMs < record.updatedAtMs || (commandValue.outcome === 'succeeded' && resultDigestValue === null) || (commandValue.outcome !== 'succeeded' && resultDigestValue !== null) ) { throw new ApprovedActionExecutionFenceConflictError(); } const status = commandValue.outcome === 'indeterminate' ? 'blocked' : commandValue.outcome; return withExecutionDigest({ ...executionWithoutDigest(record), status, version: record.version + 1, eligibleAtMs: null, nextAttemptAtMs: null, leaseOwner: null, leaseToken: null, leaseExpiresAtMs: null, resultMutationId, resultCode: normalizedResultCode, resultDigest: resultDigestValue, completedAtMs, updatedAtMs: completedAtMs, }); }