feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,372 @@
import {
normalizeProjectPolicySubject,
type ProjectRole,
} from '../security/project-policy/projectPolicy';
import {
normalizeSecurityPolicyDecision,
type SecurityPolicyFence,
type SecuritySubject,
} from '../security/security';
import { RUN_STATUSES, type RunStatus } from './run';
export const RUN_CANCELLATION_SCHEMA = 'qinglong/run-cancellation@v1' as const;
/** @deprecated Use RUN_CANCELLATION_SCHEMA from the profile-neutral export. */
export const CLUSTER_RUN_CANCELLATION_SCHEMA = RUN_CANCELLATION_SCHEMA;
export const CLUSTER_RUN_CANCELLATION_STATUSES = [
'accepted',
'already_requested',
'already_terminal',
] as const;
export type ClusterRunCancellationStatus =
(typeof CLUSTER_RUN_CANCELLATION_STATUSES)[number];
export interface ClusterRunCancellationRequestBody {
readonly schema: typeof CLUSTER_RUN_CANCELLATION_SCHEMA;
readonly mutationId: string;
}
export interface ClusterRunCancellationWorkflowTarget {
readonly packageName: string;
readonly workflowId: string;
}
export interface ClusterRunCancellationCommand {
readonly projectId: string;
readonly runId: string;
readonly mutationId: string;
readonly eventId: string;
readonly subject: Readonly<SecuritySubject>;
readonly policyFence: Readonly<SecurityPolicyFence>;
readonly workflowTarget?: Readonly<ClusterRunCancellationWorkflowTarget>;
}
export interface ClusterRunCancellationResult {
readonly status: ClusterRunCancellationStatus;
readonly projectId: string;
readonly runId: string;
readonly runStatus: RunStatus;
readonly runVersion: number;
readonly eventSequence: number;
readonly cancelRequestedAtMs?: number;
readonly cancelReason?: 'user' | 'policy' | 'shutdown' | 'reconcile' | 'timeout';
}
export interface ClusterRunCancellationResponseBody
extends ClusterRunCancellationResult {
readonly schema: typeof CLUSTER_RUN_CANCELLATION_SCHEMA;
}
export interface ClusterRunCancellationRepository {
requestUserCancellation(
command: Readonly<ClusterRunCancellationCommand>,
): Promise<Readonly<ClusterRunCancellationResult>>;
}
export type ClusterRunCancellationFenceReason =
| 'authorization_changed'
| 'project_mismatch'
| 'state_mismatch';
export class InvalidClusterRunCancellationError extends TypeError {
constructor(message: string) {
super(`Cluster Run cancellation is invalid: ${message}`);
this.name = 'InvalidClusterRunCancellationError';
}
}
export class ClusterRunCancellationNotFoundError extends Error {
readonly code = 'CLUSTER_RUN_CANCELLATION_NOT_FOUND';
constructor() {
super('Cluster Run cancellation target does not exist');
this.name = 'ClusterRunCancellationNotFoundError';
}
}
export class ClusterRunCancellationFenceRejectedError extends Error {
readonly code = 'CLUSTER_RUN_CANCELLATION_FENCE_REJECTED';
constructor(readonly reason: ClusterRunCancellationFenceReason) {
super(`Cluster Run cancellation fence rejected: ${reason}`);
this.name = 'ClusterRunCancellationFenceRejectedError';
}
}
export class ClusterRunCancellationUnavailableError extends Error {
readonly code = 'CLUSTER_RUN_CANCELLATION_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Cluster Run cancellation is unavailable', options);
this.name = 'ClusterRunCancellationUnavailableError';
}
}
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const EVENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
const PACKAGE_NAME_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const WORKFLOW_ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
const TERMINAL = new Set<RunStatus>([
'succeeded',
'failed',
'cancelled',
'timed_out',
]);
const CANCEL_REASONS = new Set([
'user',
'policy',
'shutdown',
'reconcile',
'timeout',
]);
function exactKeys(value: object, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidClusterRunCancellationError('shape is invalid');
}
}
function identifier(value: unknown, name: string): string {
if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
throw new InvalidClusterRunCancellationError(`${name} is invalid`);
}
return value;
}
function counter(value: unknown, name: string, minimum: number): number {
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < minimum ||
value > 2_147_483_647
) {
throw new InvalidClusterRunCancellationError(`${name} is invalid`);
}
return value;
}
function timestamp(value: unknown, name: string): number {
if (
typeof value !== 'number' ||
!Number.isSafeInteger(value) ||
value < 0
) {
throw new InvalidClusterRunCancellationError(`${name} is invalid`);
}
return value;
}
export function parseClusterRunCancellationRequestBody(
value: unknown,
): Readonly<ClusterRunCancellationRequestBody> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidClusterRunCancellationError('request body is invalid');
}
exactKeys(value, ['schema', 'mutationId']);
const body = value as Record<string, unknown>;
if (body.schema !== CLUSTER_RUN_CANCELLATION_SCHEMA) {
throw new InvalidClusterRunCancellationError('schema is invalid');
}
return Object.freeze({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
mutationId: identifier(body.mutationId, 'mutationId'),
});
}
export function normalizeClusterRunCancellationCommand(
value: ClusterRunCancellationCommand,
): Readonly<ClusterRunCancellationCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidClusterRunCancellationError('command is invalid');
}
const hasWorkflowTarget = value.workflowTarget !== undefined;
exactKeys(
value,
[
'projectId',
'runId',
'mutationId',
'eventId',
'subject',
'policyFence',
...(hasWorkflowTarget ? ['workflowTarget'] : []),
],
);
if (typeof value.eventId !== 'string' || !EVENT_ID_PATTERN.test(value.eventId)) {
throw new InvalidClusterRunCancellationError('eventId is invalid');
}
let subject: Readonly<SecuritySubject>;
let fence: Readonly<SecurityPolicyFence> | null;
try {
subject = normalizeProjectPolicySubject(value.subject);
fence = normalizeSecurityPolicyDecision({
effect: 'allow',
reasons: ['role_grant'],
fence: value.policyFence,
}).fence;
} catch {
throw new InvalidClusterRunCancellationError(
'authorization authority is invalid',
);
}
if (!fence || fence.bindingVersion === null) {
throw new InvalidClusterRunCancellationError(
'authorization fence is incomplete',
);
}
let workflowTarget:
| Readonly<ClusterRunCancellationWorkflowTarget>
| undefined;
if (hasWorkflowTarget) {
const target = value.workflowTarget;
if (!target || typeof target !== 'object' || Array.isArray(target)) {
throw new InvalidClusterRunCancellationError(
'workflowTarget is invalid',
);
}
exactKeys(target, ['packageName', 'workflowId']);
if (
typeof target.packageName !== 'string' ||
!PACKAGE_NAME_PATTERN.test(target.packageName) ||
typeof target.workflowId !== 'string' ||
!WORKFLOW_ID_PATTERN.test(target.workflowId)
) {
throw new InvalidClusterRunCancellationError(
'workflowTarget is invalid',
);
}
workflowTarget = Object.freeze({
packageName: target.packageName,
workflowId: target.workflowId,
});
}
return Object.freeze({
projectId: identifier(value.projectId, 'projectId'),
runId: identifier(value.runId, 'runId'),
mutationId: identifier(value.mutationId, 'mutationId'),
eventId: value.eventId,
subject,
policyFence: fence,
...(workflowTarget === undefined ? {} : { workflowTarget }),
});
}
export function normalizeClusterRunCancellationResult(
value: ClusterRunCancellationResult,
): Readonly<ClusterRunCancellationResult> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidClusterRunCancellationError('result is invalid');
}
const hasCancellation = value.cancelRequestedAtMs !== undefined;
exactKeys(
value,
hasCancellation
? [
'status', 'projectId', 'runId', 'runStatus', 'runVersion',
'eventSequence', 'cancelRequestedAtMs', 'cancelReason',
]
: [
'status', 'projectId', 'runId', 'runStatus', 'runVersion',
'eventSequence',
],
);
if (
!CLUSTER_RUN_CANCELLATION_STATUSES.includes(value.status) ||
!RUN_STATUSES.includes(value.runStatus) ||
(value.status === 'accepted' && !hasCancellation) ||
(value.status === 'already_requested' && !hasCancellation) ||
(value.status === 'already_terminal' && !TERMINAL.has(value.runStatus)) ||
(value.status !== 'already_terminal' && TERMINAL.has(value.runStatus))
) {
throw new InvalidClusterRunCancellationError('result state is invalid');
}
let cancelRequestedAtMs: number | undefined;
let cancelReason: ClusterRunCancellationResult['cancelReason'];
if (hasCancellation) {
cancelRequestedAtMs = timestamp(
value.cancelRequestedAtMs,
'cancelRequestedAtMs',
);
if (!CANCEL_REASONS.has(value.cancelReason ?? '')) {
throw new InvalidClusterRunCancellationError('cancelReason is invalid');
}
cancelReason = value.cancelReason;
} else if (value.cancelReason !== undefined) {
throw new InvalidClusterRunCancellationError('cancellation shape is invalid');
}
return Object.freeze({
status: value.status,
projectId: identifier(value.projectId, 'projectId'),
runId: identifier(value.runId, 'runId'),
runStatus: value.runStatus,
runVersion: counter(value.runVersion, 'runVersion', 1),
eventSequence: counter(value.eventSequence, 'eventSequence', 0),
...(cancelRequestedAtMs === undefined
? {}
: { cancelRequestedAtMs, cancelReason: cancelReason! }),
});
}
export function createClusterRunCancellationResponseBody(
value: ClusterRunCancellationResult,
): Readonly<ClusterRunCancellationResponseBody> {
return Object.freeze({
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
...normalizeClusterRunCancellationResult(value),
});
}
export function parseClusterRunCancellationResponseBody(
value: unknown,
): Readonly<ClusterRunCancellationResponseBody> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidClusterRunCancellationError('response body is invalid');
}
const { schema, ...result } = value as Record<string, unknown>;
if (schema !== CLUSTER_RUN_CANCELLATION_SCHEMA) {
throw new InvalidClusterRunCancellationError('schema is invalid');
}
return createClusterRunCancellationResponseBody(
result as unknown as ClusterRunCancellationResult,
);
}
export type ClusterRunCancellationAllowedRole = Extract<
ProjectRole,
'owner' | 'admin' | 'operator'
>;
// Profile-neutral names are canonical for new Local and Cluster consumers.
// Cluster-prefixed names remain source-compatible throughout the 3.0 Alpha.
export const RUN_CANCELLATION_STATUSES = CLUSTER_RUN_CANCELLATION_STATUSES;
export type RunCancellationStatus = ClusterRunCancellationStatus;
export type RunCancellationRequestBody = ClusterRunCancellationRequestBody;
export type RunCancellationWorkflowTarget =
ClusterRunCancellationWorkflowTarget;
export type RunCancellationCommand = ClusterRunCancellationCommand;
export type RunCancellationResult = ClusterRunCancellationResult;
export type RunCancellationResponseBody = ClusterRunCancellationResponseBody;
export type RunCancellationRepository = ClusterRunCancellationRepository;
export type RunCancellationFenceReason = ClusterRunCancellationFenceReason;
export type RunCancellationAllowedRole = ClusterRunCancellationAllowedRole;
export const InvalidRunCancellationError =
InvalidClusterRunCancellationError;
export const RunCancellationNotFoundError =
ClusterRunCancellationNotFoundError;
export const RunCancellationFenceRejectedError =
ClusterRunCancellationFenceRejectedError;
export const RunCancellationUnavailableError =
ClusterRunCancellationUnavailableError;
export const parseRunCancellationRequestBody =
parseClusterRunCancellationRequestBody;
export const normalizeRunCancellationCommand =
normalizeClusterRunCancellationCommand;
export const normalizeRunCancellationResult =
normalizeClusterRunCancellationResult;
export const createRunCancellationResponseBody =
createClusterRunCancellationResponseBody;
export const parseRunCancellationResponseBody =
parseClusterRunCancellationResponseBody;
@@ -0,0 +1,241 @@
import { MAX_STEP_RUNS_PER_RUN } from './stepRun';
export const MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE = 128;
export const MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGES_PER_CYCLE = 64;
export interface ClusterRunCancellationConvergencePageCommand {
readonly limit: number;
}
export interface ClusterRunCancellationConvergencePageResult {
readonly scanned: number;
readonly settledRuns: number;
readonly settledAttempts: number;
readonly blocked: number;
readonly hasMore: boolean;
}
export interface ClusterRunCancellationConvergenceRepository {
convergePage(
command: Readonly<ClusterRunCancellationConvergencePageCommand>,
): Promise<Readonly<ClusterRunCancellationConvergencePageResult>>;
}
export interface ClusterRunCancellationConvergenceCycleResult
extends ClusterRunCancellationConvergencePageResult {
readonly pages: number;
readonly remaining: boolean;
readonly stopReason: 'complete' | 'page_limit' | 'blocked';
}
export interface ClusterRunCancellationConvergenceCoordinatorOptions {
readonly pageSize?: number;
readonly maxPages?: number;
}
export class ClusterRunCancellationConvergenceUnavailableError extends Error {
readonly code = 'CLUSTER_RUN_CANCELLATION_CONVERGENCE_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Cluster Run cancellation convergence is unavailable', options);
this.name = 'ClusterRunCancellationConvergenceUnavailableError';
}
}
function boundedInteger(
name: string,
value: number,
minimum: number,
maximum: number,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
}
return value;
}
export function normalizeClusterRunCancellationConvergencePageCommand(
value: ClusterRunCancellationConvergencePageCommand,
): Readonly<ClusterRunCancellationConvergencePageCommand> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Cluster Run cancellation convergence command is invalid');
}
const keys = Object.keys(value).sort();
if (keys.length !== 1 || keys[0] !== 'limit') {
throw new TypeError('Cluster Run cancellation convergence command shape is invalid');
}
const limit = boundedInteger(
'Cluster Run cancellation convergence page size',
value.limit,
1,
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE,
);
return Object.freeze({ limit });
}
export function normalizeClusterRunCancellationConvergencePageResult(
value: ClusterRunCancellationConvergencePageResult,
limit = MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE,
): Readonly<ClusterRunCancellationConvergencePageResult> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Cluster Run cancellation convergence result is invalid');
}
const keys = Object.keys(value).sort();
if (
keys.length !== 5 ||
keys.join(',') !== 'blocked,hasMore,scanned,settledAttempts,settledRuns'
) {
throw new TypeError('Cluster Run cancellation convergence result shape is invalid');
}
const maximum = boundedInteger(
'Cluster Run cancellation convergence result limit',
limit,
1,
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE,
);
const scanned = boundedInteger(
'Cluster Run cancellation convergence scanned count',
value.scanned,
0,
maximum,
);
const settledRuns = boundedInteger(
'Cluster Run cancellation convergence settled Run count',
value.settledRuns,
0,
scanned,
);
const settledAttempts = boundedInteger(
'Cluster Run cancellation convergence settled Attempt count',
value.settledAttempts,
0,
scanned * MAX_STEP_RUNS_PER_RUN,
);
const blocked = boundedInteger(
'Cluster Run cancellation convergence blocked count',
value.blocked,
0,
scanned - settledRuns,
);
if (typeof value.hasMore !== 'boolean') {
throw new TypeError('Cluster Run cancellation convergence continuation is invalid');
}
return Object.freeze({
scanned,
settledRuns,
settledAttempts,
blocked,
hasMore: value.hasMore,
});
}
/**
* Runs a bounded, sequential convergence cycle. It owns no timer or connection;
* deployment profiles choose the cadence and the repository owns row locking.
*/
export class ClusterRunCancellationConvergenceCoordinator {
private readonly pageSize: number;
private readonly maxPages: number;
private inFlight:
| Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>>
| undefined;
constructor(
private readonly repository: ClusterRunCancellationConvergenceRepository,
options: ClusterRunCancellationConvergenceCoordinatorOptions,
) {
if (
typeof repository?.convergePage !== 'function' ||
!options || typeof options !== 'object' || Array.isArray(options)
) {
throw new TypeError('Cluster Run cancellation convergence coordinator is invalid');
}
this.pageSize = boundedInteger(
'Cluster Run cancellation convergence page size',
options.pageSize ?? 32,
1,
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGE_SIZE,
);
this.maxPages = boundedInteger(
'Cluster Run cancellation convergence page limit',
options.maxPages ?? 4,
1,
MAX_CLUSTER_RUN_CANCELLATION_CONVERGENCE_PAGES_PER_CYCLE,
);
}
reconcile(): Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>> {
if (this.inFlight) return this.inFlight;
const operation = this.reconcileOnce().finally(() => {
if (this.inFlight === operation) this.inFlight = undefined;
});
this.inFlight = operation;
return operation;
}
private async reconcileOnce(): Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>> {
let pages = 0;
let scanned = 0;
let settledRuns = 0;
let settledAttempts = 0;
let blocked = 0;
for (; pages < this.maxPages; pages += 1) {
let page: Readonly<ClusterRunCancellationConvergencePageResult>;
try {
page = normalizeClusterRunCancellationConvergencePageResult(
await this.repository.convergePage({
limit: this.pageSize,
}),
this.pageSize,
);
} catch (error) {
if (error instanceof ClusterRunCancellationConvergenceUnavailableError) {
throw error;
}
throw new ClusterRunCancellationConvergenceUnavailableError({ cause: error });
}
scanned += page.scanned;
settledRuns += page.settledRuns;
settledAttempts += page.settledAttempts;
blocked += page.blocked;
const completedPages = pages + 1;
if (page.blocked > 0) {
return Object.freeze({
pages: completedPages,
scanned,
settledRuns,
settledAttempts,
blocked,
hasMore: page.hasMore,
remaining: true,
stopReason: 'blocked' as const,
});
}
if (!page.hasMore) {
return Object.freeze({
pages: completedPages,
scanned,
settledRuns,
settledAttempts,
blocked,
hasMore: false,
remaining: false,
stopReason: 'complete' as const,
});
}
if (page.scanned === 0 || page.settledRuns === 0) {
throw new ClusterRunCancellationConvergenceUnavailableError();
}
}
return Object.freeze({
pages,
scanned,
settledRuns,
settledAttempts,
blocked,
hasMore: true,
remaining: true,
stopReason: 'page_limit' as const,
});
}
}
@@ -0,0 +1,513 @@
import type {
RunAttemptRecord,
RunEventRecord,
RunRecord,
} from './run';
import {
runRetryDelayMs,
type RunRetryPolicyRecord,
} from './runRetryPolicy';
export const MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE = 64;
export type ClusterRunLostRetryDisposition =
| 'scheduled'
| 'requeued'
| 'failed_disabled'
| 'failed_unsafe'
| 'failed_exhausted';
export interface ClusterRunLostRetryPageCommand {
readonly limit: number;
}
export interface ClusterRunLostRetryPageResult {
readonly scanned: number;
readonly scheduled: number;
readonly requeued: number;
readonly failed: number;
readonly raced: number;
readonly hasMore: boolean;
}
export interface ClusterRunLostRetryRepository {
reconcilePage(
command: Readonly<ClusterRunLostRetryPageCommand>,
): Promise<Readonly<ClusterRunLostRetryPageResult>>;
}
export interface ClusterRunLostRetryCoordinatorOptions {
readonly pageSize?: number;
}
export interface ClusterRunLostRetryTransitionInput {
readonly run: Readonly<RunRecord>;
readonly attempt: Readonly<RunAttemptRecord>;
readonly policy: Readonly<RunRetryPolicyRecord> | null;
readonly observedAtMs: number;
readonly runEventId: string;
readonly attemptId?: string;
readonly attemptEventId?: string;
}
export interface ClusterRunLostRetryTransition {
readonly disposition: ClusterRunLostRetryDisposition;
readonly runTransitions: readonly Readonly<RunRecord>[];
readonly policy?: Readonly<RunRetryPolicyRecord>;
readonly attempt?: Readonly<RunAttemptRecord>;
readonly events: readonly Readonly<RunEventRecord>[];
}
export class ClusterRunLostRetryUnavailableError extends Error {
readonly code = 'CLUSTER_RUN_LOST_RETRY_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Cluster Run lost retry is unavailable', options);
this.name = 'ClusterRunLostRetryUnavailableError';
}
}
export class InvalidClusterRunLostRetryTransitionError extends TypeError {
readonly code = 'CLUSTER_RUN_LOST_RETRY_INVALID';
constructor(message: string) {
super(`Cluster Run lost retry is invalid: ${message}`);
this.name = 'InvalidClusterRunLostRetryTransitionError';
}
}
function invalid(message: string): never {
throw new InvalidClusterRunLostRetryTransitionError(message);
}
function boundedInteger(
name: string,
value: number,
minimum: number,
maximum: number,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
}
return value;
}
function identifier(name: string, value: string | undefined): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
/[\u0000-\u001f\u007f]/.test(value)
) {
return invalid(`${name} is invalid`);
}
return value;
}
function reserve(
current: Readonly<RunRecord>,
status: RunRecord['status'],
atMs: number,
error?: Readonly<{ code: string; summary: string }>,
): Readonly<RunRecord> {
const version = current.version + 1;
const eventSequence = current.eventSequence + 1;
if (
!Number.isSafeInteger(version) ||
version < 1 ||
!Number.isSafeInteger(eventSequence) ||
eventSequence < 1
) {
return invalid('Run counter overflowed');
}
const next: RunRecord = {
...current,
status,
version,
eventSequence,
};
if (status === 'queued') {
next.queuedAtMs = atMs;
delete next.errorCode;
delete next.errorSummary;
} else if (error) {
next.errorCode = error.code;
next.errorSummary = error.summary;
}
if (status === 'failed') next.finishedAtMs = atMs;
return Object.freeze(next);
}
function event(
id: string,
run: Readonly<RunRecord>,
attemptId: string,
type: string,
dedupeKey: string,
createdAtMs: number,
payload: Readonly<Record<string, unknown>>,
): Readonly<RunEventRecord> {
return Object.freeze({
id: identifier('event ID', id),
runId: run.id,
sequence: run.eventSequence,
type,
dedupeKey,
actorType: 'reconciler',
attemptId,
payload: Object.freeze({ ...payload, version: run.version }),
createdAtMs,
});
}
function transitionTime(
input: Readonly<ClusterRunLostRetryTransitionInput>,
): number {
const atMs = input.observedAtMs;
if (!Number.isSafeInteger(atMs) || atMs < 0) {
return invalid('observation time is invalid');
}
const lowerBound = Math.max(
input.run.createdAtMs,
input.run.startedAtMs ?? 0,
input.attempt.createdAtMs,
input.attempt.startedAtMs ?? 0,
input.attempt.finishedAtMs ?? 0,
input.policy?.updatedAtMs ?? 0,
);
if (atMs < lowerBound) return invalid('observation precedes durable state');
return atMs;
}
function finish(
input: Readonly<ClusterRunLostRetryTransitionInput>,
atMs: number,
disposition:
| 'failed_disabled'
| 'failed_unsafe'
| 'failed_exhausted',
error: Readonly<{ code: string; summary: string }>,
): Readonly<ClusterRunLostRetryTransition> {
const run = reserve(input.run, 'failed', atMs, error);
let policy = input.policy ?? undefined;
if (input.policy?.nextAttemptAtMs !== undefined) {
const { nextAttemptAtMs: _nextAttemptAtMs, ...withoutNextAttempt } =
input.policy;
policy = Object.freeze({
...withoutNextAttempt,
version: input.policy.version + 1,
updatedAtMs: atMs,
});
}
return Object.freeze({
disposition,
runTransitions: Object.freeze([run]),
...(policy === undefined ? {} : { policy }),
events: Object.freeze([
event(
input.runEventId,
run,
input.attempt.id,
'run.failed',
`cluster-lost-retry:${disposition}:${input.attempt.id}`,
atMs,
{
from_status: input.run.status,
to_status: 'failed',
attempt: input.attempt.attempt,
error_code: error.code,
},
),
]),
});
}
/**
* Pure, profile-neutral lost recovery policy. Storage owns row locking and
* persistence; this function can only schedule, create a fresh Attempt, or
* terminalize an unsafe/exhausted Run.
*/
export function buildClusterRunLostRetryTransition(
input: Readonly<ClusterRunLostRetryTransitionInput>,
): Readonly<ClusterRunLostRetryTransition> {
const { run, attempt, policy } = input;
if (
!run ||
!attempt ||
run.executionOwner !== 'runtime' ||
run.triggerType === 'plugin_package_workflow' ||
(run.status !== 'lost' && run.status !== 'retry_wait') ||
run.cancelRequestedAtMs !== undefined ||
attempt.runId !== run.id ||
attempt.status !== 'lost' ||
attempt.attempt < 1
) {
return invalid('aggregate is not eligible');
}
const atMs = transitionTime(input);
identifier('Run ID', run.id);
identifier('Attempt ID', attempt.id);
identifier('Run event ID', input.runEventId);
if (!policy || !policy.retryOnLost || policy.maxAttempts <= 1) {
return finish(input, atMs, 'failed_disabled', {
code: 'RUN_LOST_RETRY_DISABLED',
summary: 'Run was lost and automatic retry was not enabled at admission',
});
}
if (policy.runId !== run.id) return invalid('retry policy Run mismatches');
if (policy.safety === 'unknown') {
return finish(input, atMs, 'failed_unsafe', {
code: 'RUN_LOST_RETRY_UNSAFE',
summary: 'Run was lost but execution safety was not declared',
});
}
if (attempt.attempt >= policy.maxAttempts) {
return finish(input, atMs, 'failed_exhausted', {
code: 'RUN_LOST_RETRY_EXHAUSTED',
summary: 'Run exhausted its admitted automatic retry attempts',
});
}
if (run.status === 'lost') {
const nextAttemptAtMs =
Math.max(run.createdAtMs, attempt.createdAtMs, attempt.finishedAtMs ?? 0) +
runRetryDelayMs(policy, attempt.attempt);
if (!Number.isSafeInteger(nextAttemptAtMs)) {
return invalid('next Attempt time overflowed');
}
const error = {
code: 'RUN_LOST_RETRY_SCHEDULED',
summary: 'A fresh Attempt will be created after the admitted backoff',
};
const nextRun = reserve(run, 'retry_wait', atMs, error);
const nextPolicy = Object.freeze({
...policy,
nextAttemptAtMs,
version: policy.version + 1,
updatedAtMs: atMs,
});
return Object.freeze({
disposition: 'scheduled',
runTransitions: Object.freeze([nextRun]),
policy: nextPolicy,
events: Object.freeze([
event(
input.runEventId,
nextRun,
attempt.id,
'run.retry_wait',
`cluster-lost-retry:scheduled:${attempt.id}`,
atMs,
{
from_status: 'lost',
to_status: 'retry_wait',
attempt: attempt.attempt,
max_attempts: policy.maxAttempts,
safety: policy.safety,
next_attempt_at_ms: nextAttemptAtMs,
error_code: error.code,
},
),
]),
});
}
if (
policy.nextAttemptAtMs === undefined ||
policy.nextAttemptAtMs > atMs
) {
return invalid('retry_wait policy is not due');
}
const nextAttemptId = identifier('replacement Attempt ID', input.attemptId);
const nextAttemptEventId = identifier(
'Attempt event ID',
input.attemptEventId,
);
const queued = reserve(run, 'queued', atMs);
const claimed = reserve(queued, 'queued', atMs);
const replacement = Object.freeze({
id: nextAttemptId,
runId: run.id,
attempt: attempt.attempt + 1,
status: 'claimed' as const,
executorType: attempt.executorType,
callbackSequence: 0,
createdAtMs: atMs,
});
const { nextAttemptAtMs: _nextAttemptAtMs, ...withoutNextAttempt } = policy;
const nextPolicy = Object.freeze({
...withoutNextAttempt,
version: policy.version + 1,
updatedAtMs: atMs,
});
return Object.freeze({
disposition: 'requeued',
runTransitions: Object.freeze([queued, claimed]),
policy: nextPolicy,
attempt: replacement,
events: Object.freeze([
event(
input.runEventId,
queued,
replacement.id,
'run.queued',
`cluster-lost-retry:queued:${replacement.id}`,
atMs,
{
from_status: 'retry_wait',
to_status: 'queued',
previous_attempt_id: attempt.id,
},
),
event(
nextAttemptEventId,
claimed,
replacement.id,
'attempt.claimed',
`cluster-lost-retry:attempt-claimed:${replacement.id}`,
atMs,
{
attempt: replacement.attempt,
executor_type: replacement.executorType,
previous_attempt_id: attempt.id,
},
),
]),
});
}
export function normalizeClusterRunLostRetryPageCommand(
value: ClusterRunLostRetryPageCommand,
): Readonly<ClusterRunLostRetryPageCommand> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).length !== 1 ||
!Object.prototype.hasOwnProperty.call(value, 'limit')
) {
throw new TypeError('Cluster Run lost retry command is invalid');
}
return Object.freeze({
limit: boundedInteger(
'Cluster Run lost retry page size',
value.limit,
1,
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
),
});
}
export function normalizeClusterRunLostRetryPageResult(
value: ClusterRunLostRetryPageResult,
limit = MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
): Readonly<ClusterRunLostRetryPageResult> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).sort().join(',') !==
'failed,hasMore,raced,requeued,scanned,scheduled'
) {
throw new TypeError('Cluster Run lost retry result is invalid');
}
const maximum = boundedInteger(
'Cluster Run lost retry result limit',
limit,
1,
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
);
const scanned = boundedInteger(
'Cluster Run lost retry scanned count',
value.scanned,
0,
maximum,
);
const scheduled = boundedInteger(
'Cluster Run lost retry scheduled count',
value.scheduled,
0,
scanned,
);
const requeued = boundedInteger(
'Cluster Run lost retry requeued count',
value.requeued,
0,
scanned,
);
const failed = boundedInteger(
'Cluster Run lost retry failed count',
value.failed,
0,
scanned,
);
const raced = boundedInteger(
'Cluster Run lost retry raced count',
value.raced,
0,
scanned,
);
if (
scheduled + requeued + failed + raced !== scanned ||
typeof value.hasMore !== 'boolean'
) {
throw new TypeError('Cluster Run lost retry result counts are invalid');
}
return Object.freeze({
scanned,
scheduled,
requeued,
failed,
raced,
hasMore: value.hasMore,
});
}
/** One non-overlapping bounded page; deployment owns the shared cadence. */
export class ClusterRunLostRetryCoordinator {
private readonly pageSize: number;
private inFlight:
| Promise<Readonly<ClusterRunLostRetryPageResult>>
| undefined;
constructor(
private readonly repository: ClusterRunLostRetryRepository,
options: ClusterRunLostRetryCoordinatorOptions = {},
) {
if (
typeof repository?.reconcilePage !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options)
) {
throw new TypeError('Cluster Run lost retry coordinator is invalid');
}
this.pageSize = boundedInteger(
'Cluster Run lost retry page size',
options.pageSize ?? 16,
1,
MAX_CLUSTER_RUN_LOST_RETRY_PAGE_SIZE,
);
}
reconcile(): Promise<Readonly<ClusterRunLostRetryPageResult>> {
if (this.inFlight) return this.inFlight;
const operation = Promise.resolve()
.then(() =>
this.repository.reconcilePage({ limit: this.pageSize }),
)
.then((result) =>
normalizeClusterRunLostRetryPageResult(result, this.pageSize),
)
.catch((error: unknown) => {
if (error instanceof ClusterRunLostRetryUnavailableError) throw error;
throw new ClusterRunLostRetryUnavailableError({ cause: error });
})
.finally(() => {
if (this.inFlight === operation) this.inFlight = undefined;
});
this.inFlight = operation;
return operation;
}
}
@@ -0,0 +1,84 @@
import type { RunRecord } from './run';
export const MAX_PROJECT_RUN_LIST_STORAGE_LIMIT = 65;
export interface ProjectRunListCursor {
readonly createdAtMs: number;
readonly runId: string;
}
export interface ProjectRunListQuery {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<ProjectRunListCursor>;
}
export interface ProjectRunListReader {
listRunsByProject(
query: Readonly<ProjectRunListQuery>,
): Promise<readonly RunRecord[]>;
}
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
export function normalizeProjectRunListQuery(
value: Readonly<ProjectRunListQuery>,
): Readonly<ProjectRunListQuery> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Project Run list query is invalid');
}
const keys = Reflect.ownKeys(value);
const afterKeys =
value.after &&
typeof value.after === 'object' &&
!Array.isArray(value.after)
? Reflect.ownKeys(value.after)
: [];
if (
keys.length < 2 ||
keys.length > 3 ||
keys.some(
(key) => key !== 'projectId' && key !== 'limit' && key !== 'after',
) ||
!Object.hasOwn(value, 'projectId') ||
!Object.hasOwn(value, 'limit') ||
!boundedText(value.projectId, 128) ||
!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_PROJECT_RUN_LIST_STORAGE_LIMIT ||
(value.after !== undefined &&
(!value.after ||
typeof value.after !== 'object' ||
Array.isArray(value.after) ||
afterKeys.length !== 2 ||
afterKeys.some((key) => key !== 'createdAtMs' && key !== 'runId') ||
!Object.hasOwn(value.after, 'createdAtMs') ||
!Object.hasOwn(value.after, 'runId') ||
!Number.isSafeInteger(value.after.createdAtMs) ||
value.after.createdAtMs < 0 ||
!boundedText(value.after.runId, 128)))
) {
throw new TypeError('Project Run list query is invalid');
}
return Object.freeze({
projectId: value.projectId,
limit: value.limit,
...(value.after === undefined
? {}
: {
after: Object.freeze({
createdAtMs: value.after.createdAtMs,
runId: value.after.runId,
}),
}),
});
}
@@ -0,0 +1,192 @@
import {
RUN_EVENT_ACTOR_TYPES,
type RunEventRecord,
type RunRecord,
} from '../run';
import type { RunRepositoryReader } from '../runRepository';
export const DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT = 32;
export const MAX_BOUNDED_RUN_EVENT_LIST_LIMIT = 64;
const MAX_INT = 2_147_483_647;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export interface BoundedRunEventListInput {
readonly afterSequence?: number;
readonly limit?: number;
}
export interface BoundedRunEventListItem {
readonly sequence: number;
readonly type: string;
readonly actorType: RunEventRecord['actorType'];
readonly createdAtMs: number;
}
export interface BoundedRunEventListProjection {
readonly found: boolean;
readonly events: readonly Readonly<BoundedRunEventListItem>[];
readonly hasMore: boolean;
readonly nextAfterSequence: number;
}
export class InvalidBoundedRunEventListProjectionError extends TypeError {
readonly code = 'BOUNDED_RUN_EVENT_LIST_PROJECTION_INVALID';
constructor() {
super('Bounded Run event list projection input is invalid');
this.name = 'InvalidBoundedRunEventListProjectionError';
}
}
export class BoundedRunEventListProjectionUnavailableError extends Error {
readonly code = 'BOUNDED_RUN_EVENT_LIST_PROJECTION_UNAVAILABLE';
constructor() {
super('Bounded Run event list projection is unavailable');
this.name = 'BoundedRunEventListProjectionUnavailableError';
}
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function normalizeInput(value: Readonly<BoundedRunEventListInput>): Readonly<{
afterSequence: number;
limit: number;
}> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidBoundedRunEventListProjectionError();
}
const keys = Reflect.ownKeys(value);
if (
keys.length > 2 ||
keys.some((key) => key !== 'afterSequence' && key !== 'limit') ||
(value.afterSequence !== undefined &&
!integer(value.afterSequence, 0, MAX_INT)) ||
(value.limit !== undefined &&
!integer(value.limit, 1, MAX_BOUNDED_RUN_EVENT_LIST_LIMIT))
) {
throw new InvalidBoundedRunEventListProjectionError();
}
return Object.freeze({
afterSequence: value.afterSequence ?? 0,
limit: value.limit ?? DEFAULT_BOUNDED_RUN_EVENT_LIST_LIMIT,
});
}
function ownsRun(value: RunRecord, projectId: string, runId: string): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
value.projectId === projectId &&
value.id === runId &&
boundedText(value.projectId, 128) &&
boundedText(value.id, 128)
);
}
function projectEvent(
value: RunEventRecord,
runId: string,
previousSequence: number,
): Readonly<BoundedRunEventListItem> | null {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.runId !== runId ||
!integer(value.sequence, previousSequence + 1, MAX_INT) ||
!boundedText(value.type, 128) ||
!RUN_EVENT_ACTOR_TYPES.includes(value.actorType) ||
!integer(value.createdAtMs, 0, Number.MAX_SAFE_INTEGER)
) {
return null;
}
return Object.freeze({
sequence: value.sequence,
type: value.type,
actorType: value.actorType,
createdAtMs: value.createdAtMs,
});
}
export async function executeBoundedRunEventListProjection(
runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'>,
projectId: string,
runId: string,
input: Readonly<BoundedRunEventListInput>,
): Promise<Readonly<BoundedRunEventListProjection>> {
if (
!runs ||
typeof runs.findRunById !== 'function' ||
typeof runs.listEvents !== 'function' ||
!boundedText(projectId, 128) ||
!boundedText(runId, 128)
) {
throw new InvalidBoundedRunEventListProjectionError();
}
const normalized = normalizeInput(input);
let run: RunRecord | null;
try {
run = await runs.findRunById(runId);
} catch {
throw new BoundedRunEventListProjectionUnavailableError();
}
if (!run || !ownsRun(run, projectId, runId)) {
return Object.freeze({
found: false,
events: Object.freeze([]),
hasMore: false,
nextAfterSequence: normalized.afterSequence,
});
}
let rows: RunEventRecord[];
try {
rows = await runs.listEvents(runId, {
afterSequence: normalized.afterSequence,
limit: normalized.limit + 1,
});
} catch {
throw new BoundedRunEventListProjectionUnavailableError();
}
if (!Array.isArray(rows) || rows.length > normalized.limit + 1) {
throw new BoundedRunEventListProjectionUnavailableError();
}
const events: Readonly<BoundedRunEventListItem>[] = [];
let previousSequence = normalized.afterSequence;
for (let index = 0; index < rows.length; index += 1) {
const projected = projectEvent(rows[index]!, runId, previousSequence);
if (!projected) throw new BoundedRunEventListProjectionUnavailableError();
previousSequence = projected.sequence;
if (index < normalized.limit) events.push(projected);
}
const lastReturned = events.at(-1);
return Object.freeze({
found: true,
events: Object.freeze(events),
hasMore: rows.length > normalized.limit,
nextAfterSequence: lastReturned?.sequence ?? normalized.afterSequence,
});
}
@@ -0,0 +1,237 @@
import {
EXECUTION_ORIGINS,
RUN_STATUSES,
type ExecutionOrigin,
type ExecutionOwner,
type RunRecord,
type RunStatus,
} from '../run';
import {
normalizeProjectRunListQuery,
type ProjectRunListCursor,
type ProjectRunListReader,
} from '../projectRunList';
export const DEFAULT_BOUNDED_RUN_LIST_LIMIT = 32;
export const MAX_BOUNDED_RUN_LIST_LIMIT = 64;
const MAX_INT = 2_147_483_647;
const MIN_INT = -2_147_483_648;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export interface BoundedRunListInput {
readonly limit?: number;
readonly after?: Readonly<ProjectRunListCursor>;
}
export interface BoundedRunListItem {
readonly id: string;
readonly taskId: string;
readonly taskRevision: string;
readonly status: RunStatus;
readonly version: number;
readonly eventSequence: number;
readonly priority: number;
readonly executionOrigin: ExecutionOrigin;
readonly executionOwner: ExecutionOwner;
readonly createdAtMs: number;
readonly queuedAtMs?: number;
readonly startedAtMs?: number;
readonly finishedAtMs?: number;
}
export interface BoundedRunListProjection {
readonly runs: readonly Readonly<BoundedRunListItem>[];
readonly hasMore: boolean;
readonly next?: Readonly<ProjectRunListCursor>;
}
export class InvalidBoundedRunListProjectionError extends TypeError {
readonly code = 'BOUNDED_RUN_LIST_PROJECTION_INVALID';
constructor() {
super('Bounded Run list projection input is invalid');
this.name = 'InvalidBoundedRunListProjectionError';
}
}
export class BoundedRunListProjectionUnavailableError extends Error {
readonly code = 'BOUNDED_RUN_LIST_PROJECTION_UNAVAILABLE';
constructor() {
super('Bounded Run list projection is unavailable');
this.name = 'BoundedRunListProjectionUnavailableError';
}
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function integer(value: unknown, minimum: number, maximum: number): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function cursor(value: unknown): value is Readonly<ProjectRunListCursor> {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Reflect.ownKeys(value).length === 2 &&
Object.hasOwn(value, 'createdAtMs') &&
Object.hasOwn(value, 'runId') &&
integer((value as ProjectRunListCursor).createdAtMs, 0, Number.MAX_SAFE_INTEGER) &&
boundedText((value as ProjectRunListCursor).runId, 128)
);
}
function normalizeInput(value: Readonly<BoundedRunListInput>): Readonly<{
limit: number;
after?: Readonly<ProjectRunListCursor>;
}> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidBoundedRunListProjectionError();
}
const keys = Reflect.ownKeys(value);
if (
keys.length > 2 ||
keys.some((key) => key !== 'limit' && key !== 'after') ||
(value.limit !== undefined &&
!integer(value.limit, 1, MAX_BOUNDED_RUN_LIST_LIMIT)) ||
(value.after !== undefined && !cursor(value.after))
) {
throw new InvalidBoundedRunListProjectionError();
}
return Object.freeze({
limit: value.limit ?? DEFAULT_BOUNDED_RUN_LIST_LIMIT,
...(value.after === undefined
? {}
: {
after: Object.freeze({
createdAtMs: value.after.createdAtMs,
runId: value.after.runId,
}),
}),
});
}
function isBefore(
value: Readonly<ProjectRunListCursor>,
boundary: Readonly<ProjectRunListCursor>,
): boolean {
return (
value.createdAtMs < boundary.createdAtMs ||
(value.createdAtMs === boundary.createdAtMs && value.runId < boundary.runId)
);
}
function optionalTimestamp(value: unknown): value is number | undefined {
return value === undefined || integer(value, 0, Number.MAX_SAFE_INTEGER);
}
function projectRun(
value: RunRecord,
projectId: string,
boundary?: Readonly<ProjectRunListCursor>,
): Readonly<BoundedRunListItem> | null {
const rowCursor = { createdAtMs: value?.createdAtMs, runId: value?.id };
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.projectId !== projectId ||
!cursor(rowCursor) ||
(boundary !== undefined && !isBefore(rowCursor, boundary)) ||
!boundedText(value.taskId, 255) ||
!boundedText(value.taskRevision, 255) ||
!RUN_STATUSES.includes(value.status) ||
!EXECUTION_ORIGINS.includes(value.executionOrigin) ||
(value.executionOwner !== 'legacy' && value.executionOwner !== 'runtime') ||
!integer(value.version, 0, MAX_INT) ||
!integer(value.eventSequence, 0, MAX_INT) ||
!integer(value.priority, MIN_INT, MAX_INT) ||
!optionalTimestamp(value.queuedAtMs) ||
!optionalTimestamp(value.startedAtMs) ||
!optionalTimestamp(value.finishedAtMs)
) {
return null;
}
return Object.freeze({
id: value.id,
taskId: value.taskId,
taskRevision: value.taskRevision,
status: value.status,
version: value.version,
eventSequence: value.eventSequence,
priority: value.priority,
executionOrigin: value.executionOrigin,
executionOwner: value.executionOwner,
createdAtMs: value.createdAtMs,
...(value.queuedAtMs === undefined ? {} : { queuedAtMs: value.queuedAtMs }),
...(value.startedAtMs === undefined ? {} : { startedAtMs: value.startedAtMs }),
...(value.finishedAtMs === undefined ? {} : { finishedAtMs: value.finishedAtMs }),
});
}
export async function executeBoundedRunListProjection(
runs: ProjectRunListReader,
projectId: string,
input: Readonly<BoundedRunListInput>,
): Promise<Readonly<BoundedRunListProjection>> {
if (
!runs ||
typeof runs.listRunsByProject !== 'function' ||
!boundedText(projectId, 128)
) {
throw new InvalidBoundedRunListProjectionError();
}
const normalized = normalizeInput(input);
const query = normalizeProjectRunListQuery({
projectId,
limit: normalized.limit + 1,
...(normalized.after === undefined ? {} : { after: normalized.after }),
});
let rows: readonly RunRecord[];
try {
rows = await runs.listRunsByProject(query);
} catch {
throw new BoundedRunListProjectionUnavailableError();
}
if (!Array.isArray(rows) || rows.length > normalized.limit + 1) {
throw new BoundedRunListProjectionUnavailableError();
}
const projected: Readonly<BoundedRunListItem>[] = [];
let boundary = normalized.after;
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index]!;
const item = projectRun(row, projectId, boundary);
if (!item) throw new BoundedRunListProjectionUnavailableError();
boundary = Object.freeze({ createdAtMs: row.createdAtMs, runId: row.id });
if (index < normalized.limit) projected.push(item);
}
const hasMore = rows.length > normalized.limit;
const lastReturned = projected.at(-1);
return Object.freeze({
runs: Object.freeze(projected),
hasMore,
...(hasMore && lastReturned
? {
next: Object.freeze({
createdAtMs: lastReturned.createdAtMs,
runId: lastReturned.id,
}),
}
: {}),
});
}
@@ -0,0 +1,121 @@
import { EXECUTION_ORIGINS, RUN_STATUSES, type RunRecord } from '../run';
import type { RunRepositoryReader } from '../runRepository';
const MAX_INT = 2_147_483_647;
const MIN_INT = -2_147_483_648;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export type BoundedRunReadProjection = Readonly<
Record<string, boolean | string | number>
>;
export class BoundedRunReadProjectionUnavailableError extends Error {
readonly code = 'BOUNDED_RUN_READ_PROJECTION_UNAVAILABLE';
constructor() {
super('Bounded Run read projection is unavailable');
this.name = 'BoundedRunReadProjectionUnavailableError';
}
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function optionalTimestamp(value: unknown): value is number | undefined {
return value === undefined || integer(value, 0, Number.MAX_SAFE_INTEGER);
}
function projectRun(
value: RunRecord,
projectId: string,
runId: string,
): BoundedRunReadProjection | null {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.projectId !== projectId ||
value.id !== runId ||
!boundedText(value.id, 128) ||
!boundedText(value.projectId, 128) ||
!boundedText(value.taskId, 255) ||
!boundedText(value.taskRevision, 255) ||
!RUN_STATUSES.includes(value.status) ||
!EXECUTION_ORIGINS.includes(value.executionOrigin) ||
(value.executionOwner !== 'legacy' && value.executionOwner !== 'runtime') ||
!integer(value.version, 0, MAX_INT) ||
!integer(value.eventSequence, 0, MAX_INT) ||
!integer(value.priority, MIN_INT, MAX_INT) ||
!integer(value.createdAtMs, 0, Number.MAX_SAFE_INTEGER) ||
!optionalTimestamp(value.queuedAtMs) ||
!optionalTimestamp(value.startedAtMs) ||
!optionalTimestamp(value.finishedAtMs)
) {
return null;
}
return Object.freeze({
found: true,
id: value.id,
taskId: value.taskId,
taskRevision: value.taskRevision,
status: value.status,
version: value.version,
eventSequence: value.eventSequence,
priority: value.priority,
executionOrigin: value.executionOrigin,
executionOwner: value.executionOwner,
createdAtMs: value.createdAtMs,
...(value.queuedAtMs === undefined ? {} : { queuedAtMs: value.queuedAtMs }),
...(value.startedAtMs === undefined
? {}
: { startedAtMs: value.startedAtMs }),
...(value.finishedAtMs === undefined
? {}
: { finishedAtMs: value.finishedAtMs }),
});
}
export async function executeBoundedRunReadProjection(
runs: Pick<RunRepositoryReader, 'findRunById'>,
projectId: string,
runId: string,
): Promise<BoundedRunReadProjection> {
if (
!runs ||
typeof runs.findRunById !== 'function' ||
!boundedText(projectId, 128) ||
!boundedText(runId, 128)
) {
throw new TypeError('Bounded Run read projection input is invalid');
}
let run: RunRecord | null;
try {
run = await runs.findRunById(runId);
} catch {
throw new BoundedRunReadProjectionUnavailableError();
}
if (!run || run.projectId !== projectId) {
return Object.freeze({ found: false });
}
const projection = projectRun(run, projectId, runId);
if (!projection) throw new BoundedRunReadProjectionUnavailableError();
return projection;
}
@@ -0,0 +1,230 @@
import type { RunRecord } from '../run';
import type { RunRepositoryReader } from '../runRepository';
import {
normalizeListStepRunsResult,
type StepRunKind,
type StepRunRecord,
type StepRunRepository,
type StepRunStatus,
} from '../stepRun';
export const DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT = 32;
export const MAX_BOUNDED_RUN_STEP_LIST_LIMIT = 64;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export interface BoundedRunStepListCursor {
readonly stepKey: string;
readonly stepRunId: string;
}
export interface BoundedRunStepListInput {
readonly after?: Readonly<BoundedRunStepListCursor>;
readonly limit?: number;
}
export interface BoundedRunStepListItem {
readonly id: string;
readonly parentStepRunId: string | null;
readonly stepKey: string;
readonly kind: StepRunKind;
readonly required: boolean;
readonly status: StepRunStatus;
readonly version: number;
readonly attemptCount: number;
readonly readyAtMs: number | null;
readonly startedAtMs: number | null;
readonly finishedAtMs: number | null;
readonly resultCode: string | null;
readonly createdAtMs: number;
readonly updatedAtMs: number;
}
export interface BoundedRunStepListProjection {
readonly found: boolean;
readonly steps: readonly Readonly<BoundedRunStepListItem>[];
readonly hasMore: boolean;
readonly next: Readonly<BoundedRunStepListCursor> | null;
}
export class InvalidBoundedRunStepListProjectionError extends TypeError {
readonly code = 'BOUNDED_RUN_STEP_LIST_PROJECTION_INVALID';
constructor() {
super('Bounded Run Step list projection input is invalid');
this.name = 'InvalidBoundedRunStepListProjectionError';
}
}
export class BoundedRunStepListProjectionUnavailableError extends Error {
readonly code = 'BOUNDED_RUN_STEP_LIST_PROJECTION_UNAVAILABLE';
constructor() {
super('Bounded Run Step list projection is unavailable');
this.name = 'BoundedRunStepListProjectionUnavailableError';
}
}
function boundedText(value: unknown, maximum: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!CONTROL_PATTERN.test(value)
);
}
function normalizeInput(value: Readonly<BoundedRunStepListInput>): Readonly<{
after?: Readonly<BoundedRunStepListCursor>;
limit: number;
}> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidBoundedRunStepListProjectionError();
}
const keys = Reflect.ownKeys(value);
if (
keys.length > 2 ||
keys.some((key) => key !== 'after' && key !== 'limit') ||
(value.limit !== undefined &&
(!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_BOUNDED_RUN_STEP_LIST_LIMIT))
) {
throw new InvalidBoundedRunStepListProjectionError();
}
let after: Readonly<BoundedRunStepListCursor> | undefined;
if (value.after !== undefined) {
if (
!value.after ||
typeof value.after !== 'object' ||
Array.isArray(value.after) ||
Reflect.ownKeys(value.after).length !== 2 ||
!Object.hasOwn(value.after, 'stepKey') ||
!Object.hasOwn(value.after, 'stepRunId') ||
!boundedText(value.after.stepKey, 128) ||
!boundedText(value.after.stepRunId, 128)
) {
throw new InvalidBoundedRunStepListProjectionError();
}
after = Object.freeze({
stepKey: value.after.stepKey,
stepRunId: value.after.stepRunId,
});
}
return Object.freeze({
...(after === undefined ? {} : { after }),
limit: value.limit ?? DEFAULT_BOUNDED_RUN_STEP_LIST_LIMIT,
});
}
function ownsRun(value: RunRecord, projectId: string, runId: string): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
value.projectId === projectId &&
value.id === runId &&
boundedText(value.projectId, 128) &&
boundedText(value.id, 128)
);
}
function projectStep(
value: Readonly<StepRunRecord>,
): Readonly<BoundedRunStepListItem> {
return Object.freeze({
id: value.id,
parentStepRunId: value.parentStepRunId,
stepKey: value.stepKey,
kind: value.kind,
required: value.required,
status: value.status,
version: value.version,
attemptCount: value.attemptCount,
readyAtMs: value.readyAtMs,
startedAtMs: value.startedAtMs,
finishedAtMs: value.finishedAtMs,
resultCode: value.resultCode,
createdAtMs: value.createdAtMs,
updatedAtMs: value.updatedAtMs,
});
}
export async function executeBoundedRunStepListProjection(
runs: Pick<RunRepositoryReader, 'findRunById'>,
stepRuns: Pick<StepRunRepository, 'listByRun'>,
projectId: string,
runId: string,
input: Readonly<BoundedRunStepListInput>,
): Promise<Readonly<BoundedRunStepListProjection>> {
if (
!runs ||
typeof runs.findRunById !== 'function' ||
!stepRuns ||
typeof stepRuns.listByRun !== 'function' ||
!boundedText(projectId, 128) ||
!boundedText(runId, 128)
) {
throw new InvalidBoundedRunStepListProjectionError();
}
const normalized = normalizeInput(input);
let run: RunRecord | null;
try {
run = await runs.findRunById(runId);
} catch {
throw new BoundedRunStepListProjectionUnavailableError();
}
if (!run || !ownsRun(run, projectId, runId)) {
return Object.freeze({
found: false,
steps: Object.freeze([]),
hasMore: false,
next: null,
});
}
const query = Object.freeze({
runId,
limit: normalized.limit,
...(normalized.after === undefined
? {}
: {
after: Object.freeze({
stepKey: normalized.after.stepKey,
id: normalized.after.stepRunId,
}),
}),
});
try {
const page = normalizeListStepRunsResult(
await stepRuns.listByRun(query),
query,
);
const ids = new Set<string>();
const stepKeys = new Set<string>();
for (const step of page.stepRuns) {
if (ids.has(step.id) || stepKeys.has(step.stepKey)) {
throw new BoundedRunStepListProjectionUnavailableError();
}
ids.add(step.id);
stepKeys.add(step.stepKey);
}
return Object.freeze({
found: true,
steps: Object.freeze(page.stepRuns.map(projectStep)),
hasMore: page.truncated,
next:
page.next === undefined
? null
: Object.freeze({
stepKey: page.next.stepKey,
stepRunId: page.next.id,
}),
});
} catch (error) {
if (error instanceof BoundedRunStepListProjectionUnavailableError) {
throw error;
}
throw new BoundedRunStepListProjectionUnavailableError();
}
}
@@ -0,0 +1,87 @@
export class RunRepositoryError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly retryable = false,
public readonly cause?: unknown,
) {
super(message);
this.name = new.target.name;
}
}
export class DuplicateIdempotencyKeyError extends RunRepositoryError {
constructor(
public readonly projectId: string,
public readonly idempotencyKey: string,
) {
super(
'A Run with the same project idempotency key already exists',
'DUPLICATE_RUN_IDEMPOTENCY_KEY',
);
}
}
export class DuplicateRunAttemptError extends RunRepositoryError {
constructor(public readonly runId: string, public readonly attempt: number) {
super(
'A RunAttempt with the same run and attempt number already exists',
'DUPLICATE_RUN_ATTEMPT',
);
}
}
export class DuplicateRunEventError extends RunRepositoryError {
constructor(
public readonly runId: string,
public readonly dedupeKey?: string,
) {
super(
'A RunEvent with the same sequence or dedupe key already exists',
'DUPLICATE_RUN_EVENT',
);
}
}
export class RunRepositoryConstraintError extends RunRepositoryError {
constructor(
message = 'Run repository constraint violation',
cause?: unknown,
) {
super(message, 'RUN_REPOSITORY_CONSTRAINT', false, cause);
}
}
export class RunRepositoryBusyError extends RunRepositoryError {
constructor(cause?: unknown) {
super(
'Run repository is temporarily busy',
'RUN_REPOSITORY_BUSY',
true,
cause,
);
}
}
export class RunRepositoryOperationError extends RunRepositoryError {
constructor(cause?: unknown) {
super(
'Run repository operation failed',
'RUN_REPOSITORY_OPERATION_FAILED',
false,
cause,
);
}
}
export class RunEventPayloadTooLargeError extends RunRepositoryError {
constructor(
public readonly actualBytes: number,
public readonly maxBytes: number,
) {
super(
'RunEvent payload exceeds the configured size limit',
'RUN_EVENT_PAYLOAD_TOO_LARGE',
);
}
}
+150
View File
@@ -0,0 +1,150 @@
export const RUN_STATUSES = [
'created',
'queued',
'dispatching',
'running',
'waiting_approval',
'retry_wait',
'lost',
'succeeded',
'failed',
'cancelled',
'timed_out',
] as const;
export type RunStatus = (typeof RUN_STATUSES)[number];
export const RUN_ATTEMPT_STATUSES = [
'claimed',
'starting',
'running',
'succeeded',
'failed',
'cancelled',
'timed_out',
'lost',
] as const;
export type RunAttemptStatus = (typeof RUN_ATTEMPT_STATUSES)[number];
export const EXECUTION_ORIGINS = [
'manual',
'scheduled_system',
'scheduled_node',
'once',
'boot',
'grpc',
'subscription',
'system',
'script',
'legacy_import',
] as const;
export type ExecutionOrigin = (typeof EXECUTION_ORIGINS)[number];
export type ExecutionOwner = 'legacy' | 'runtime';
export const RUN_CANCELLATION_REASONS = [
'user',
'policy',
'shutdown',
'reconcile',
'timeout',
] as const;
export type RunCancellationReason = (typeof RUN_CANCELLATION_REASONS)[number];
export const RUN_EVENT_ACTOR_TYPES = [
'user',
'api_app',
'trigger',
'agent',
'mcp_client',
'worker',
'executor',
'system',
'legacy_shell',
'scheduler',
'reconciler',
'compatibility',
] as const;
export type RunEventActorType = (typeof RUN_EVENT_ACTOR_TYPES)[number];
export interface RunRecord {
id: string;
projectId: string;
taskId: string;
taskRevision: string;
taskName?: string;
taskSnapshotRef?: string;
legacyCronId?: number;
parentRunId?: string;
retryOfRunId?: string;
triggerId?: string;
triggerType: string;
executionOrigin: ExecutionOrigin;
executionOwner: ExecutionOwner;
triggeredBy?: string;
requestId?: string;
scheduledForMs?: number;
status: RunStatus;
version: number;
eventSequence: number;
priority: number;
idempotencyKey?: string;
inputRef?: string;
outputRef?: string;
createdAtMs: number;
queuedAtMs?: number;
startedAtMs?: number;
finishedAtMs?: number;
cancelRequestedAtMs?: number;
cancelReason?: RunCancellationReason;
errorCode?: string;
errorSummary?: string;
}
export interface RunAttemptRecord {
id: string;
runId: string;
stepRunId?: string;
attempt: number;
status: RunAttemptStatus;
executorType: string;
workerId?: string;
workerSessionId?: string;
workerGeneration?: number;
executorHandle?: string;
pid?: number;
logArtifactId?: string;
leaseToken?: string;
leaseTokenDigest?: string;
leaseGeneration?: number;
leaseVersion?: number;
leaseExpiresAtMs?: number;
offerId?: string;
deadlineAtMs?: number;
callbackTokenHash?: string;
callbackSequence: number;
createdAtMs: number;
startedAtMs?: number;
finishedAtMs?: number;
exitCode?: number;
errorCode?: string;
errorSummary?: string;
}
export interface RunEventRecord {
id: string;
runId: string;
sequence: number;
type: string;
dedupeKey?: string;
actorType: RunEventActorType;
actorId?: string;
attemptId?: string;
stepRunId?: string;
payload: Readonly<Record<string, unknown>>;
createdAtMs: number;
}
@@ -0,0 +1,240 @@
import { createHash } from 'node:crypto';
import {
assertWorkerId,
assertWorkerSessionId,
} from '../worker/workerSession';
export const RUN_DISPATCH_LEASE_STATUSES = [
'leased',
'released',
'completed',
] as const;
export type RunDispatchLeaseStatus =
(typeof RUN_DISPATCH_LEASE_STATUSES)[number];
export const RUN_DISPATCH_RELEASE_REASONS = [
'declined',
'shutdown',
'start_failed',
'capacity_changed',
'lease_expired',
] as const;
export type RunDispatchReleaseReason =
(typeof RUN_DISPATCH_RELEASE_REASONS)[number];
export const MIN_RUN_DISPATCH_LEASE_DURATION_MS = 5_000;
export const MAX_RUN_DISPATCH_LEASE_DURATION_MS = 10 * 60_000;
export interface RunDispatchLeaseRecord {
readonly attemptId: string;
readonly runId: string;
readonly status: RunDispatchLeaseStatus;
readonly version: number;
readonly leaseGeneration: number;
readonly workerId: string;
readonly workerSessionId: string;
readonly workerGeneration: number;
/** The database never persists or returns the bearer lease token. */
readonly leaseTokenDigest: string;
readonly acquiredAtMs: number;
readonly renewedAtMs: number;
readonly expiresAtMs: number;
readonly releasedAtMs?: number;
readonly releaseReason?: RunDispatchReleaseReason;
readonly completedAtMs?: number;
readonly updatedAtMs: number;
}
export class InvalidRunDispatchLeaseValueError extends TypeError {
constructor(message: string) {
super(`Run dispatch lease value is invalid: ${message}`);
this.name = 'InvalidRunDispatchLeaseValueError';
}
}
export type RunDispatchLeaseFenceReason =
| 'missing'
| 'run_mismatch'
| 'not_leased'
| 'worker_mismatch'
| 'worker_session_mismatch'
| 'worker_generation_mismatch'
| 'lease_generation_mismatch'
| 'lease_token_mismatch'
| 'version_mismatch'
| 'lease_expired'
| 'worker_unavailable';
export class RunDispatchLeaseFenceRejectedError extends Error {
constructor(
readonly attemptId: string,
readonly reason: RunDispatchLeaseFenceReason,
) {
super(`Run dispatch lease for Attempt ${attemptId} was fenced: ${reason}`);
this.name = 'RunDispatchLeaseFenceRejectedError';
}
}
function invalid(message: string): never {
throw new InvalidRunDispatchLeaseValueError(message);
}
export function assertRunDispatchId(name: string, value: string): void {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
/[\u0000-\u001f\u007f]/.test(value)
) {
invalid(`${name} is invalid`);
}
}
export function assertRunDispatchLeaseToken(value: string): void {
if (
typeof value !== 'string' ||
value.length < 32 ||
value.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(value)
) {
invalid('leaseToken is invalid');
}
}
export function digestRunDispatchLeaseToken(value: string): string {
assertRunDispatchLeaseToken(value);
return createHash('sha256').update(value, 'utf8').digest('hex');
}
export function assertRunDispatchLeaseDuration(value: number): void {
if (
!Number.isSafeInteger(value) ||
value < MIN_RUN_DISPATCH_LEASE_DURATION_MS ||
value > MAX_RUN_DISPATCH_LEASE_DURATION_MS
) {
invalid(
`leaseDurationMs must be between ${MIN_RUN_DISPATCH_LEASE_DURATION_MS} and ${MAX_RUN_DISPATCH_LEASE_DURATION_MS}`,
);
}
}
export function assertRunDispatchLeaseFence(value: {
workerId: string;
workerSessionId: string;
workerGeneration: number;
leaseGeneration: number;
leaseToken: string;
expectedVersion: number;
}): void {
assertWorkerId(value.workerId);
assertWorkerSessionId(value.workerSessionId);
for (const [name, number, minimum] of [
['workerGeneration', value.workerGeneration, 1],
['leaseGeneration', value.leaseGeneration, 1],
['expectedVersion', value.expectedVersion, 0],
] as const) {
if (!Number.isSafeInteger(number) || number < minimum) invalid(`${name} is invalid`);
}
assertRunDispatchLeaseToken(value.leaseToken);
}
export function assertRunDispatchLeaseRecord(
value: RunDispatchLeaseRecord,
): void {
assertRunDispatchId('attemptId', value.attemptId);
assertRunDispatchId('runId', value.runId);
if (!RUN_DISPATCH_LEASE_STATUSES.includes(value.status)) invalid('status is invalid');
assertWorkerId(value.workerId);
assertWorkerSessionId(value.workerSessionId);
if (!/^[0-9a-f]{64}$/.test(value.leaseTokenDigest)) {
invalid('leaseTokenDigest is invalid');
}
for (const [name, number, minimum] of [
['version', value.version, 0],
['leaseGeneration', value.leaseGeneration, 1],
['workerGeneration', value.workerGeneration, 1],
['acquiredAtMs', value.acquiredAtMs, 0],
['renewedAtMs', value.renewedAtMs, 0],
['expiresAtMs', value.expiresAtMs, 0],
['updatedAtMs', value.updatedAtMs, 0],
] as const) {
if (!Number.isSafeInteger(number) || number < minimum) invalid(`${name} is invalid`);
}
if (
value.renewedAtMs < value.acquiredAtMs ||
value.expiresAtMs <= value.renewedAtMs ||
value.updatedAtMs < value.acquiredAtMs
) {
invalid('timestamps are inconsistent');
}
if (value.status === 'leased') {
if (
value.releasedAtMs !== undefined ||
value.releaseReason !== undefined ||
value.completedAtMs !== undefined
) invalid('active lease has terminal metadata');
return;
}
if (value.status === 'released') {
if (
value.releasedAtMs === undefined ||
value.releaseReason === undefined ||
!RUN_DISPATCH_RELEASE_REASONS.includes(value.releaseReason) ||
value.completedAtMs !== undefined
) invalid('released lease metadata is inconsistent');
return;
}
if (
value.completedAtMs === undefined ||
value.releasedAtMs !== undefined ||
value.releaseReason !== undefined
) invalid('completed lease metadata is inconsistent');
}
export interface ClaimRunDispatchLeaseCommand {
readonly runId: string;
readonly attemptId: string;
readonly workerId: string;
readonly workerSessionId: string;
readonly workerGeneration: number;
readonly leaseToken: string;
readonly leaseDurationMs: number;
readonly eventId: string;
readonly offerId: string;
}
export type ClaimRunDispatchLeaseResult =
| { readonly status: 'claimed'; readonly lease: RunDispatchLeaseRecord }
| { readonly status: 'idempotent' | 'leased'; readonly lease: RunDispatchLeaseRecord }
| { readonly status: 'not_eligible' | 'worker_unavailable' | 'capacity_exhausted' };
export interface RenewRunDispatchLeaseCommand {
readonly attemptId: string;
readonly workerId: string;
readonly workerSessionId: string;
readonly workerGeneration: number;
readonly leaseGeneration: number;
readonly leaseToken: string;
readonly expectedVersion: number;
readonly leaseDurationMs: number;
}
export interface ReleaseRunDispatchLeaseCommand {
readonly runId: string;
readonly attemptId: string;
readonly workerId: string;
readonly workerSessionId: string;
readonly workerGeneration: number;
readonly leaseGeneration: number;
readonly leaseToken: string;
readonly expectedVersion: number;
readonly reason: Exclude<RunDispatchReleaseReason, 'lease_expired'>;
readonly eventId: string;
}
export interface RunDispatchLeaseRepository {
findByAttemptId(attemptId: string): Promise<RunDispatchLeaseRecord | null>;
claim(command: ClaimRunDispatchLeaseCommand): Promise<ClaimRunDispatchLeaseResult>;
renew(command: RenewRunDispatchLeaseCommand): Promise<RunDispatchLeaseRecord>;
release(command: ReleaseRunDispatchLeaseCommand): Promise<RunDispatchLeaseRecord>;
}
@@ -0,0 +1,59 @@
import type {
RunAttemptRecord,
RunAttemptStatus,
RunEventRecord,
RunRecord,
} from './run';
import type { RunRetryPolicyRecord } from './runRetryPolicy';
export const MAX_RUN_EVENT_PAYLOAD_BYTES = 16 * 1024;
export const MAX_RUN_EVENT_PAGE_SIZE = 500;
export const MAX_CANCELLATION_RECOVERY_PAGE_SIZE = 500;
export interface RunRepositoryReader {
findRunById(runId: string): Promise<RunRecord | null>;
findAttemptById(attemptId: string): Promise<RunAttemptRecord | null>;
findLatestAttemptByRunId(runId: string): Promise<RunAttemptRecord | null>;
findRetryPolicyByRunId(runId: string): Promise<RunRetryPolicyRecord | null>;
listEvents(
runId: string,
options?: { afterSequence?: number; limit?: number },
): Promise<RunEventRecord[]>;
listCancellationRequested(options?: {
beforeMs?: number;
limit?: number;
}): Promise<RunRecord[]>;
}
export interface RunRepositoryTransaction extends RunRepositoryReader {
insertRun(run: RunRecord): Promise<void>;
insertAttempt(attempt: RunAttemptRecord): Promise<void>;
insertRetryPolicy(policy: RunRetryPolicyRecord): Promise<void>;
/**
* Replaces a Run only when its persisted version still equals
* `expectedVersion`. The supplied Run must carry `expectedVersion + 1`.
*/
compareAndSetRun(run: RunRecord, expectedVersion: number): Promise<boolean>;
/**
* Replaces an Attempt only when both state and callback sequence still match.
* The Run aggregate version remains the primary serialization boundary.
*/
compareAndSetAttempt(
attempt: RunAttemptRecord,
expected: {
status: RunAttemptStatus;
callbackSequence: number;
},
): Promise<boolean>;
compareAndSetRetryPolicy(
policy: RunRetryPolicyRecord,
expectedVersion: number,
): Promise<boolean>;
appendEvent(event: RunEventRecord): Promise<void>;
}
export interface RunRepository extends RunRepositoryReader {
transaction<T>(
work: (transaction: RunRepositoryTransaction) => Promise<T>,
): Promise<T>;
}
@@ -0,0 +1,6 @@
export * from './repositoryErrors';
export * from './run';
export * from './runRepository';
export * from './projectRunList';
export * from './runRetryPolicy';
export * from './clusterRunLostRetry';
@@ -0,0 +1,138 @@
export const RUN_RETRY_SAFETIES = [
'unknown',
'idempotent',
'deduplicated',
] as const;
export type RunRetrySafety = (typeof RUN_RETRY_SAFETIES)[number];
export const MAX_RUN_ATTEMPTS = 16;
export const MAX_RUN_RETRY_BACKOFF_MS = 24 * 60 * 60 * 1000;
export interface RunRetryPolicyDefinition {
maxAttempts: number;
retryOnLost: boolean;
safety: RunRetrySafety;
backoffBaseMs: number;
backoffMaxMs: number;
}
export interface RunRetryPolicyRecord extends RunRetryPolicyDefinition {
runId: string;
nextAttemptAtMs?: number;
version: number;
createdAtMs: number;
updatedAtMs: number;
}
export const NO_AUTOMATIC_RUN_RETRY: Readonly<RunRetryPolicyDefinition> = {
maxAttempts: 1,
retryOnLost: false,
safety: 'unknown',
backoffBaseMs: 0,
backoffMaxMs: 0,
};
export class InvalidRunRetryPolicyError extends TypeError {
readonly code = 'INVALID_RUN_RETRY_POLICY';
constructor(message: string) {
super(message);
this.name = 'InvalidRunRetryPolicyError';
}
}
function assertNonNegativeTime(name: string, value: number): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new InvalidRunRetryPolicyError(
`${name} must be a non-negative safe integer`,
);
}
}
export function assertRunRetryPolicyDefinition(
policy: RunRetryPolicyDefinition,
): void {
if (
!Number.isSafeInteger(policy.maxAttempts) ||
policy.maxAttempts < 1 ||
policy.maxAttempts > MAX_RUN_ATTEMPTS
) {
throw new InvalidRunRetryPolicyError(
`maxAttempts must be between 1 and ${MAX_RUN_ATTEMPTS}`,
);
}
if (typeof policy.retryOnLost !== 'boolean') {
throw new InvalidRunRetryPolicyError('retryOnLost must be boolean');
}
if (!RUN_RETRY_SAFETIES.includes(policy.safety)) {
throw new InvalidRunRetryPolicyError('safety is not supported');
}
assertNonNegativeTime('backoffBaseMs', policy.backoffBaseMs);
assertNonNegativeTime('backoffMaxMs', policy.backoffMaxMs);
if (
policy.backoffBaseMs > MAX_RUN_RETRY_BACKOFF_MS ||
policy.backoffMaxMs > MAX_RUN_RETRY_BACKOFF_MS
) {
throw new InvalidRunRetryPolicyError(
`retry backoff cannot exceed ${MAX_RUN_RETRY_BACKOFF_MS}ms`,
);
}
if (policy.backoffMaxMs < policy.backoffBaseMs) {
throw new InvalidRunRetryPolicyError(
'backoffMaxMs cannot be smaller than backoffBaseMs',
);
}
}
export function assertAdmittedRunRetryPolicy(
policy: RunRetryPolicyDefinition,
): void {
assertRunRetryPolicyDefinition(policy);
if (
policy.retryOnLost &&
policy.maxAttempts > 1 &&
policy.safety === 'unknown'
) {
throw new InvalidRunRetryPolicyError(
'automatic lost retry requires idempotent or deduplicated safety',
);
}
}
export function assertRunRetryPolicyRecord(policy: RunRetryPolicyRecord): void {
assertRunRetryPolicyDefinition(policy);
if (!policy.runId) {
throw new InvalidRunRetryPolicyError('runId is required');
}
if (!Number.isSafeInteger(policy.version) || policy.version < 0) {
throw new InvalidRunRetryPolicyError(
'version must be a non-negative safe integer',
);
}
assertNonNegativeTime('createdAtMs', policy.createdAtMs);
assertNonNegativeTime('updatedAtMs', policy.updatedAtMs);
if (policy.updatedAtMs < policy.createdAtMs) {
throw new InvalidRunRetryPolicyError(
'updatedAtMs cannot be earlier than createdAtMs',
);
}
if (policy.nextAttemptAtMs !== undefined) {
assertNonNegativeTime('nextAttemptAtMs', policy.nextAttemptAtMs);
}
}
export function runRetryDelayMs(
policy: RunRetryPolicyDefinition,
lostAttempt: number,
): number {
assertRunRetryPolicyDefinition(policy);
if (!Number.isSafeInteger(lostAttempt) || lostAttempt < 1) {
throw new InvalidRunRetryPolicyError(
'lostAttempt must be a positive safe integer',
);
}
if (policy.backoffBaseMs === 0) return 0;
const exponent = Math.min(lostAttempt - 1, MAX_RUN_ATTEMPTS - 1);
return Math.min(policy.backoffMaxMs, policy.backoffBaseMs * 2 ** exponent);
}
File diff suppressed because it is too large Load Diff