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,265 @@
import type {
SecurityPolicyFence,
SecuritySubject,
} from '../../../security/security';
import type { SecurityAuditRecord } from '../../../security/audit/securityAudit';
import type { RunCancellationReason, RunStatus } from '../../../run/run';
import type { StepRunKind, StepRunStatus } from '../../../run/stepRun';
import type {
PluginPackageWorkflowAdmissionReceipt,
PluginPackageWorkflowExecutionPlan,
} from '../pluginPackageWorkflowExecutionPlan';
export interface AuthorizedPluginPackageWorkflowAdmission {
readonly plan: PluginPackageWorkflowExecutionPlan;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface PluginPackageWorkflowAdministrationRepository {
findPlanByPlanId(
planId: string,
): Promise<Readonly<PluginPackageWorkflowExecutionPlan> | null>;
admitAuthorized(admission: AuthorizedPluginPackageWorkflowAdmission): Promise<
Readonly<{
status: 'created' | 'existing';
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
}>
>;
}
export const PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_STATUSES = [
'accepted',
'existing',
'already_requested',
'already_terminal',
] as const;
export type PluginPackageWorkflowCancellationStatus =
(typeof PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_STATUSES)[number];
export interface AuthorizedPluginPackageWorkflowCancellation {
readonly projectId: string;
readonly packageName: string;
readonly runId: string;
readonly mutationId: string;
readonly runEventId: string;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface PluginPackageWorkflowCancellationResult {
readonly status: PluginPackageWorkflowCancellationStatus;
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly runStatus: RunStatus;
readonly runVersion: number;
readonly eventSequence: number;
readonly cancelRequestedAtMs?: number;
readonly cancelReason?: RunCancellationReason;
}
export interface PluginPackageWorkflowCancellationRepository {
requestUserCancellation(
cancellation: AuthorizedPluginPackageWorkflowCancellation,
): Promise<Readonly<PluginPackageWorkflowCancellationResult>>;
}
export const PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA =
'qinglong/plugin-package-workflow-run-inspection@v1' as const;
export interface AuthorizedPluginPackageWorkflowRunInspection {
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface PluginPackageWorkflowRunInspectionResult {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA;
readonly found: boolean;
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly run: Readonly<{
status: RunStatus;
version: number;
eventSequence: number;
createdAtMs: number;
queuedAtMs: number | null;
startedAtMs: number | null;
finishedAtMs: number | null;
cancelRequestedAtMs: number | null;
cancelReason: RunCancellationReason | null;
}> | null;
readonly stepCount: number | null;
readonly stepStatusCounts: Readonly<Record<StepRunStatus, number>> | null;
}
export interface PluginPackageWorkflowRunInspectionRepository {
inspectRunAuthorized(
inspection: AuthorizedPluginPackageWorkflowRunInspection,
): Promise<Readonly<PluginPackageWorkflowRunInspectionResult>>;
}
export const PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA =
'qinglong/plugin-package-workflow-run-list@v1' as const;
export const DEFAULT_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE = 32;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE = 64;
export interface PluginPackageWorkflowRunListCursor {
readonly admittedAtMs: number;
readonly runId: string;
}
export interface AuthorizedPluginPackageWorkflowRunList {
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly limit: number;
readonly after: Readonly<PluginPackageWorkflowRunListCursor> | null;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface PluginPackageWorkflowRunListItem {
readonly runId: string;
readonly status: RunStatus;
readonly version: number;
readonly eventSequence: number;
readonly stepCount: number;
readonly admittedAtMs: number;
readonly queuedAtMs: number | null;
readonly startedAtMs: number | null;
readonly finishedAtMs: number | null;
readonly cancelRequestedAtMs: number | null;
readonly cancelReason: RunCancellationReason | null;
}
export interface PluginPackageWorkflowRunListResult {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA;
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly after: Readonly<PluginPackageWorkflowRunListCursor> | null;
readonly runs: readonly Readonly<PluginPackageWorkflowRunListItem>[];
readonly truncated: boolean;
readonly next: Readonly<PluginPackageWorkflowRunListCursor> | null;
}
export interface PluginPackageWorkflowRunListRepository {
listRunsAuthorized(
query: AuthorizedPluginPackageWorkflowRunList,
): Promise<Readonly<PluginPackageWorkflowRunListResult>>;
}
export const PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA =
'qinglong/plugin-package-workflow-step-run-list@v1' as const;
export const DEFAULT_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE = 32;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE = 64;
export interface PluginPackageWorkflowStepRunCursor {
readonly stepKey: string;
readonly id: string;
}
export interface AuthorizedPluginPackageWorkflowStepRunList {
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly limit: number;
readonly after: Readonly<PluginPackageWorkflowStepRunCursor> | null;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface PluginPackageWorkflowStepRunListItem {
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 PluginPackageWorkflowStepRunListResult {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA;
readonly found: boolean;
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly stepRuns: readonly Readonly<PluginPackageWorkflowStepRunListItem>[];
readonly truncated: boolean;
readonly next: Readonly<PluginPackageWorkflowStepRunCursor> | null;
}
export interface PluginPackageWorkflowStepRunListRepository {
listStepRunsAuthorized(
query: AuthorizedPluginPackageWorkflowStepRunList,
): Promise<Readonly<PluginPackageWorkflowStepRunListResult>>;
}
export const PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA =
'qinglong/plugin-package-workflow-run-event-list@v1' as const;
export const DEFAULT_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE = 32;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE = 64;
export interface AuthorizedPluginPackageWorkflowRunEventList {
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly limit: number;
readonly afterSequence: number;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface PluginPackageWorkflowRunEventListItem {
readonly id: string;
readonly sequence: number;
readonly type: string;
readonly stepRunId: string | null;
readonly createdAtMs: number;
}
export interface PluginPackageWorkflowRunEventListResult {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA;
readonly found: boolean;
readonly projectId: string;
readonly packageName: string;
readonly workflowId: string;
readonly runId: string;
readonly afterSequence: number;
readonly headSequence: number | null;
readonly events: readonly Readonly<PluginPackageWorkflowRunEventListItem>[];
readonly truncated: boolean;
readonly nextAfterSequence: number | null;
}
export interface PluginPackageWorkflowRunEventListRepository {
listRunEventsAuthorized(
query: AuthorizedPluginPackageWorkflowRunEventList,
): Promise<Readonly<PluginPackageWorkflowRunEventListResult>>;
}
@@ -0,0 +1,41 @@
export class InvalidPluginPackageWorkflowAdministrationMutationError extends TypeError {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_MUTATION_INVALID';
constructor(message: string) {
super(
`Plugin Package Workflow administration mutation is invalid: ${message}`,
);
this.name = 'InvalidPluginPackageWorkflowAdministrationMutationError';
}
}
export class PluginPackageWorkflowAdministrationAuthorizationFenceConflictError extends Error {
readonly code =
'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT';
constructor() {
super('Plugin Package Workflow administration authorization fence changed');
this.name =
'PluginPackageWorkflowAdministrationAuthorizationFenceConflictError';
}
}
export class PluginPackageWorkflowAdministrationMutationConflictError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_ADMINISTRATION_MUTATION_CONFLICT';
constructor() {
super(
'Plugin Package Workflow administration conflicts with durable state',
);
this.name = 'PluginPackageWorkflowAdministrationMutationConflictError';
}
}
export class PluginPackageWorkflowCancellationNotFoundError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_NOT_FOUND';
constructor() {
super('Plugin Package Workflow cancellation target does not exist');
this.name = 'PluginPackageWorkflowCancellationNotFoundError';
}
}
@@ -0,0 +1,206 @@
import { normalizeProjectPolicySubject } from '../../../security/project-policy/projectPolicy';
import { normalizeSecurityAuditRecord } from '../../../security/audit/securityAudit';
import { RUN_STATUSES } from '../../../run/run';
import { normalizePluginPackageWorkflowExecutionPlan } from '../pluginPackageWorkflowExecutionPlan';
import {
PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_STATUSES,
type AuthorizedPluginPackageWorkflowAdmission,
type AuthorizedPluginPackageWorkflowCancellation,
type PluginPackageWorkflowCancellationResult,
} from './contracts';
import { InvalidPluginPackageWorkflowAdministrationMutationError } from './errors';
import {
exactKeys,
identifier,
normalizeFence,
packageName,
sameSubject,
} from './support';
export function normalizeAuthorizedPluginPackageWorkflowAdmission(
value: AuthorizedPluginPackageWorkflowAdmission,
): Readonly<AuthorizedPluginPackageWorkflowAdmission> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['actor', 'audit', 'fence', 'plan'])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'admission shape is invalid',
);
}
try {
const plan = normalizePluginPackageWorkflowExecutionPlan(value.plan);
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'workflow.start' ||
audit.projectId !== plan.target.projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
audit.occurredAtMs !== plan.plannedAtMs ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'audit binding is invalid',
);
}
return Object.freeze({ plan, actor, fence, audit });
} catch (error) {
if (
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
) {
throw error;
}
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'admission value is invalid',
);
}
}
export function normalizeAuthorizedPluginPackageWorkflowCancellation(
value: AuthorizedPluginPackageWorkflowCancellation,
): Readonly<AuthorizedPluginPackageWorkflowCancellation> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'actor',
'audit',
'fence',
'mutationId',
'packageName',
'projectId',
'runEventId',
'runId',
])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'cancellation shape is invalid',
);
}
try {
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const runId = identifier(value.runId, 'runId');
const mutationId = identifier(value.mutationId, 'mutationId');
const runEventId = identifier(value.runEventId, 'runEventId');
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'workflow.cancel' ||
audit.projectId !== projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'cancellation audit binding is invalid',
);
}
return Object.freeze({
projectId,
packageName: normalizedPackageName,
runId,
mutationId,
runEventId,
actor,
fence,
audit,
});
} catch (error) {
if (
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
) {
throw error;
}
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'cancellation value is invalid',
);
}
}
export function normalizePluginPackageWorkflowCancellationResult(
value: PluginPackageWorkflowCancellationResult,
): Readonly<PluginPackageWorkflowCancellationResult> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'cancellation result is invalid',
);
}
const hasCancellation = value.cancelRequestedAtMs !== undefined;
if (
!exactKeys(
value,
hasCancellation
? [
'cancelReason',
'cancelRequestedAtMs',
'eventSequence',
'packageName',
'projectId',
'runId',
'runStatus',
'runVersion',
'status',
'workflowId',
]
: [
'eventSequence',
'packageName',
'projectId',
'runId',
'runStatus',
'runVersion',
'status',
'workflowId',
],
) ||
!PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_STATUSES.includes(value.status) ||
!RUN_STATUSES.includes(value.runStatus) ||
!Number.isSafeInteger(value.runVersion) ||
value.runVersion < 1 ||
!Number.isSafeInteger(value.eventSequence) ||
value.eventSequence < 0 ||
(hasCancellation &&
(!Number.isSafeInteger(value.cancelRequestedAtMs) ||
(value.cancelRequestedAtMs as number) < 0 ||
!['user', 'policy', 'shutdown', 'reconcile', 'timeout'].includes(
value.cancelReason ?? '',
))) ||
(!hasCancellation && value.cancelReason !== undefined)
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'cancellation result state is invalid',
);
}
return Object.freeze({
status: value.status,
projectId: identifier(value.projectId, 'projectId'),
packageName: packageName(value.packageName),
workflowId: identifier(value.workflowId, 'workflowId'),
runId: identifier(value.runId, 'runId'),
runStatus: value.runStatus,
runVersion: value.runVersion,
eventSequence: value.eventSequence,
...(hasCancellation
? {
cancelRequestedAtMs: value.cancelRequestedAtMs!,
cancelReason: value.cancelReason!,
}
: {}),
});
}
@@ -0,0 +1,254 @@
import { normalizeProjectPolicySubject } from '../../../security/project-policy/projectPolicy';
import { normalizeSecurityAuditRecord } from '../../../security/audit/securityAudit';
import {
MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE,
PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
type AuthorizedPluginPackageWorkflowRunEventList,
type PluginPackageWorkflowRunEventListItem,
type PluginPackageWorkflowRunEventListResult,
} from './contracts';
import { InvalidPluginPackageWorkflowAdministrationMutationError } from './errors';
import {
exactKeys,
identifier,
normalizeFence,
packageName,
resourceId,
sameSubject,
} from './support';
export function normalizeAuthorizedPluginPackageWorkflowRunEventList(
value: AuthorizedPluginPackageWorkflowRunEventList,
): Readonly<AuthorizedPluginPackageWorkflowRunEventList> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'actor',
'afterSequence',
'audit',
'fence',
'limit',
'packageName',
'projectId',
'runId',
'workflowId',
])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list shape is invalid',
);
}
try {
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const runId = identifier(value.runId, 'runId');
if (
!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE ||
!Number.isSafeInteger(value.afterSequence) ||
value.afterSequence < 0
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list page is invalid',
);
}
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'workflow.event.list' ||
audit.projectId !== projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list audit binding is invalid',
);
}
return Object.freeze({
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
limit: value.limit,
afterSequence: value.afterSequence,
actor,
fence,
audit,
});
} catch (error) {
if (
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
) {
throw error;
}
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list value is invalid',
);
}
}
const RUN_EVENT_TYPE = /^[a-z][a-z0-9_.-]{0,127}$/;
function normalizeWorkflowRunEventListItem(
value: PluginPackageWorkflowRunEventListItem,
): Readonly<PluginPackageWorkflowRunEventListItem> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['createdAtMs', 'id', 'sequence', 'stepRunId', 'type']) ||
!Number.isSafeInteger(value.sequence) ||
value.sequence < 1 ||
typeof value.type !== 'string' ||
!RUN_EVENT_TYPE.test(value.type) ||
!Number.isSafeInteger(value.createdAtMs) ||
value.createdAtMs < 0
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list item is invalid',
);
}
return Object.freeze({
id: identifier(value.id, 'RunEvent id'),
sequence: value.sequence,
type: value.type,
stepRunId:
value.stepRunId === null
? null
: identifier(value.stepRunId, 'RunEvent StepRun id'),
createdAtMs: value.createdAtMs,
});
}
export function normalizePluginPackageWorkflowRunEventListResult(
value: PluginPackageWorkflowRunEventListResult,
): Readonly<PluginPackageWorkflowRunEventListResult> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'afterSequence',
'events',
'found',
'headSequence',
'nextAfterSequence',
'packageName',
'projectId',
'runId',
'schema',
'truncated',
'workflowId',
]) ||
value.schema !== PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA ||
typeof value.found !== 'boolean' ||
typeof value.truncated !== 'boolean' ||
!Number.isSafeInteger(value.afterSequence) ||
value.afterSequence < 0 ||
!Array.isArray(value.events) ||
value.events.length > MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_PAGE_SIZE
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list result shape is invalid',
);
}
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const runId = identifier(value.runId, 'runId');
if (!value.found) {
if (
value.headSequence !== null ||
value.events.length !== 0 ||
value.truncated ||
value.nextAfterSequence !== null
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'missing RunEvent list result is invalid',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
found: false,
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
afterSequence: value.afterSequence,
headSequence: null,
events: Object.freeze([]),
truncated: false,
nextAfterSequence: null,
});
}
if (
!Number.isSafeInteger(value.headSequence) ||
(value.headSequence as number) < 0 ||
value.afterSequence > (value.headSequence as number)
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list head is invalid',
);
}
const headSequence = value.headSequence as number;
const events = value.events.map(normalizeWorkflowRunEventListItem);
const ids = new Set<string>();
for (let index = 0; index < events.length; index += 1) {
const event = events[index]!;
if (
ids.has(event.id) ||
event.sequence !== value.afterSequence + index + 1 ||
event.sequence > headSequence
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list sequence is invalid',
);
}
ids.add(event.id);
}
const lastSequence = events.at(-1)?.sequence ?? value.afterSequence;
let nextAfterSequence: number | null = null;
if (value.truncated) {
if (
events.length === 0 ||
lastSequence >= headSequence ||
value.nextAfterSequence !== lastSequence
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list continuation is invalid',
);
}
nextAfterSequence = lastSequence;
} else if (
value.nextAfterSequence !== null ||
lastSequence !== headSequence
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'RunEvent list terminal page is invalid',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_EVENT_LIST_SCHEMA,
found: true,
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
afterSequence: value.afterSequence,
headSequence,
events: Object.freeze(events),
truncated: value.truncated,
nextAfterSequence,
});
}
@@ -0,0 +1,225 @@
import { normalizeProjectPolicySubject } from '../../../security/project-policy/projectPolicy';
import { normalizeSecurityAuditRecord } from '../../../security/audit/securityAudit';
import { RUN_CANCELLATION_REASONS, RUN_STATUSES } from '../../../run/run';
import { STEP_RUN_STATUSES, type StepRunStatus } from '../../../run/stepRun';
import {
PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
type AuthorizedPluginPackageWorkflowRunInspection,
type PluginPackageWorkflowRunInspectionResult,
} from './contracts';
import { InvalidPluginPackageWorkflowAdministrationMutationError } from './errors';
import {
exactKeys,
identifier,
normalizeFence,
nullableTimestamp,
packageName,
resourceId,
sameSubject,
} from './support';
export function normalizeAuthorizedPluginPackageWorkflowRunInspection(
value: AuthorizedPluginPackageWorkflowRunInspection,
): Readonly<AuthorizedPluginPackageWorkflowRunInspection> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'actor',
'audit',
'fence',
'packageName',
'projectId',
'runId',
'workflowId',
])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection shape is invalid',
);
}
try {
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const runId = identifier(value.runId, 'runId');
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'workflow.run.read' ||
audit.projectId !== projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection audit binding is invalid',
);
}
return Object.freeze({
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
actor,
fence,
audit,
});
} catch (error) {
if (
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
) {
throw error;
}
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection value is invalid',
);
}
}
export function normalizePluginPackageWorkflowRunInspectionResult(
value: PluginPackageWorkflowRunInspectionResult,
): Readonly<PluginPackageWorkflowRunInspectionResult> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'found',
'packageName',
'projectId',
'run',
'runId',
'schema',
'stepCount',
'stepStatusCounts',
'workflowId',
]) ||
value.schema !== PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA ||
typeof value.found !== 'boolean'
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection result shape is invalid',
);
}
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const runId = identifier(value.runId, 'runId');
if (!value.found) {
if (
value.run !== null ||
value.stepCount !== null ||
value.stepStatusCounts !== null
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'missing run inspection result is invalid',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
found: false,
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
run: null,
stepCount: null,
stepStatusCounts: null,
});
}
if (
!value.run ||
typeof value.run !== 'object' ||
Array.isArray(value.run) ||
!exactKeys(value.run, [
'cancelReason',
'cancelRequestedAtMs',
'createdAtMs',
'eventSequence',
'finishedAtMs',
'queuedAtMs',
'startedAtMs',
'status',
'version',
]) ||
!RUN_STATUSES.includes(value.run.status) ||
!Number.isSafeInteger(value.run.version) ||
value.run.version < 1 ||
!Number.isSafeInteger(value.run.eventSequence) ||
value.run.eventSequence < 0 ||
!Number.isSafeInteger(value.run.createdAtMs) ||
value.run.createdAtMs < 0 ||
!Number.isSafeInteger(value.stepCount) ||
(value.stepCount as number) < 1 ||
(value.stepCount as number) > 128 ||
!value.stepStatusCounts ||
typeof value.stepStatusCounts !== 'object' ||
Array.isArray(value.stepStatusCounts) ||
!exactKeys(value.stepStatusCounts, STEP_RUN_STATUSES)
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'found run inspection result is invalid',
);
}
const queuedAtMs = nullableTimestamp(value.run.queuedAtMs);
const startedAtMs = nullableTimestamp(value.run.startedAtMs);
const finishedAtMs = nullableTimestamp(value.run.finishedAtMs);
const cancelRequestedAtMs = nullableTimestamp(value.run.cancelRequestedAtMs);
const cancelReason = value.run.cancelReason;
if (
(cancelRequestedAtMs === null) !== (cancelReason === null) ||
(cancelReason !== null && !RUN_CANCELLATION_REASONS.includes(cancelReason))
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection cancellation state is invalid',
);
}
const counts = Object.fromEntries(
STEP_RUN_STATUSES.map((status) => {
const count = value.stepStatusCounts![status];
if (!Number.isSafeInteger(count) || count < 0 || count > 128) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection StepRun counts are invalid',
);
}
return [status, count];
}),
) as Record<StepRunStatus, number>;
if (
Object.values(counts).reduce((total, count) => total + count, 0) !==
value.stepCount
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection StepRun count is inconsistent',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_INSPECTION_SCHEMA,
found: true,
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
run: Object.freeze({
status: value.run.status,
version: value.run.version,
eventSequence: value.run.eventSequence,
createdAtMs: value.run.createdAtMs,
queuedAtMs,
startedAtMs,
finishedAtMs,
cancelRequestedAtMs,
cancelReason,
}),
stepCount: value.stepCount,
stepStatusCounts: Object.freeze(counts),
});
}
@@ -0,0 +1,282 @@
import { normalizeProjectPolicySubject } from '../../../security/project-policy/projectPolicy';
import { normalizeSecurityAuditRecord } from '../../../security/audit/securityAudit';
import { RUN_CANCELLATION_REASONS, RUN_STATUSES } from '../../../run/run';
import {
MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE,
PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
type AuthorizedPluginPackageWorkflowRunList,
type PluginPackageWorkflowRunListCursor,
type PluginPackageWorkflowRunListItem,
type PluginPackageWorkflowRunListResult,
} from './contracts';
import { InvalidPluginPackageWorkflowAdministrationMutationError } from './errors';
import {
exactKeys,
identifier,
normalizeFence,
nullableTimestamp,
packageName,
resourceId,
sameSubject,
} from './support';
function normalizeWorkflowRunListCursor(
value: PluginPackageWorkflowRunListCursor,
label: string,
): Readonly<PluginPackageWorkflowRunListCursor> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['admittedAtMs', 'runId']) ||
!Number.isSafeInteger(value.admittedAtMs) ||
value.admittedAtMs < 0
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
`${label} is invalid`,
);
}
return Object.freeze({
admittedAtMs: value.admittedAtMs,
runId: identifier(value.runId, `${label}.runId`),
});
}
function workflowRunListPositionBefore(
left: Readonly<PluginPackageWorkflowRunListCursor>,
right: Readonly<PluginPackageWorkflowRunListCursor>,
): boolean {
return (
left.admittedAtMs < right.admittedAtMs ||
(left.admittedAtMs === right.admittedAtMs && left.runId < right.runId)
);
}
export function normalizeAuthorizedPluginPackageWorkflowRunList(
value: AuthorizedPluginPackageWorkflowRunList,
): Readonly<AuthorizedPluginPackageWorkflowRunList> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'actor',
'after',
'audit',
'fence',
'limit',
'packageName',
'projectId',
'workflowId',
])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list shape is invalid',
);
}
try {
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
if (
!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list limit is invalid',
);
}
const after =
value.after === null
? null
: normalizeWorkflowRunListCursor(value.after, 'run list cursor');
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'workflow.run.list' ||
audit.projectId !== projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list audit binding is invalid',
);
}
return Object.freeze({
projectId,
packageName: normalizedPackageName,
workflowId,
limit: value.limit,
after,
actor,
fence,
audit,
});
} catch (error) {
if (
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
) {
throw error;
}
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list value is invalid',
);
}
}
function normalizeWorkflowRunListItem(
value: PluginPackageWorkflowRunListItem,
): Readonly<PluginPackageWorkflowRunListItem> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'admittedAtMs',
'cancelReason',
'cancelRequestedAtMs',
'eventSequence',
'finishedAtMs',
'queuedAtMs',
'runId',
'startedAtMs',
'status',
'stepCount',
'version',
]) ||
!RUN_STATUSES.includes(value.status) ||
!Number.isSafeInteger(value.version) ||
value.version < 1 ||
!Number.isSafeInteger(value.eventSequence) ||
value.eventSequence < 0 ||
!Number.isSafeInteger(value.stepCount) ||
value.stepCount < 1 ||
value.stepCount > 128 ||
!Number.isSafeInteger(value.admittedAtMs) ||
value.admittedAtMs < 0
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list item is invalid',
);
}
const queuedAtMs = nullableTimestamp(value.queuedAtMs);
const startedAtMs = nullableTimestamp(value.startedAtMs);
const finishedAtMs = nullableTimestamp(value.finishedAtMs);
const cancelRequestedAtMs = nullableTimestamp(value.cancelRequestedAtMs);
if (
(cancelRequestedAtMs === null) !== (value.cancelReason === null) ||
(value.cancelReason !== null &&
!RUN_CANCELLATION_REASONS.includes(value.cancelReason))
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list cancellation state is invalid',
);
}
return Object.freeze({
runId: identifier(value.runId, 'run list runId'),
status: value.status,
version: value.version,
eventSequence: value.eventSequence,
stepCount: value.stepCount,
admittedAtMs: value.admittedAtMs,
queuedAtMs,
startedAtMs,
finishedAtMs,
cancelRequestedAtMs,
cancelReason: value.cancelReason,
});
}
export function normalizePluginPackageWorkflowRunListResult(
value: PluginPackageWorkflowRunListResult,
): Readonly<PluginPackageWorkflowRunListResult> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'after',
'next',
'packageName',
'projectId',
'runs',
'schema',
'truncated',
'workflowId',
]) ||
value.schema !== PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA ||
!Array.isArray(value.runs) ||
value.runs.length > MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_PAGE_SIZE ||
typeof value.truncated !== 'boolean'
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list result shape is invalid',
);
}
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const after =
value.after === null
? null
: normalizeWorkflowRunListCursor(value.after, 'run list result cursor');
const runs = value.runs.map(normalizeWorkflowRunListItem);
const ids = new Set<string>();
for (let index = 0; index < runs.length; index += 1) {
const run = runs[index]!;
const position = { admittedAtMs: run.admittedAtMs, runId: run.runId };
if (ids.has(run.runId)) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list identities are not unique',
);
}
ids.add(run.runId);
const previous = index === 0 ? after : runs[index - 1]!;
if (
previous !== null &&
!workflowRunListPositionBefore(position, {
admittedAtMs: previous.admittedAtMs,
runId: previous.runId,
})
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list order is invalid',
);
}
}
const next =
value.next === null
? null
: normalizeWorkflowRunListCursor(value.next, 'run list next cursor');
const last = runs.at(-1);
if (
(value.truncated &&
(!last ||
!next ||
next.admittedAtMs !== last.admittedAtMs ||
next.runId !== last.runId)) ||
(!value.truncated && next !== null)
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run list continuation is invalid',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_RUN_LIST_SCHEMA,
projectId,
packageName: normalizedPackageName,
workflowId,
after,
runs: Object.freeze(runs),
truncated: value.truncated,
next,
});
}
@@ -0,0 +1,339 @@
import { normalizeProjectPolicySubject } from '../../../security/project-policy/projectPolicy';
import { normalizeSecurityAuditRecord } from '../../../security/audit/securityAudit';
import { STEP_RUN_KINDS, STEP_RUN_STATUSES } from '../../../run/stepRun';
import {
MAX_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE,
PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
type AuthorizedPluginPackageWorkflowStepRunList,
type PluginPackageWorkflowStepRunCursor,
type PluginPackageWorkflowStepRunListItem,
type PluginPackageWorkflowStepRunListResult,
} from './contracts';
import { InvalidPluginPackageWorkflowAdministrationMutationError } from './errors';
import {
exactKeys,
identifier,
normalizeFence,
nullableTimestamp,
packageName,
resourceId,
sameSubject,
} from './support';
export function normalizeAuthorizedPluginPackageWorkflowStepRunList(
value: AuthorizedPluginPackageWorkflowStepRunList,
): Readonly<AuthorizedPluginPackageWorkflowStepRunList> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'actor',
'after',
'audit',
'fence',
'limit',
'packageName',
'projectId',
'runId',
'workflowId',
])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list shape is invalid',
);
}
try {
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const runId = identifier(value.runId, 'runId');
if (
!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list limit is invalid',
);
}
let after: Readonly<PluginPackageWorkflowStepRunCursor> | null = null;
if (value.after !== null) {
if (
!value.after ||
typeof value.after !== 'object' ||
Array.isArray(value.after) ||
!exactKeys(value.after, ['id', 'stepKey'])
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list cursor is invalid',
);
}
after = Object.freeze({
stepKey: resourceId(value.after.stepKey, 'after.stepKey'),
id: identifier(value.after.id, 'after.id'),
});
}
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'workflow.step.list' ||
audit.projectId !== projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list audit binding is invalid',
);
}
return Object.freeze({
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
limit: value.limit,
after,
actor,
fence,
audit,
});
} catch (error) {
if (
error instanceof InvalidPluginPackageWorkflowAdministrationMutationError
) {
throw error;
}
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list value is invalid',
);
}
}
const STEP_RUN_RESULT_CODE = /^[a-z][a-z0-9_]{0,63}$/;
function normalizeWorkflowStepRunListItem(
value: PluginPackageWorkflowStepRunListItem,
): Readonly<PluginPackageWorkflowStepRunListItem> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'attemptCount',
'createdAtMs',
'finishedAtMs',
'id',
'kind',
'parentStepRunId',
'readyAtMs',
'required',
'resultCode',
'startedAtMs',
'status',
'stepKey',
'updatedAtMs',
'version',
]) ||
!STEP_RUN_KINDS.includes(value.kind) ||
!STEP_RUN_STATUSES.includes(value.status) ||
typeof value.required !== 'boolean' ||
!Number.isSafeInteger(value.version) ||
value.version < 1 ||
value.version > 2_147_483_647 ||
!Number.isSafeInteger(value.attemptCount) ||
value.attemptCount < 0 ||
value.attemptCount > 64 ||
!Number.isSafeInteger(value.createdAtMs) ||
value.createdAtMs < 0 ||
!Number.isSafeInteger(value.updatedAtMs) ||
value.updatedAtMs < value.createdAtMs
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list item is invalid',
);
}
const id = identifier(value.id, 'StepRun id');
const parentStepRunId =
value.parentStepRunId === null
? null
: identifier(value.parentStepRunId, 'parent StepRun id');
if (parentStepRunId === id) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list parent is invalid',
);
}
const stepKey = resourceId(value.stepKey, 'StepRun stepKey');
const readyAtMs = nullableTimestamp(value.readyAtMs);
const startedAtMs = nullableTimestamp(value.startedAtMs);
const finishedAtMs = nullableTimestamp(value.finishedAtMs);
const resultCode = value.resultCode;
if (
(resultCode !== null &&
(typeof resultCode !== 'string' ||
!STEP_RUN_RESULT_CODE.test(resultCode))) ||
(readyAtMs !== null &&
(readyAtMs < value.createdAtMs || readyAtMs > value.updatedAtMs)) ||
(startedAtMs !== null &&
(readyAtMs === null ||
startedAtMs < readyAtMs ||
startedAtMs > value.updatedAtMs)) ||
(finishedAtMs !== null &&
(finishedAtMs < value.createdAtMs ||
finishedAtMs > value.updatedAtMs ||
(readyAtMs !== null && finishedAtMs < readyAtMs) ||
(startedAtMs !== null && finishedAtMs < startedAtMs))) ||
(value.status === 'pending' &&
(readyAtMs !== null || startedAtMs !== null || finishedAtMs !== null)) ||
((value.status === 'ready' || value.status === 'waiting_approval') &&
(readyAtMs === null || startedAtMs !== null || finishedAtMs !== null)) ||
((value.status === 'running' || value.status === 'lost') &&
(readyAtMs === null || startedAtMs === null || finishedAtMs !== null)) ||
(['succeeded', 'failed', 'skipped', 'cancelled', 'timed_out'].includes(
value.status,
) &&
finishedAtMs === null) ||
(value.status === 'succeeded' && resultCode !== null) ||
(['failed', 'skipped', 'cancelled', 'timed_out', 'lost'].includes(
value.status,
) &&
resultCode === null) ||
(['pending', 'ready', 'waiting_approval', 'running'].includes(
value.status,
) &&
resultCode !== null)
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list item state is invalid',
);
}
return Object.freeze({
id,
parentStepRunId,
stepKey,
kind: value.kind,
required: value.required,
status: value.status,
version: value.version,
attemptCount: value.attemptCount,
readyAtMs,
startedAtMs,
finishedAtMs,
resultCode,
createdAtMs: value.createdAtMs,
updatedAtMs: value.updatedAtMs,
});
}
export function normalizePluginPackageWorkflowStepRunListResult(
value: PluginPackageWorkflowStepRunListResult,
): Readonly<PluginPackageWorkflowStepRunListResult> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'found',
'next',
'packageName',
'projectId',
'runId',
'schema',
'stepRuns',
'truncated',
'workflowId',
]) ||
value.schema !== PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA ||
typeof value.found !== 'boolean' ||
typeof value.truncated !== 'boolean' ||
!Array.isArray(value.stepRuns) ||
value.stepRuns.length > MAX_PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_PAGE_SIZE
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list result shape is invalid',
);
}
const projectId = identifier(value.projectId, 'projectId');
const normalizedPackageName = packageName(value.packageName);
const workflowId = resourceId(value.workflowId, 'workflowId');
const runId = identifier(value.runId, 'runId');
if (!value.found) {
if (value.stepRuns.length !== 0 || value.truncated || value.next !== null) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'missing StepRun list result is invalid',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
found: false,
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
stepRuns: Object.freeze([]),
truncated: false,
next: null,
});
}
const stepRuns = value.stepRuns.map(normalizeWorkflowStepRunListItem);
const identities = new Set<string>();
for (let index = 0; index < stepRuns.length; index += 1) {
const item = stepRuns[index]!;
if (identities.has(item.id) || identities.has(`step:${item.stepKey}`)) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list identities are duplicated',
);
}
identities.add(item.id);
identities.add(`step:${item.stepKey}`);
const previous = stepRuns[index - 1];
if (
previous &&
(previous.stepKey > item.stepKey ||
(previous.stepKey === item.stepKey && previous.id >= item.id))
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list order is invalid',
);
}
}
let next: Readonly<PluginPackageWorkflowStepRunCursor> | null = null;
if (value.truncated) {
const last = stepRuns.at(-1);
if (
!last ||
!value.next ||
typeof value.next !== 'object' ||
Array.isArray(value.next) ||
!exactKeys(value.next, ['id', 'stepKey']) ||
value.next.id !== last.id ||
value.next.stepKey !== last.stepKey
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list continuation is invalid',
);
}
next = Object.freeze({ id: last.id, stepKey: last.stepKey });
} else if (value.next !== null) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'StepRun list continuation is unexpected',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_STEP_RUN_LIST_SCHEMA,
found: true,
projectId,
packageName: normalizedPackageName,
workflowId,
runId,
stepRuns: Object.freeze(stepRuns),
truncated: value.truncated,
next,
});
}
@@ -0,0 +1,86 @@
import type {
SecurityPolicyFence,
SecuritySubject,
} from '../../../security/security';
import { InvalidPluginPackageWorkflowAdministrationMutationError } from './errors';
export function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
export function sameSubject(
left: Readonly<SecuritySubject>,
right: Readonly<SecuritySubject>,
): boolean {
return left.type === right.type && left.id === right.id;
}
export function normalizeFence(
value: SecurityPolicyFence,
): SecurityPolicyFence {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['bindingVersion', 'projectVersion']) ||
!Number.isSafeInteger(value.projectVersion) ||
value.projectVersion < 1 ||
!Number.isSafeInteger(value.bindingVersion) ||
(value.bindingVersion as number) < 1
) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'authorization fence is invalid',
);
}
return Object.freeze({
projectVersion: value.projectVersion,
bindingVersion: value.bindingVersion,
});
}
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const RESOURCE_ID = /^[a-z][a-z0-9-]{0,62}$/;
export function identifier(value: unknown, label: string): string {
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
`${label} is invalid`,
);
}
return value;
}
export function packageName(value: unknown): string {
if (typeof value !== 'string' || !PACKAGE_NAME.test(value)) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'packageName is invalid',
);
}
return value;
}
export function resourceId(value: unknown, label: string): string {
if (typeof value !== 'string' || !RESOURCE_ID.test(value)) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
`${label} is invalid`,
);
}
return value;
}
export function nullableTimestamp(value: unknown): number | null {
if (value === null) return null;
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new InvalidPluginPackageWorkflowAdministrationMutationError(
'run inspection timestamp is invalid',
);
}
return value as number;
}
@@ -0,0 +1,7 @@
export * from './plugin-package-workflow-administration/contracts';
export * from './plugin-package-workflow-administration/errors';
export * from './plugin-package-workflow-administration/runInspection';
export * from './plugin-package-workflow-administration/runList';
export * from './plugin-package-workflow-administration/stepRunList';
export * from './plugin-package-workflow-administration/runEventList';
export * from './plugin-package-workflow-administration/mutation';
@@ -0,0 +1,547 @@
import { createHash } from 'node:crypto';
import {
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt,
type PluginPackageWorkflowTaskAttemptAdmissionReceipt,
} from './pluginPackageWorkflowTaskAttemptAdmission';
import {
RUN_DISPATCH_LEASE_STATUSES,
type RunDispatchLeaseStatus,
} from '../../run/runDispatchLease';
import type {
RunAttemptRecord,
RunCancellationReason,
RunEventRecord,
RunRecord,
} from '../../run/run';
import {
MAX_STEP_RUNS_PER_RUN,
STEP_RUN_TERMINAL_STATUSES,
normalizeStepRunRecord,
transitionStepRunMutation,
type StepRunMutation,
type StepRunRecord,
} from '../../run/stepRun';
export const PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_CONVERGENCE_SCHEMA =
'qinglong/plugin-package-workflow-cancellation-convergence@v1' as const;
export interface PluginPackageWorkflowCancellationActiveAttempt {
readonly admission:
Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
readonly attempt: Readonly<RunAttemptRecord>;
readonly leaseStatus: RunDispatchLeaseStatus | null;
}
export interface PluginPackageWorkflowCancellationSnapshot {
readonly run: Readonly<RunRecord>;
readonly stepRuns: readonly Readonly<StepRunRecord>[];
readonly activeTaskAttempts:
readonly Readonly<PluginPackageWorkflowCancellationActiveAttempt>[];
readonly observedAtMs: number;
}
export interface PluginPackageWorkflowCancellationAttemptTransition {
readonly previousStatus: 'claimed';
readonly attempt: Readonly<RunAttemptRecord>;
readonly event: Readonly<RunEventRecord>;
}
export interface PluginPackageWorkflowCancellationTerminalTransition {
readonly expectedRunVersion: number;
readonly expectedRunEventSequence: number;
readonly status: 'cancelled' | 'timed_out';
readonly finishedAtMs: number;
readonly errorCode: 'EXECUTION_CANCELLED' | 'EXECUTION_TIMED_OUT';
readonly errorSummary: string;
readonly event: Readonly<RunEventRecord>;
}
export interface PluginPackageWorkflowCancellationResolution {
readonly schema:
typeof PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_CONVERGENCE_SCHEMA;
readonly expectedRunVersion: number;
readonly expectedRunEventSequence: number;
readonly run: Readonly<RunRecord>;
readonly attemptTransitions:
readonly Readonly<PluginPackageWorkflowCancellationAttemptTransition>[];
readonly stepMutations: readonly Readonly<StepRunMutation>[];
readonly blockedAttemptIds: readonly string[];
readonly blockedStepRunIds: readonly string[];
readonly terminalTransition:
Readonly<PluginPackageWorkflowCancellationTerminalTransition> | null;
readonly observedAtMs: number;
}
export class InvalidPluginPackageWorkflowCancellationConvergenceError
extends TypeError
{
readonly code =
'PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_CONVERGENCE_INVALID';
constructor(message: string) {
super(
`Plugin Package Workflow cancellation convergence is invalid: ${message}`,
);
this.name =
'InvalidPluginPackageWorkflowCancellationConvergenceError';
}
}
const ACTIVE_ATTEMPT_STATUSES = new Set(['claimed', 'starting', 'running']);
const TERMINAL_STEP_STATUSES = new Set(STEP_RUN_TERMINAL_STATUSES);
const ID_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-cancellation-convergence-id@v1\0',
'utf8',
);
function invalid(message: string): never {
throw new InvalidPluginPackageWorkflowCancellationConvergenceError(message);
}
function counter(value: unknown, label: string): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0 ||
(value as number) > 2_147_483_647
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function timestamp(value: unknown, label: string): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function identity(
runId: string,
cancelRequestedAtMs: number,
kind: 'attempt' | 'step' | 'run',
targetId: string,
epoch: number,
): string {
return createHash('sha256')
.update(ID_DOMAIN)
.update(runId, 'utf8')
.update('\0', 'utf8')
.update(String(cancelRequestedAtMs), 'utf8')
.update('\0', 'utf8')
.update(kind, 'utf8')
.update('\0', 'utf8')
.update(targetId, 'utf8')
.update('\0', 'utf8')
.update(String(epoch), 'utf8')
.digest('hex')
.slice(0, 32);
}
function terminalMapping(reason: RunCancellationReason): Readonly<{
status: 'cancelled' | 'timed_out';
errorCode: 'EXECUTION_CANCELLED' | 'EXECUTION_TIMED_OUT';
errorSummary: string;
}> {
return reason === 'timeout'
? Object.freeze({
status: 'timed_out' as const,
errorCode: 'EXECUTION_TIMED_OUT' as const,
errorSummary: 'Execution exceeded its configured timeout',
})
: Object.freeze({
status: 'cancelled' as const,
errorCode: 'EXECUTION_CANCELLED' as const,
errorSummary: 'Execution was cancelled',
});
}
function validateRun(run: Readonly<RunRecord>): Readonly<{
cancelRequestedAtMs: number;
cancelReason: RunCancellationReason;
}> {
if (
!run ||
typeof run !== 'object' ||
Array.isArray(run) ||
run.triggerType !== 'plugin_package_workflow' ||
run.executionOrigin !== 'system' ||
run.executionOwner !== 'runtime' ||
run.status !== 'running' ||
run.cancelRequestedAtMs === undefined ||
run.cancelReason === undefined
) {
invalid('Run is not a cancelling runtime-owned Workflow aggregate');
}
counter(run.version, 'Run version');
counter(run.eventSequence, 'Run event sequence');
const cancelRequestedAtMs = timestamp(
run.cancelRequestedAtMs,
'Run cancellation time',
);
return Object.freeze({
cancelRequestedAtMs,
cancelReason: run.cancelReason,
});
}
function validateActiveAttempt(
run: Readonly<RunRecord>,
value: Readonly<PluginPackageWorkflowCancellationActiveAttempt>,
stepsById: ReadonlyMap<string, Readonly<StepRunRecord>>,
): Readonly<{
admission:
Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
attempt: Readonly<RunAttemptRecord>;
leaseStatus: RunDispatchLeaseStatus | null;
stepRun: Readonly<StepRunRecord>;
}> {
let admission:
Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
try {
admission =
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
value.admission,
);
} catch {
return invalid('active Attempt admission is invalid');
}
const { attempt } = value;
if (
!attempt ||
typeof attempt !== 'object' ||
Array.isArray(attempt) ||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status) ||
attempt.id !== admission.attemptId ||
attempt.runId !== run.id ||
attempt.runId !== admission.runId ||
attempt.stepRunId !== admission.stepRunId ||
attempt.attempt !== admission.attemptNumber ||
attempt.executorType !== admission.executorType ||
attempt.createdAtMs !== admission.admittedAtMs
) {
invalid('active Attempt does not match its immutable admission');
}
counter(attempt.callbackSequence, 'Attempt callback sequence');
if (
value.leaseStatus !== null &&
!RUN_DISPATCH_LEASE_STATUSES.includes(value.leaseStatus)
) {
invalid('active Attempt lease status is invalid');
}
const stepRun = stepsById.get(admission.stepRunId);
if (!stepRun || stepRun.kind !== 'task') {
invalid('active Attempt StepRun is missing');
}
if (
attempt.status === 'claimed' ||
attempt.status === 'starting'
) {
if (
stepRun.status !== 'ready' ||
stepRun.version !== admission.stepRunVersion ||
stepRun.stepRunDigest !== admission.stepRunDigest ||
attempt.startedAtMs !== undefined
) {
invalid('pre-start Attempt crossed its admitted StepRun epoch');
}
} else if (
stepRun.status !== 'running' ||
stepRun.version !== admission.stepRunVersion + 1 ||
stepRun.startedAtMs === null ||
attempt.startedAtMs === undefined
) {
invalid('running Attempt does not match the canonical StepRun');
}
return Object.freeze({
admission,
attempt,
leaseStatus: value.leaseStatus,
stepRun,
});
}
/**
* Converges one cancelling Workflow snapshot without inventing completion for
* active execution. Unleased pre-start claims and non-executing StepRuns are
* settled immediately; leased/starting/running Attempts remain blocked until
* worker completion or recovery crosses their own authority fences.
*/
export function resolvePluginPackageWorkflowCancellation(
snapshot: Readonly<PluginPackageWorkflowCancellationSnapshot>,
): Readonly<PluginPackageWorkflowCancellationResolution> {
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
invalid('snapshot is invalid');
}
const { cancelRequestedAtMs, cancelReason } = validateRun(snapshot.run);
const observedAtMs = timestamp(snapshot.observedAtMs, 'observation time');
if (observedAtMs < cancelRequestedAtMs) {
invalid('observation precedes cancellation intent');
}
if (
!Array.isArray(snapshot.stepRuns) ||
snapshot.stepRuns.length < 1 ||
snapshot.stepRuns.length > MAX_STEP_RUNS_PER_RUN ||
!Array.isArray(snapshot.activeTaskAttempts)
) {
invalid('Workflow StepRun or active Attempt collection is invalid');
}
const steps = snapshot.stepRuns.map((value) => {
try {
return normalizeStepRunRecord(value);
} catch {
return invalid('StepRun is invalid');
}
}).sort((left, right) =>
left.stepKey.localeCompare(right.stepKey) || left.id.localeCompare(right.id));
const stepsById = new Map<string, Readonly<StepRunRecord>>();
for (const stepRun of steps) {
if (
stepRun.runId !== snapshot.run.id ||
stepsById.has(stepRun.id) ||
observedAtMs < stepRun.updatedAtMs
) {
invalid('StepRun set is incomplete, duplicated or time-inconsistent');
}
stepsById.set(stepRun.id, stepRun);
}
const active = snapshot.activeTaskAttempts
.map((value) =>
validateActiveAttempt(snapshot.run, value, stepsById))
.sort((left, right) => left.attempt.id.localeCompare(right.attempt.id));
const activeByStepId = new Map<
string,
(typeof active)[number]
>();
for (const value of active) {
if (
activeByStepId.has(value.stepRun.id) ||
observedAtMs < value.attempt.createdAtMs ||
observedAtMs < (value.attempt.startedAtMs ?? 0)
) {
invalid('active Attempt set is duplicated or time-inconsistent');
}
activeByStepId.set(value.stepRun.id, value);
}
const terminal = terminalMapping(cancelReason);
const attemptTransitions:
PluginPackageWorkflowCancellationAttemptTransition[] = [];
const stepMutations: StepRunMutation[] = [];
const blockedAttemptIds: string[] = [];
const blockedStepRunIds = new Set<string>();
let runVersion = snapshot.run.version;
let runEventSequence = snapshot.run.eventSequence;
for (const value of active) {
const isBlocked =
value.attempt.status !== 'claimed' ||
value.leaseStatus === 'leased';
if (isBlocked) {
blockedAttemptIds.push(value.attempt.id);
blockedStepRunIds.add(value.stepRun.id);
continue;
}
if (
runVersion >= 2_147_483_647 ||
runEventSequence >= 2_147_483_647
) {
invalid('Run aggregate counter overflowed');
}
const digest = identity(
snapshot.run.id,
cancelRequestedAtMs,
'attempt',
value.attempt.id,
value.attempt.callbackSequence,
);
runVersion += 1;
runEventSequence += 1;
const attempt = Object.freeze({
...value.attempt,
status: terminal.status,
finishedAtMs: observedAtMs,
errorCode: terminal.errorCode,
errorSummary: terminal.errorSummary,
});
const event = Object.freeze({
id: `wca:${digest}`,
runId: snapshot.run.id,
sequence: runEventSequence,
type: `workflow.task_attempt.${terminal.status}`,
dedupeKey: `wca:${digest}`,
actorType: 'reconciler' as const,
actorId: 'runtime:cancellation',
attemptId: attempt.id,
stepRunId: value.stepRun.id,
payload: Object.freeze({
execution_scope: 'workflow_task',
attempt_id: attempt.id,
step_run_id: value.stepRun.id,
from_status: 'claimed',
to_status: terminal.status,
cancel_reason: cancelReason,
cancel_requested_at_ms: cancelRequestedAtMs,
error_code: terminal.errorCode,
version: runVersion,
}),
createdAtMs: observedAtMs,
} satisfies RunEventRecord);
attemptTransitions.push(Object.freeze({
previousStatus: 'claimed' as const,
attempt,
event,
}));
}
let projectedSteps = new Map(stepsById);
for (const current of steps) {
if (
TERMINAL_STEP_STATUSES.has(
current.status as (typeof STEP_RUN_TERMINAL_STATUSES)[number],
) ||
blockedStepRunIds.has(current.id)
) {
continue;
}
if (current.status === 'running') {
blockedStepRunIds.add(current.id);
continue;
}
const target =
cancelReason === 'timeout' &&
(current.status === 'ready' ||
current.status === 'waiting_approval')
? 'timed_out'
: 'cancelled';
if (
runVersion >= 2_147_483_647 ||
runEventSequence >= 2_147_483_647
) {
invalid('Run aggregate counter overflowed');
}
const digest = identity(
snapshot.run.id,
cancelRequestedAtMs,
'step',
current.id,
current.version,
);
const mutation = transitionStepRunMutation(
current,
{
expectedVersion: current.version,
expectedDigest: current.stepRunDigest,
mutationId: `wcm:${digest}`,
to: target,
atMs: observedAtMs,
resultCode:
target === 'timed_out'
? 'workflow_timed_out'
: 'workflow_cancelled',
...(target === 'timed_out'
? { errorSummary: terminal.errorSummary }
: {}),
},
{
expectedRunVersion: runVersion,
expectedRunEventSequence: runEventSequence,
eventId: `wcs:${digest}`,
dedupeKey: `wcs:${digest}`,
actor: {
type: 'reconciler',
id: 'runtime:cancellation',
},
},
);
stepMutations.push(mutation);
projectedSteps.set(current.id, mutation.stepRun);
runVersion += 1;
runEventSequence += 1;
}
const blockedSteps = [...blockedStepRunIds].sort();
const canTerminalize =
blockedSteps.length === 0 &&
[...projectedSteps.values()].every((stepRun) =>
TERMINAL_STEP_STATUSES.has(
stepRun.status as (typeof STEP_RUN_TERMINAL_STATUSES)[number],
));
let terminalTransition:
PluginPackageWorkflowCancellationTerminalTransition | null = null;
if (canTerminalize) {
if (
runVersion >= 2_147_483_647 ||
runEventSequence >= 2_147_483_647
) {
invalid('Run aggregate counter overflowed');
}
const digest = identity(
snapshot.run.id,
cancelRequestedAtMs,
'run',
snapshot.run.id,
runVersion,
);
terminalTransition = Object.freeze({
expectedRunVersion: runVersion,
expectedRunEventSequence: runEventSequence,
status: terminal.status,
finishedAtMs: observedAtMs,
errorCode: terminal.errorCode,
errorSummary: terminal.errorSummary,
event: Object.freeze({
id: `wcr:${digest}`,
runId: snapshot.run.id,
sequence: runEventSequence + 1,
type: `workflow.${terminal.status}`,
dedupeKey: `wcr:${digest}`,
actorType: 'reconciler',
actorId: 'runtime:cancellation',
payload: Object.freeze({
from_status: 'running',
to_status: terminal.status,
step_count: steps.length,
cancel_reason: cancelReason,
cancel_requested_at_ms: cancelRequestedAtMs,
error_code: terminal.errorCode,
version: runVersion + 1,
}),
createdAtMs: observedAtMs,
}),
});
runVersion += 1;
runEventSequence += 1;
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_CANCELLATION_CONVERGENCE_SCHEMA,
expectedRunVersion: snapshot.run.version,
expectedRunEventSequence: snapshot.run.eventSequence,
run: Object.freeze({
...snapshot.run,
version: runVersion,
eventSequence: runEventSequence,
...(terminalTransition === null
? {}
: {
status: terminalTransition.status,
finishedAtMs: terminalTransition.finishedAtMs,
errorCode: terminalTransition.errorCode,
errorSummary: terminalTransition.errorSummary,
}),
}),
attemptTransitions: Object.freeze(attemptTransitions),
stepMutations: Object.freeze(stepMutations),
blockedAttemptIds: Object.freeze(blockedAttemptIds),
blockedStepRunIds: Object.freeze(blockedSteps),
terminalTransition,
observedAtMs,
});
}
@@ -0,0 +1,963 @@
import { createHash } from 'node:crypto';
import {
MAX_PLUGIN_PACKAGE_AUTOMATION_WORKFLOWS,
normalizePluginPackageAutomationPublication,
type PluginPackageAutomationPublication,
type PluginPackageAutomationPublicationTarget,
} from '../pluginPackageAutomationPublication';
import {
MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS,
normalizePluginPackageMaterializedRevision,
normalizePluginPackageWorkflowResource,
type PluginPackageMaterializedRevision,
type PluginPackageTaskResource,
type PluginPackageWorkflowResource,
} from '../pluginPackageResourceMaterialization';
import type { RunEventRecord, RunRecord } from '../../run/run';
import {
createStepRunMutation,
normalizeStepRunMutation,
type StepRunMutation,
} from '../../run/stepRun';
import { TaskSpecSemanticRegistry } from '../../task-definition/taskSpecSemantic';
export const PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_SCHEMA =
'qinglong/plugin-package-workflow-execution-plan@v1' as const;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_BYTES = 256 * 1024;
export const PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_SCHEMA =
'qinglong/plugin-package-workflow-admission-receipt@v1' as const;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_BYTES = 256 * 1024;
export interface PluginPackageWorkflowExecutionPlanTarget
extends PluginPackageAutomationPublicationTarget {
readonly publicationDigest: string;
readonly workflowId: string;
readonly workflowDefinitionDigest: string;
}
export interface PluginPackageWorkflowExecutionPlanStep {
readonly stepRunId: string;
readonly stepKey: string;
readonly taskId: string;
readonly taskDefinitionRef: string;
readonly taskDefinitionDigest: string;
readonly needs: readonly string[];
readonly initialStatus: 'pending' | 'ready';
readonly required: true;
}
export interface PluginPackageWorkflowExecutionPlan {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_SCHEMA;
readonly planId: string;
readonly runId: string;
readonly target: Readonly<PluginPackageWorkflowExecutionPlanTarget>;
readonly steps: readonly Readonly<PluginPackageWorkflowExecutionPlanStep>[];
readonly plannedAtMs: number;
readonly planDigest: string;
}
export interface CreatePluginPackageWorkflowExecutionPlanInput {
readonly planId: string;
readonly runId: string;
readonly workflowId: string;
readonly stepRunIds: Readonly<Record<string, string>>;
readonly publication: Readonly<PluginPackageAutomationPublication>;
readonly revision: Readonly<PluginPackageMaterializedRevision>;
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
readonly plannedAtMs: number;
}
export interface PluginPackageWorkflowAdmissionReceiptStep {
readonly stepKey: string;
readonly stepRunId: string;
readonly stepRunDigest: string;
readonly mutationId: string;
readonly eventId: string;
}
export interface PluginPackageWorkflowAdmissionReceipt {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_SCHEMA;
readonly planId: string;
readonly planDigest: string;
readonly runId: string;
readonly publicationDigest: string;
readonly workflowId: string;
readonly steps: readonly Readonly<PluginPackageWorkflowAdmissionReceiptStep>[];
readonly finalRunVersion: number;
readonly finalRunEventSequence: number;
readonly admittedAtMs: number;
readonly receiptDigest: string;
}
export interface PluginPackageWorkflowAdmissionBundle {
readonly plan: Readonly<PluginPackageWorkflowExecutionPlan>;
readonly run: Readonly<RunRecord>;
readonly admissionEvent: Readonly<RunEventRecord>;
readonly stepMutations: readonly Readonly<StepRunMutation>[];
readonly receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
}
export interface PluginPackageWorkflowAdmissionRepository {
findByPlanId(
planId: string,
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null>;
findByRunId(
runId: string,
): Promise<Readonly<PluginPackageWorkflowAdmissionReceipt> | null>;
admit(plan: Readonly<PluginPackageWorkflowExecutionPlan>): Promise<
Readonly<{
status: 'created' | 'existing';
receipt: Readonly<PluginPackageWorkflowAdmissionReceipt>;
}>
>;
}
export class InvalidPluginPackageWorkflowExecutionPlanError extends TypeError {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_INVALID';
constructor(message: string) {
super(`Plugin Package Workflow execution plan is invalid: ${message}`);
this.name = 'InvalidPluginPackageWorkflowExecutionPlanError';
}
}
export class PluginPackageWorkflowExecutionPlanConflictError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_CONFLICT';
constructor(message: string) {
super(`Plugin Package Workflow cannot be planned: ${message}`);
this.name = 'PluginPackageWorkflowExecutionPlanConflictError';
}
}
export class InvalidPluginPackageWorkflowAdmissionReceiptError extends TypeError {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_INVALID';
constructor(message: string) {
super(`Plugin Package Workflow admission receipt is invalid: ${message}`);
this.name = 'InvalidPluginPackageWorkflowAdmissionReceiptError';
}
}
export class PluginPackageWorkflowAdmissionConflictError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_ADMISSION_CONFLICT';
constructor(message: string) {
super(`Plugin Package Workflow admission conflicts with state: ${message}`);
this.name = 'PluginPackageWorkflowAdmissionConflictError';
}
}
export class PluginPackageWorkflowAdmissionNotAllowedError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_ADMISSION_NOT_ALLOWED';
constructor() {
super('Plugin Package Workflow admission is not allowed');
this.name = 'PluginPackageWorkflowAdmissionNotAllowedError';
}
}
export class PluginPackageWorkflowAdmissionUnavailableError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_ADMISSION_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Plugin Package Workflow admission is unavailable', options);
this.name = 'PluginPackageWorkflowAdmissionUnavailableError';
}
}
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const PORTABLE_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const RESOURCE_ID = /^[a-z][a-z0-9-]{0,62}$/;
const DIGEST = /^[0-9a-f]{64}$/;
const PLAN_DIGEST_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-execution-plan-digest@v1\0',
'utf8',
);
const WORKFLOW_DEFINITION_DIGEST_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-definition-digest@v1\0',
'utf8',
);
const WORKFLOW_ADMISSION_RECEIPT_DIGEST_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-admission-receipt-digest@v1\0',
'utf8',
);
function invalid(message: string): never {
throw new InvalidPluginPackageWorkflowExecutionPlanError(message);
}
function portableRunId(value: unknown, label: string): string {
if (typeof value !== 'string' || !PORTABLE_RUN_ID.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function conflict(message: string): never {
throw new PluginPackageWorkflowExecutionPlanConflictError(message);
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
return invalid(`${label} must be an object`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
return invalid(`${label} must contain enumerable data properties`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const actual = Reflect.ownKeys(value);
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key) => typeof key !== 'string') ||
actual
.map(String)
.sort()
.some((key, index) => key !== canonical[index])
) {
invalid(`${label} shape is invalid`);
}
}
function denseArray(
value: unknown,
maximum: number,
label: string,
minimum = 1,
): readonly unknown[] {
if (
!Array.isArray(value) ||
value.length < minimum ||
value.length > maximum
) {
return invalid(`${label} is invalid`);
}
const keys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
ownKeys.length !== value.length + 1 ||
!ownKeys.includes('length') ||
keys.length !== value.length ||
keys.some((key, index) => key !== String(index)) ||
keys.some((key) => {
const descriptor = descriptors[key];
return (
descriptor === undefined ||
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true
);
})
) {
return invalid(`${label} must be a dense data array`);
}
return value;
}
function identifier(value: unknown, label: string): string {
if (typeof value !== 'string' || !IDENTIFIER.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function resourceId(value: unknown, label: string): string {
if (typeof value !== 'string' || !RESOURCE_ID.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function packageName(value: unknown): string {
if (typeof value !== 'string' || !PACKAGE_NAME.test(value)) {
return invalid('packageName is invalid');
}
return value;
}
function digest(value: unknown, label: string): string {
if (typeof value !== 'string' || !DIGEST.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function positiveInteger(value: unknown, label: string): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
(value as number) > 2_147_483_647
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function timestamp(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return invalid('plannedAtMs is invalid');
}
return value as number;
}
function hash(domain: Buffer, value: unknown): string {
return createHash('sha256')
.update(domain)
.update(JSON.stringify(value))
.digest('hex');
}
export function pluginPackageWorkflowDefinitionDigest(
value: PluginPackageWorkflowResource,
): string {
return hash(
WORKFLOW_DEFINITION_DIGEST_DOMAIN,
normalizePluginPackageWorkflowResource(value),
);
}
function target(
value: PluginPackageWorkflowExecutionPlanTarget,
): Readonly<PluginPackageWorkflowExecutionPlanTarget> {
const record = dataRecord(value, 'plan target');
exactKeys(
record,
[
'generation',
'generationDigest',
'installationId',
'lockDigest',
'materializedRevisionDigest',
'packageName',
'projectId',
'publicationDigest',
'workflowDefinitionDigest',
'workflowId',
],
'plan target',
);
return Object.freeze({
projectId: identifier(value.projectId, 'projectId'),
packageName: packageName(value.packageName),
installationId: identifier(value.installationId, 'installationId'),
lockDigest: digest(value.lockDigest, 'lockDigest'),
generation: positiveInteger(value.generation, 'generation'),
generationDigest: digest(value.generationDigest, 'generationDigest'),
materializedRevisionDigest: digest(
value.materializedRevisionDigest,
'materializedRevisionDigest',
),
publicationDigest: digest(value.publicationDigest, 'publicationDigest'),
workflowId: resourceId(value.workflowId, 'workflowId'),
workflowDefinitionDigest: digest(
value.workflowDefinitionDigest,
'workflowDefinitionDigest',
),
});
}
function taskDefinitionRef(
materializedRevisionDigest: string,
taskId: string,
): string {
return `plugin-package:${materializedRevisionDigest}:task:${taskId}`;
}
function normalizeStep(
value: PluginPackageWorkflowExecutionPlanStep,
planTarget: Readonly<PluginPackageWorkflowExecutionPlanTarget>,
): Readonly<PluginPackageWorkflowExecutionPlanStep> {
const step = dataRecord(value, 'plan step');
exactKeys(
step,
[
'initialStatus',
'needs',
'required',
'stepKey',
'stepRunId',
'taskDefinitionDigest',
'taskDefinitionRef',
'taskId',
],
'plan step',
);
if (
(value.initialStatus !== 'pending' && value.initialStatus !== 'ready') ||
value.required !== true
) {
return invalid('plan step status or required flag is invalid');
}
const stepKey = resourceId(value.stepKey, 'stepKey');
const taskId = resourceId(value.taskId, 'taskId');
const needs = denseArray(
value.needs,
MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS,
'step needs',
0,
);
const normalizedNeeds = Object.freeze(
needs.map((need) => resourceId(need, 'step dependency')),
);
if (
new Set(normalizedNeeds).size !== normalizedNeeds.length ||
normalizedNeeds.includes(stepKey) ||
[...normalizedNeeds]
.sort()
.some((need, index) => need !== normalizedNeeds[index]) ||
(normalizedNeeds.length === 0) !== (value.initialStatus === 'ready')
) {
return invalid('step dependency or initial status is invalid');
}
const expectedReference = taskDefinitionRef(
planTarget.materializedRevisionDigest,
taskId,
);
if (value.taskDefinitionRef !== expectedReference) {
return invalid('taskDefinitionRef is not generation-bound');
}
return Object.freeze({
stepRunId: identifier(value.stepRunId, 'stepRunId'),
stepKey,
taskId,
taskDefinitionRef: expectedReference,
taskDefinitionDigest: digest(
value.taskDefinitionDigest,
'taskDefinitionDigest',
),
needs: normalizedNeeds,
initialStatus: value.initialStatus,
required: true,
});
}
export function pluginPackageWorkflowExecutionPlanDigest(
value:
| Omit<PluginPackageWorkflowExecutionPlan, 'planDigest'>
| PluginPackageWorkflowExecutionPlan,
): string {
return hash(PLAN_DIGEST_DOMAIN, {
schema: value.schema,
planId: value.planId,
runId: value.runId,
target: value.target,
steps: value.steps,
plannedAtMs: value.plannedAtMs,
});
}
function bounded(
value: Readonly<PluginPackageWorkflowExecutionPlan>,
): Readonly<PluginPackageWorkflowExecutionPlan> {
if (
Buffer.byteLength(JSON.stringify(value), 'utf8') >
MAX_PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_BYTES
) {
return invalid('encoded plan exceeds its size limit');
}
return value;
}
function assertDag(
steps: readonly Readonly<PluginPackageWorkflowExecutionPlanStep>[],
): void {
const byKey = new Map(steps.map((step) => [step.stepKey, step]));
if (
byKey.size !== steps.length ||
new Set(steps.map(({ stepRunId }) => stepRunId)).size !== steps.length ||
steps.some(({ needs }) => needs.some((need) => !byKey.has(need)))
) {
invalid('plan step identity or dependency is invalid');
}
const pending = new Map(
steps.map((step) => [step.stepKey, new Set(step.needs)]),
);
const ready = [...pending.entries()]
.filter(([, needs]) => needs.size === 0)
.map(([stepKey]) => stepKey);
let visited = 0;
while (ready.length > 0) {
const current = ready.pop()!;
if (!pending.delete(current)) continue;
visited += 1;
for (const [stepKey, needs] of pending) {
if (needs.delete(current) && needs.size === 0) ready.push(stepKey);
}
}
if (visited !== steps.length) invalid('plan graph contains a cycle');
}
export function normalizePluginPackageWorkflowExecutionPlan(
value: PluginPackageWorkflowExecutionPlan,
): Readonly<PluginPackageWorkflowExecutionPlan> {
const plan = dataRecord(value, 'plan');
exactKeys(
plan,
[
'planDigest',
'planId',
'plannedAtMs',
'runId',
'schema',
'steps',
'target',
],
'plan',
);
if (value.schema !== PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_SCHEMA) {
return invalid('plan schema is invalid');
}
const normalizedTarget = target(value.target);
const steps = denseArray(
value.steps,
MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS,
'plan steps',
)
.map((step) =>
normalizeStep(
step as PluginPackageWorkflowExecutionPlanStep,
normalizedTarget,
),
)
.sort((left, right) => left.stepKey.localeCompare(right.stepKey));
if (
steps.some(
(step, index) => index > 0 && steps[index - 1]!.stepKey === step.stepKey,
)
) {
return invalid('plan step identity is duplicated');
}
assertDag(steps);
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_SCHEMA,
planId: identifier(value.planId, 'planId'),
runId: portableRunId(value.runId, 'runId'),
target: normalizedTarget,
steps: Object.freeze(steps),
plannedAtMs: timestamp(value.plannedAtMs),
});
const planDigest = digest(value.planDigest, 'planDigest');
if (pluginPackageWorkflowExecutionPlanDigest(unsigned) !== planDigest) {
return invalid('planDigest does not match plan');
}
return bounded(Object.freeze({ ...unsigned, planDigest }));
}
function assertPublicationRevisionBinding(
publication: Readonly<PluginPackageAutomationPublication>,
revision: Readonly<PluginPackageMaterializedRevision>,
): void {
const generation = revision.generation;
if (
publication.state !== 'active' ||
publication.target.projectId !== generation.projectId ||
publication.target.packageName !== generation.packageName ||
publication.target.installationId !== generation.installationId ||
publication.target.lockDigest !== generation.lockDigest ||
publication.target.generation !== generation.generation ||
publication.target.generationDigest !== generation.generationDigest ||
publication.target.materializedRevisionDigest !== revision.revisionDigest
) {
conflict('publication is not the active definition of this revision');
}
}
export function createPluginPackageWorkflowExecutionPlan(
value: CreatePluginPackageWorkflowExecutionPlanInput,
): Readonly<PluginPackageWorkflowExecutionPlan> {
const input = dataRecord(value, 'create input');
exactKeys(
input,
[
'planId',
'plannedAtMs',
'publication',
'revision',
'runId',
'stepRunIds',
'taskSpecSemanticRegistry',
'workflowId',
],
'create input',
);
if (!(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)) {
return invalid('TaskSpec semantic registry is invalid');
}
const publication = normalizePluginPackageAutomationPublication(
value.publication,
);
const revision = normalizePluginPackageMaterializedRevision(
value.revision,
value.taskSpecSemanticRegistry,
);
assertPublicationRevisionBinding(publication, revision);
const workflowId = resourceId(value.workflowId, 'workflowId');
const publicationWorkflow = publication.definitions.workflows.find(
(workflow) => workflow.id === workflowId,
);
const revisionWorkflowResource = revision.resources.find(
(resource) =>
resource.kind === 'workflow' &&
(resource.value as PluginPackageWorkflowResource).id === workflowId,
);
if (!publicationWorkflow || !revisionWorkflowResource) {
return conflict('Workflow is not present in the exact publication');
}
const revisionWorkflow = normalizePluginPackageWorkflowResource(
revisionWorkflowResource.value,
);
const workflowDefinitionDigest =
pluginPackageWorkflowDefinitionDigest(publicationWorkflow);
if (
workflowDefinitionDigest !==
pluginPackageWorkflowDefinitionDigest(revisionWorkflow) ||
!publicationWorkflow.enabled
) {
return conflict('Workflow definition drifted or is disabled');
}
if (
publication.definitions.workflows.length >
MAX_PLUGIN_PACKAGE_AUTOMATION_WORKFLOWS
) {
return invalid('publication Workflow count exceeds the limit');
}
const tasks = new Map(
revision.resources
.filter(({ kind }) => kind === 'task')
.map((resource) => [
(resource.value as PluginPackageTaskResource).id,
resource,
]),
);
const stepRunIds = dataRecord(value.stepRunIds, 'stepRunIds');
exactKeys(
stepRunIds,
publicationWorkflow.steps.map(({ id }) => id),
'stepRunIds',
);
if (publicationWorkflow.steps.length < 1) {
return conflict('Workflow has no executable steps');
}
const planTarget = target({
...publication.target,
publicationDigest: publication.publicationDigest,
workflowId,
workflowDefinitionDigest,
});
const steps = publicationWorkflow.steps.map((step) => {
const taskResource = tasks.get(step.task);
if (!taskResource) {
return conflict('Workflow Task is absent from the exact revision');
}
const task = taskResource.value as PluginPackageTaskResource;
if (!task.enabled) {
return conflict('Workflow Task is disabled');
}
return normalizeStep(
{
stepRunId: stepRunIds[step.id] as string,
stepKey: step.id,
taskId: step.task,
taskDefinitionRef: taskDefinitionRef(
revision.revisionDigest,
step.task,
),
taskDefinitionDigest: taskResource.sourceDigest,
needs: step.needs,
initialStatus: step.needs.length === 0 ? 'ready' : 'pending',
required: true,
},
planTarget,
);
});
assertDag(steps);
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_EXECUTION_PLAN_SCHEMA,
planId: identifier(value.planId, 'planId'),
runId: portableRunId(value.runId, 'runId'),
target: planTarget,
steps: Object.freeze(
[...steps].sort((left, right) =>
left.stepKey.localeCompare(right.stepKey),
),
),
plannedAtMs: timestamp(value.plannedAtMs),
});
const result = Object.freeze({
...unsigned,
planDigest: pluginPackageWorkflowExecutionPlanDigest(unsigned),
});
return normalizePluginPackageWorkflowExecutionPlan(result);
}
function admissionIdentity(
prefix: 'wfa' | 'wfe' | 'wfm',
planDigest: string,
index?: number,
): string {
if (index === undefined) return `${prefix}:${planDigest.slice(0, 32)}`;
const digestLength = prefix === 'wfe' ? 27 : 56;
return `${prefix}:${planDigest.slice(0, digestLength)}:${index}`;
}
function receiptStep(
value: PluginPackageWorkflowAdmissionReceiptStep,
): Readonly<PluginPackageWorkflowAdmissionReceiptStep> {
const step = dataRecord(value, 'receipt step');
exactKeys(
step,
['eventId', 'mutationId', 'stepKey', 'stepRunDigest', 'stepRunId'],
'receipt step',
);
return Object.freeze({
stepKey: resourceId(value.stepKey, 'receipt stepKey'),
stepRunId: identifier(value.stepRunId, 'receipt stepRunId'),
stepRunDigest: digest(value.stepRunDigest, 'receipt stepRunDigest'),
mutationId: identifier(value.mutationId, 'receipt mutationId'),
eventId: identifier(value.eventId, 'receipt eventId'),
});
}
function workflowAdmissionReceiptFields(
value:
| Omit<PluginPackageWorkflowAdmissionReceipt, 'receiptDigest'>
| PluginPackageWorkflowAdmissionReceipt,
): Omit<PluginPackageWorkflowAdmissionReceipt, 'receiptDigest'> {
return {
schema: value.schema,
planId: value.planId,
planDigest: value.planDigest,
runId: value.runId,
publicationDigest: value.publicationDigest,
workflowId: value.workflowId,
steps: value.steps,
finalRunVersion: value.finalRunVersion,
finalRunEventSequence: value.finalRunEventSequence,
admittedAtMs: value.admittedAtMs,
};
}
export function pluginPackageWorkflowAdmissionReceiptDigest(
value:
| Omit<PluginPackageWorkflowAdmissionReceipt, 'receiptDigest'>
| PluginPackageWorkflowAdmissionReceipt,
): string {
return hash(
WORKFLOW_ADMISSION_RECEIPT_DIGEST_DOMAIN,
workflowAdmissionReceiptFields(value),
);
}
export function normalizePluginPackageWorkflowAdmissionReceipt(
value: PluginPackageWorkflowAdmissionReceipt,
): Readonly<PluginPackageWorkflowAdmissionReceipt> {
const receipt = dataRecord(value, 'admission receipt');
exactKeys(
receipt,
[
'admittedAtMs',
'finalRunEventSequence',
'finalRunVersion',
'planDigest',
'planId',
'publicationDigest',
'receiptDigest',
'runId',
'schema',
'steps',
'workflowId',
],
'admission receipt',
);
if (value.schema !== PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_SCHEMA) {
throw new InvalidPluginPackageWorkflowAdmissionReceiptError(
'schema is invalid',
);
}
const steps = denseArray(
value.steps,
MAX_PLUGIN_PACKAGE_WORKFLOW_STEPS,
'receipt steps',
)
.map((step) =>
receiptStep(step as PluginPackageWorkflowAdmissionReceiptStep),
)
.sort((left, right) => left.stepKey.localeCompare(right.stepKey));
if (
new Set(steps.map(({ stepKey }) => stepKey)).size !== steps.length ||
new Set(steps.map(({ stepRunId }) => stepRunId)).size !== steps.length ||
new Set(steps.map(({ mutationId }) => mutationId)).size !== steps.length ||
new Set(steps.map(({ eventId }) => eventId)).size !== steps.length
) {
throw new InvalidPluginPackageWorkflowAdmissionReceiptError(
'step identity is duplicated',
);
}
const finalCounter = steps.length + 1;
if (
value.finalRunVersion !== finalCounter ||
value.finalRunEventSequence !== finalCounter
) {
throw new InvalidPluginPackageWorkflowAdmissionReceiptError(
'final Run counters are invalid',
);
}
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_SCHEMA,
planId: identifier(value.planId, 'receipt planId'),
planDigest: digest(value.planDigest, 'receipt planDigest'),
runId: portableRunId(value.runId, 'receipt runId'),
publicationDigest: digest(
value.publicationDigest,
'receipt publicationDigest',
),
workflowId: resourceId(value.workflowId, 'receipt workflowId'),
steps: Object.freeze(steps),
finalRunVersion: finalCounter,
finalRunEventSequence: finalCounter,
admittedAtMs: timestamp(value.admittedAtMs),
});
const receiptDigest = digest(value.receiptDigest, 'receiptDigest');
if (pluginPackageWorkflowAdmissionReceiptDigest(unsigned) !== receiptDigest) {
throw new InvalidPluginPackageWorkflowAdmissionReceiptError(
'receiptDigest does not match receipt',
);
}
const normalized = Object.freeze({ ...unsigned, receiptDigest });
if (
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
MAX_PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_BYTES
) {
throw new InvalidPluginPackageWorkflowAdmissionReceiptError(
'encoded receipt exceeds its size limit',
);
}
return normalized;
}
export function createPluginPackageWorkflowAdmissionBundle(
planValue: PluginPackageWorkflowExecutionPlan,
): Readonly<PluginPackageWorkflowAdmissionBundle> {
const plan = normalizePluginPackageWorkflowExecutionPlan(planValue);
const admissionEvent = Object.freeze({
id: admissionIdentity('wfa', plan.planDigest),
runId: plan.runId,
sequence: 1,
type: 'workflow.admitted',
dedupeKey: admissionIdentity('wfa', plan.planDigest),
actorType: 'system' as const,
payload: Object.freeze({
planId: plan.planId,
planDigest: plan.planDigest,
publicationDigest: plan.target.publicationDigest,
workflowId: plan.target.workflowId,
workflowDefinitionDigest: plan.target.workflowDefinitionDigest,
stepCount: plan.steps.length,
}),
createdAtMs: plan.plannedAtMs,
} satisfies RunEventRecord);
const stepMutations = plan.steps.map((step, index) =>
normalizeStepRunMutation(
createStepRunMutation(
{
id: step.stepRunId,
runId: plan.runId,
stepKey: step.stepKey,
kind: 'task',
definitionRef: step.taskDefinitionRef,
definitionDigest: step.taskDefinitionDigest,
required: true,
initialStatus: step.initialStatus,
mutationId: admissionIdentity('wfm', plan.planDigest, index + 1),
createdAtMs: plan.plannedAtMs,
},
{
expectedRunVersion: index + 1,
expectedRunEventSequence: index + 1,
eventId: admissionIdentity('wfe', plan.planDigest, index + 1),
dedupeKey: admissionIdentity('wfe', plan.planDigest, index + 1),
actor: { type: 'system' },
},
),
),
);
const finalCounter = stepMutations.length + 1;
const run = Object.freeze({
id: plan.runId,
projectId: plan.target.projectId,
taskId: plan.target.workflowId,
taskRevision: plan.target.publicationDigest,
taskSnapshotRef:
`plugin-package:${plan.target.publicationDigest}:workflow:` +
plan.target.workflowId,
triggerType: 'plugin_package_workflow',
executionOrigin: 'system' as const,
executionOwner: 'runtime' as const,
requestId: plan.planId,
status: 'running' as const,
version: finalCounter,
eventSequence: finalCounter,
priority: 0,
idempotencyKey: `plugin-package-workflow:${plan.planId}`,
createdAtMs: plan.plannedAtMs,
startedAtMs: plan.plannedAtMs,
} satisfies RunRecord);
const receiptUnsigned = Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_ADMISSION_RECEIPT_SCHEMA,
planId: plan.planId,
planDigest: plan.planDigest,
runId: plan.runId,
publicationDigest: plan.target.publicationDigest,
workflowId: plan.target.workflowId,
steps: Object.freeze(
stepMutations.map((mutation) =>
Object.freeze({
stepKey: mutation.stepRun.stepKey,
stepRunId: mutation.stepRun.id,
stepRunDigest: mutation.stepRun.stepRunDigest,
mutationId: mutation.mutationId,
eventId: mutation.event.id,
}),
),
),
finalRunVersion: finalCounter,
finalRunEventSequence: finalCounter,
admittedAtMs: plan.plannedAtMs,
});
const receipt = normalizePluginPackageWorkflowAdmissionReceipt({
...receiptUnsigned,
receiptDigest: pluginPackageWorkflowAdmissionReceiptDigest(receiptUnsigned),
});
return Object.freeze({
plan,
run,
admissionEvent,
stepMutations: Object.freeze(stepMutations),
receipt,
});
}
@@ -0,0 +1,430 @@
import { createHash } from 'node:crypto';
import {
normalizePluginPackageWorkflowExecutionPlan,
type PluginPackageWorkflowExecutionPlan,
} from './pluginPackageWorkflowExecutionPlan';
import type { RunEventRecord, RunRecord, RunStatus } from '../../run/run';
import {
STEP_RUN_TERMINAL_STATUSES,
normalizeStepRunRecord,
transitionStepRunMutation,
type StepRunMutation,
type StepRunRecord,
type StepRunStatus,
} from '../../run/stepRun';
export const PLUGIN_PACKAGE_WORKFLOW_FRONTIER_SCHEMA =
'qinglong/plugin-package-workflow-frontier@v1' as const;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_FRONTIER_PAGE_SIZE = 64;
export type PluginPackageWorkflowTerminalStatus = Extract<
RunStatus,
'succeeded' | 'failed' | 'cancelled' | 'timed_out'
>;
export interface PluginPackageWorkflowFrontierSnapshot {
readonly plan: Readonly<PluginPackageWorkflowExecutionPlan>;
readonly run: Readonly<RunRecord>;
readonly stepRuns: readonly Readonly<StepRunRecord>[];
readonly observedAtMs: number;
}
export interface PluginPackageWorkflowFrontierResolution {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_FRONTIER_SCHEMA;
readonly runId: string;
readonly planDigest: string;
readonly expectedRunVersion: number;
readonly expectedRunEventSequence: number;
readonly stepMutations: readonly Readonly<StepRunMutation>[];
readonly readyStepRunIds: readonly string[];
readonly terminalStatus: PluginPackageWorkflowTerminalStatus | null;
readonly terminalTransition: Readonly<PluginPackageWorkflowTerminalTransition> | null;
readonly observedAtMs: number;
}
export interface PluginPackageWorkflowTerminalTransition {
readonly expectedRunVersion: number;
readonly expectedRunEventSequence: number;
readonly status: PluginPackageWorkflowTerminalStatus;
readonly finishedAtMs: number;
readonly errorCode: string | null;
readonly event: Readonly<RunEventRecord>;
}
export interface PluginPackageWorkflowFrontierCursor {
readonly admittedAtMs: number;
readonly planDigest: string;
}
export interface PluginPackageWorkflowFrontierCandidate
extends PluginPackageWorkflowFrontierCursor {
readonly runId: string;
}
export interface PluginPackageWorkflowFrontierPage {
readonly candidates: readonly Readonly<PluginPackageWorkflowFrontierCandidate>[];
readonly truncated: boolean;
readonly next?: Readonly<PluginPackageWorkflowFrontierCursor>;
}
export interface PluginPackageWorkflowFrontierAdvanceResult {
readonly status: 'advanced' | 'unchanged' | 'terminal' | 'settled';
readonly runId: string;
readonly planDigest: string;
readonly stepMutationCount: number;
readonly readyStepRunIds: readonly string[];
readonly terminalStatus: PluginPackageWorkflowTerminalStatus | null;
readonly runVersion: number;
readonly runEventSequence: number;
readonly observedAtMs: number;
}
export interface PluginPackageWorkflowFrontierRepository {
listCandidates(query: Readonly<{
limit: number;
after?: Readonly<PluginPackageWorkflowFrontierCursor>;
}>): Promise<Readonly<PluginPackageWorkflowFrontierPage>>;
advance(
runId: string,
): Promise<Readonly<PluginPackageWorkflowFrontierAdvanceResult>>;
}
export class InvalidPluginPackageWorkflowFrontierError extends TypeError {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_FRONTIER_INVALID';
constructor(message: string) {
super(`Plugin Package Workflow frontier is invalid: ${message}`);
this.name = 'InvalidPluginPackageWorkflowFrontierError';
}
}
export class PluginPackageWorkflowFrontierConflictError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_FRONTIER_CONFLICT';
constructor() {
super('Plugin Package Workflow frontier changed concurrently');
this.name = 'PluginPackageWorkflowFrontierConflictError';
}
}
export class PluginPackageWorkflowFrontierUnavailableError extends Error {
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_FRONTIER_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Plugin Package Workflow frontier is unavailable', options);
this.name = 'PluginPackageWorkflowFrontierUnavailableError';
}
}
const FRONTIER_ID_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-frontier-id@v1\0',
'utf8',
);
const BLOCKING_TERMINAL_STATUSES = new Set<StepRunStatus>([
'failed',
'skipped',
'cancelled',
'timed_out',
]);
const TERMINAL_STATUSES = new Set<StepRunStatus>(
STEP_RUN_TERMINAL_STATUSES,
);
function invalid(message: string): never {
throw new InvalidPluginPackageWorkflowFrontierError(message);
}
function timestamp(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return invalid('observation time is invalid');
}
return value as number;
}
function counter(value: unknown, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function frontierIdentity(
planDigest: string,
stepRun: Readonly<StepRunRecord>,
target: 'ready' | 'skipped',
): Readonly<{ eventId: string; mutationId: string }> {
const digest = createHash('sha256')
.update(FRONTIER_ID_DOMAIN)
.update(planDigest, 'utf8')
.update('\0', 'utf8')
.update(stepRun.id, 'utf8')
.update('\0', 'utf8')
.update(String(stepRun.version), 'utf8')
.update('\0', 'utf8')
.update(target, 'utf8')
.digest('hex');
return Object.freeze({
eventId: `wff:${digest.slice(0, 32)}`,
mutationId: `workflow-frontier:${digest}`,
});
}
function terminalIdentity(
planDigest: string,
runVersion: number,
status: PluginPackageWorkflowTerminalStatus,
): string {
const digest = createHash('sha256')
.update(FRONTIER_ID_DOMAIN)
.update(planDigest, 'utf8')
.update('\0terminal\0', 'utf8')
.update(String(runVersion), 'utf8')
.update('\0', 'utf8')
.update(status, 'utf8')
.digest('hex');
return `wft:${digest.slice(0, 32)}`;
}
function terminalErrorCode(
status: PluginPackageWorkflowTerminalStatus,
): string | null {
if (status === 'succeeded') return null;
if (status === 'failed') return 'workflow_step_failed';
if (status === 'timed_out') return 'workflow_step_timed_out';
return 'workflow_step_cancelled';
}
function validateRun(
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
run: Readonly<RunRecord>,
): void {
if (
!run ||
typeof run !== 'object' ||
Array.isArray(run) ||
run.id !== plan.runId ||
run.projectId !== plan.target.projectId ||
run.taskId !== plan.target.workflowId ||
run.taskRevision !== plan.target.publicationDigest ||
run.triggerType !== 'plugin_package_workflow' ||
run.executionOrigin !== 'system' ||
run.executionOwner !== 'runtime' ||
run.requestId !== plan.planId ||
run.idempotencyKey !== `plugin-package-workflow:${plan.planId}` ||
run.status !== 'running' ||
run.cancelRequestedAtMs !== undefined
) {
invalid('Run does not match the admitted Workflow');
}
counter(run.version, 'Run version');
counter(run.eventSequence, 'Run event sequence');
}
function validateStepRuns(
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
stepRunValues: readonly Readonly<StepRunRecord>[],
): ReadonlyMap<string, Readonly<StepRunRecord>> {
if (
!Array.isArray(stepRunValues) ||
stepRunValues.length !== plan.steps.length
) {
return invalid('StepRun set is incomplete');
}
const byKey = new Map<string, Readonly<StepRunRecord>>();
for (const value of stepRunValues) {
let stepRun: Readonly<StepRunRecord>;
try {
stepRun = normalizeStepRunRecord(value);
} catch {
return invalid('StepRun record is invalid');
}
const step = plan.steps.find(({ stepKey }) => stepKey === stepRun.stepKey);
if (
!step ||
byKey.has(step.stepKey) ||
stepRun.id !== step.stepRunId ||
stepRun.runId !== plan.runId ||
stepRun.parentStepRunId !== null ||
stepRun.kind !== 'task' ||
stepRun.definitionRef !== step.taskDefinitionRef ||
stepRun.definitionDigest !== step.taskDefinitionDigest ||
stepRun.required !== step.required
) {
return invalid('StepRun does not match the immutable plan');
}
byKey.set(step.stepKey, stepRun);
}
return byKey;
}
function dependencyCannotSucceed(
stepKey: string,
plan: Readonly<PluginPackageWorkflowExecutionPlan>,
stepRuns: ReadonlyMap<string, Readonly<StepRunRecord>>,
memo: Map<string, boolean>,
visiting: Set<string>,
): boolean {
const memoized = memo.get(stepKey);
if (memoized !== undefined) return memoized;
if (visiting.has(stepKey)) {
return invalid('Workflow dependency graph is cyclic');
}
const step = plan.steps.find((candidate) => candidate.stepKey === stepKey);
const stepRun = stepRuns.get(stepKey);
if (!step || !stepRun) return invalid('Workflow dependency is missing');
if (BLOCKING_TERMINAL_STATUSES.has(stepRun.status)) {
memo.set(stepKey, true);
return true;
}
if (stepRun.status !== 'pending') {
memo.set(stepKey, false);
return false;
}
visiting.add(stepKey);
const blocked = step.needs.some((dependency) =>
dependencyCannotSucceed(
dependency,
plan,
stepRuns,
memo,
visiting,
),
);
visiting.delete(stepKey);
memo.set(stepKey, blocked);
return blocked;
}
function terminalStatus(
statuses: readonly StepRunStatus[],
): PluginPackageWorkflowTerminalStatus | null {
if (!statuses.every((status) => TERMINAL_STATUSES.has(status))) return null;
if (statuses.includes('failed') || statuses.includes('skipped')) {
return 'failed';
}
if (statuses.includes('timed_out')) return 'timed_out';
if (statuses.includes('cancelled')) return 'cancelled';
return 'succeeded';
}
export function resolvePluginPackageWorkflowFrontier(
value: PluginPackageWorkflowFrontierSnapshot,
): Readonly<PluginPackageWorkflowFrontierResolution> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return invalid('snapshot is invalid');
}
const plan = normalizePluginPackageWorkflowExecutionPlan(value.plan);
validateRun(plan, value.run);
const observedAtMs = timestamp(value.observedAtMs);
const byKey = validateStepRuns(plan, value.stepRuns);
if (
[...byKey.values()].some(
(stepRun) => observedAtMs < stepRun.updatedAtMs,
)
) {
return invalid('observation time precedes StepRun state');
}
const blockedMemo = new Map<string, boolean>();
const projectedStatuses = new Map(
[...byKey].map(([stepKey, stepRun]) => [stepKey, stepRun.status]),
);
const mutations: Readonly<StepRunMutation>[] = [];
let runVersion = value.run.version;
let runEventSequence = value.run.eventSequence;
for (const step of plan.steps) {
const stepRun = byKey.get(step.stepKey)!;
if (stepRun.status !== 'pending') continue;
const blocked = dependencyCannotSucceed(
step.stepKey,
plan,
byKey,
blockedMemo,
new Set(),
);
const allDependenciesSucceeded = step.needs.every(
(dependency) => byKey.get(dependency)!.status === 'succeeded',
);
if (!blocked && !allDependenciesSucceeded) continue;
const target = blocked ? 'skipped' : 'ready';
const identity = frontierIdentity(plan.planDigest, stepRun, target);
const mutation = transitionStepRunMutation(
stepRun,
{
expectedVersion: stepRun.version,
expectedDigest: stepRun.stepRunDigest,
mutationId: identity.mutationId,
to: target,
atMs: observedAtMs,
...(target === 'skipped'
? { resultCode: 'dependency_not_succeeded' }
: {}),
},
{
expectedRunVersion: runVersion,
expectedRunEventSequence: runEventSequence,
eventId: identity.eventId,
dedupeKey: identity.eventId,
actor: { type: 'reconciler' },
},
);
mutations.push(mutation);
projectedStatuses.set(step.stepKey, target);
runVersion += 1;
runEventSequence += 1;
}
const readyStepRunIds = plan.steps
.filter((step) => projectedStatuses.get(step.stepKey) === 'ready')
.map((step) => step.stepRunId);
const aggregateStatus = terminalStatus(
plan.steps.map((step) => projectedStatuses.get(step.stepKey)!),
);
const terminalTransition =
aggregateStatus === null
? null
: (() => {
const eventId = terminalIdentity(
plan.planDigest,
runVersion,
aggregateStatus,
);
return Object.freeze({
expectedRunVersion: runVersion,
expectedRunEventSequence: runEventSequence,
status: aggregateStatus,
finishedAtMs: observedAtMs,
errorCode: terminalErrorCode(aggregateStatus),
event: Object.freeze({
id: eventId,
runId: plan.runId,
sequence: runEventSequence + 1,
type: `workflow.${aggregateStatus}`,
dedupeKey: eventId,
actorType: 'reconciler',
payload: Object.freeze({
planDigest: plan.planDigest,
workflowId: plan.target.workflowId,
stepCount: plan.steps.length,
status: aggregateStatus,
}),
createdAtMs: observedAtMs,
}),
} satisfies PluginPackageWorkflowTerminalTransition);
})();
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_FRONTIER_SCHEMA,
runId: plan.runId,
planDigest: plan.planDigest,
expectedRunVersion: value.run.version,
expectedRunEventSequence: value.run.eventSequence,
stepMutations: Object.freeze(mutations),
readyStepRunIds: Object.freeze(readyStepRunIds),
terminalStatus: aggregateStatus,
terminalTransition,
observedAtMs,
});
}
@@ -0,0 +1,688 @@
import { createHash } from 'node:crypto';
import {
normalizePluginPackageTaskReconciliationReceipt,
type PluginPackageTaskReconciliationReceipt,
} from '../pluginPackageTaskReconciliation';
import {
normalizePluginPackageWorkflowExecutionPlan,
type PluginPackageWorkflowExecutionPlan,
} from './pluginPackageWorkflowExecutionPlan';
import {
normalizeClusterTaskExecutionRevision,
type ClusterTaskExecutionRevision,
} from '../../task-definition/clusterExecutionRevision';
import {
normalizeLocalTaskExecutionRevision,
type LocalTaskExecutionRevision,
} from '../../local-runtime/localDispatch';
import type { RunAttemptRecord, RunEventRecord, RunRecord } from '../../run/run';
import {
MAX_STEP_RUN_ATTEMPTS,
normalizeStepRunRecord,
type StepRunRecord,
} from '../../run/stepRun';
export const PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_SCHEMA =
'qinglong/plugin-package-workflow-task-attempt-admission@v1' as const;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_PAGE_SIZE = 64;
export const MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_ATTEMPTS =
128 * MAX_STEP_RUN_ATTEMPTS;
export type PluginPackageWorkflowTaskExecutorType =
| 'local_process'
| 'remote_worker';
export interface PluginPackageWorkflowTaskExecutionBinding {
readonly projectId: string;
readonly taskId: string;
readonly taskRevision: string;
readonly taskDefinitionDigest: string;
readonly executorType: PluginPackageWorkflowTaskExecutorType;
readonly executionDigest: string;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionReceipt {
readonly schema:
typeof PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_SCHEMA;
readonly attemptId: string;
readonly planDigest: string;
readonly runId: string;
readonly stepRunId: string;
readonly stepRunVersion: number;
readonly stepRunDigest: string;
readonly resourceTaskId: string;
readonly taskReconciliationReceiptDigest: string;
readonly taskId: string;
readonly taskRevision: string;
readonly taskDefinitionDigest: string;
readonly executorType: PluginPackageWorkflowTaskExecutorType;
readonly executionDigest: string;
readonly attemptNumber: number;
readonly eventId: string;
readonly runVersion: number;
readonly runEventSequence: number;
readonly admittedAtMs: number;
readonly receiptDigest: string;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionBundle {
readonly receipt: Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
readonly attempt: Readonly<RunAttemptRecord>;
readonly event: Readonly<RunEventRecord>;
readonly run: Readonly<RunRecord>;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionCursor {
readonly readyAtMs: number;
readonly stepRunId: string;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionCandidate
extends PluginPackageWorkflowTaskAttemptAdmissionCursor {
readonly runId: string;
readonly planDigest: string;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionPage {
readonly candidates: readonly Readonly<PluginPackageWorkflowTaskAttemptAdmissionCandidate>[];
readonly truncated: boolean;
readonly next?: Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionResult {
readonly status: 'created' | 'existing';
readonly receipt: Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
}
export interface PluginPackageWorkflowTaskAttemptAdmissionRepository {
listCandidates(query: Readonly<{
limit: number;
after?: Readonly<PluginPackageWorkflowTaskAttemptAdmissionCursor>;
}>): Promise<
Readonly<PluginPackageWorkflowTaskAttemptAdmissionPage>
>;
admit(
runId: string,
stepRunId: string,
): Promise<
Readonly<PluginPackageWorkflowTaskAttemptAdmissionResult>
>;
}
export interface CreatePluginPackageWorkflowTaskAttemptAdmissionInput {
readonly plan: Readonly<PluginPackageWorkflowExecutionPlan>;
readonly run: Readonly<RunRecord>;
readonly stepRun: Readonly<StepRunRecord>;
readonly taskReconciliation:
Readonly<PluginPackageTaskReconciliationReceipt>;
readonly execution: Readonly<
LocalTaskExecutionRevision | ClusterTaskExecutionRevision
>;
readonly attemptNumber: number;
readonly admittedAtMs: number;
}
export class InvalidPluginPackageWorkflowTaskAttemptAdmissionError
extends TypeError
{
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_INVALID';
constructor(message: string) {
super(
`Plugin Package Workflow Task Attempt admission is invalid: ${message}`,
);
this.name =
'InvalidPluginPackageWorkflowTaskAttemptAdmissionError';
}
}
export class PluginPackageWorkflowTaskAttemptAdmissionConflictError
extends Error
{
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_CONFLICT';
constructor() {
super('Plugin Package Workflow Task Attempt admission conflicts with state');
this.name =
'PluginPackageWorkflowTaskAttemptAdmissionConflictError';
}
}
export class PluginPackageWorkflowTaskAttemptAdmissionUnavailableError
extends Error
{
readonly code =
'PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Plugin Package Workflow Task Attempt admission is unavailable', {
cause: options?.cause,
});
this.name =
'PluginPackageWorkflowTaskAttemptAdmissionUnavailableError';
}
}
const DIGEST = /^[0-9a-f]{64}$/;
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TASK_REVISION = /^qltd:v1:([1-9][0-9]{0,9}):([0-9a-f]{64})$/;
const ACTIVE_RECONCILIATION_DISPOSITIONS = new Set([
'created',
'retained',
'updated',
]);
const ID_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-task-attempt-id@v1\0',
'utf8',
);
const RECEIPT_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-task-attempt-receipt@v1\0',
'utf8',
);
function invalid(message: string): never {
throw new InvalidPluginPackageWorkflowTaskAttemptAdmissionError(message);
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype
) {
return invalid(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
required: readonly string[],
optional: readonly string[],
label: string,
): void {
const keys = Reflect.ownKeys(value);
const allowed = new Set([...required, ...optional]);
if (
keys.some((key) => typeof key !== 'string') ||
required.some((key) => !keys.includes(key)) ||
keys.some((key) => typeof key === 'string' && !allowed.has(key))
) {
invalid(`${label} shape is invalid`);
}
}
function identity(value: unknown, label: string): string {
if (typeof value !== 'string' || !IDENTITY.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function digest(value: unknown, label: string): string {
if (typeof value !== 'string' || !DIGEST.test(value)) {
return invalid(`${label} is invalid`);
}
return value;
}
function integer(
value: unknown,
minimum: number,
maximum: number,
label: string,
): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function timestamp(value: unknown, label: string): number {
return integer(value, 0, Number.MAX_SAFE_INTEGER, label);
}
function taskIdentity(packageName: string, resourceTaskId: string): string {
return identity(
`pkg:${packageName}:${resourceTaskId}`,
'runtime Task identity',
);
}
function executionBinding(
value: Readonly<
LocalTaskExecutionRevision | ClusterTaskExecutionRevision
>,
): Readonly<PluginPackageWorkflowTaskExecutionBinding> {
let normalized:
| Readonly<LocalTaskExecutionRevision>
| Readonly<ClusterTaskExecutionRevision>;
try {
normalized =
value?.executorType === 'local_process'
? normalizeLocalTaskExecutionRevision(
value as LocalTaskExecutionRevision,
)
: value?.executorType === 'remote_worker'
? normalizeClusterTaskExecutionRevision(
value as ClusterTaskExecutionRevision,
)
: invalid('execution revision executorType is invalid');
} catch (error) {
if (
error instanceof
InvalidPluginPackageWorkflowTaskAttemptAdmissionError
) {
throw error;
}
return invalid('execution revision is invalid');
}
const taskRevision = identity(
normalized.taskRevision,
'execution revision taskRevision',
);
const revisionMatch = TASK_REVISION.exec(taskRevision);
if (!revisionMatch) {
invalid('execution revision Task identity is inconsistent');
}
const taskDefinitionDigest = revisionMatch[2]!;
return Object.freeze({
projectId: identity(normalized.projectId, 'execution revision projectId'),
taskId: identity(normalized.taskId, 'execution revision taskId'),
taskRevision,
taskDefinitionDigest,
executorType: normalized.executorType,
executionDigest: digest(
normalized.contentDigest,
'execution revision contentDigest',
),
});
}
function admissionIdentity(
planDigest: string,
stepRunId: string,
stepRunVersion: number,
): Readonly<{ attemptId: string; eventId: string }> {
const value = createHash('sha256')
.update(ID_DOMAIN)
.update(planDigest, 'utf8')
.update('\0', 'utf8')
.update(stepRunId, 'utf8')
.update('\0', 'utf8')
.update(String(stepRunVersion), 'utf8')
.digest('hex');
return Object.freeze({
attemptId: `wta:${value.slice(0, 32)}`,
eventId: `wte:${value.slice(0, 32)}`,
});
}
function unsignedReceipt(
value: Omit<
PluginPackageWorkflowTaskAttemptAdmissionReceipt,
'receiptDigest'
>,
): object {
return {
schema: value.schema,
attemptId: value.attemptId,
planDigest: value.planDigest,
runId: value.runId,
stepRunId: value.stepRunId,
stepRunVersion: value.stepRunVersion,
stepRunDigest: value.stepRunDigest,
resourceTaskId: value.resourceTaskId,
taskReconciliationReceiptDigest:
value.taskReconciliationReceiptDigest,
taskId: value.taskId,
taskRevision: value.taskRevision,
taskDefinitionDigest: value.taskDefinitionDigest,
executorType: value.executorType,
executionDigest: value.executionDigest,
attemptNumber: value.attemptNumber,
eventId: value.eventId,
runVersion: value.runVersion,
runEventSequence: value.runEventSequence,
admittedAtMs: value.admittedAtMs,
};
}
export function pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest(
value: Omit<
PluginPackageWorkflowTaskAttemptAdmissionReceipt,
'receiptDigest'
>,
): string {
return createHash('sha256')
.update(RECEIPT_DOMAIN)
.update(JSON.stringify(unsignedReceipt(value)), 'utf8')
.digest('hex');
}
export function normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
value: PluginPackageWorkflowTaskAttemptAdmissionReceipt,
): Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt> {
const receipt = dataRecord(value, 'admission receipt');
exactKeys(
receipt,
[
'admittedAtMs',
'attemptId',
'attemptNumber',
'eventId',
'executionDigest',
'executorType',
'planDigest',
'receiptDigest',
'resourceTaskId',
'runEventSequence',
'runId',
'runVersion',
'schema',
'stepRunDigest',
'stepRunId',
'stepRunVersion',
'taskDefinitionDigest',
'taskId',
'taskReconciliationReceiptDigest',
'taskRevision',
],
[],
'admission receipt',
);
if (
value.schema !==
PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_SCHEMA
) {
invalid('admission receipt schema is invalid');
}
const executorType = value.executorType;
if (executorType !== 'local_process' && executorType !== 'remote_worker') {
invalid('admission receipt executorType is invalid');
}
const taskDefinitionDigest = digest(
value.taskDefinitionDigest,
'admission receipt taskDefinitionDigest',
);
const taskRevision = identity(
value.taskRevision,
'admission receipt taskRevision',
);
const revisionMatch = TASK_REVISION.exec(taskRevision);
if (!revisionMatch || revisionMatch[2] !== taskDefinitionDigest) {
invalid('admission receipt Task revision is inconsistent');
}
const runVersion = integer(
value.runVersion,
1,
2_147_483_647,
'admission receipt Run version',
);
const runEventSequence = integer(
value.runEventSequence,
1,
2_147_483_647,
'admission receipt Run event sequence',
);
if (runVersion !== runEventSequence) {
invalid('admission receipt Run counters are inconsistent');
}
const unsigned = Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_SCHEMA,
attemptId: identity(value.attemptId, 'admission receipt attemptId'),
planDigest: digest(value.planDigest, 'admission receipt planDigest'),
runId: identity(value.runId, 'admission receipt runId'),
stepRunId: identity(value.stepRunId, 'admission receipt stepRunId'),
stepRunVersion: integer(
value.stepRunVersion,
1,
2_147_483_647,
'admission receipt StepRun version',
),
stepRunDigest: digest(
value.stepRunDigest,
'admission receipt StepRun digest',
),
resourceTaskId: identity(
value.resourceTaskId,
'admission receipt resourceTaskId',
),
taskReconciliationReceiptDigest: digest(
value.taskReconciliationReceiptDigest,
'admission receipt Task reconciliation digest',
),
taskId: identity(value.taskId, 'admission receipt taskId'),
taskRevision,
taskDefinitionDigest,
executorType,
executionDigest: digest(
value.executionDigest,
'admission receipt executionDigest',
),
attemptNumber: integer(
value.attemptNumber,
1,
MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_ATTEMPTS,
'admission receipt attemptNumber',
),
eventId: identity(value.eventId, 'admission receipt eventId'),
runVersion,
runEventSequence,
admittedAtMs: timestamp(
value.admittedAtMs,
'admission receipt admittedAtMs',
),
});
const receiptDigest = digest(
value.receiptDigest,
'admission receipt receiptDigest',
);
if (
pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest(unsigned) !==
receiptDigest
) {
invalid('admission receipt digest does not match');
}
return Object.freeze({ ...unsigned, receiptDigest });
}
export function createPluginPackageWorkflowTaskAttemptAdmission(
value: CreatePluginPackageWorkflowTaskAttemptAdmissionInput,
): Readonly<PluginPackageWorkflowTaskAttemptAdmissionBundle> {
const input = dataRecord(value, 'admission input');
exactKeys(
input,
[
'admittedAtMs',
'attemptNumber',
'execution',
'plan',
'run',
'stepRun',
'taskReconciliation',
],
[],
'admission input',
);
let plan: Readonly<PluginPackageWorkflowExecutionPlan>;
let stepRun: Readonly<StepRunRecord>;
let taskReconciliation:
Readonly<PluginPackageTaskReconciliationReceipt>;
try {
plan = normalizePluginPackageWorkflowExecutionPlan(value.plan);
stepRun = normalizeStepRunRecord(value.stepRun);
taskReconciliation =
normalizePluginPackageTaskReconciliationReceipt(
value.taskReconciliation,
);
} catch {
return invalid('durable Workflow evidence is invalid');
}
const execution = executionBinding(value.execution);
const attemptNumber = integer(
value.attemptNumber,
1,
MAX_PLUGIN_PACKAGE_WORKFLOW_RUN_ATTEMPTS,
'attemptNumber',
);
const admittedAtMs = timestamp(value.admittedAtMs, 'admittedAtMs');
const run = value.run;
if (
!run ||
typeof run !== 'object' ||
Array.isArray(run) ||
run.id !== plan.runId ||
run.projectId !== plan.target.projectId ||
run.taskId !== plan.target.workflowId ||
run.taskRevision !== plan.target.publicationDigest ||
run.triggerType !== 'plugin_package_workflow' ||
run.executionOrigin !== 'system' ||
run.executionOwner !== 'runtime' ||
run.requestId !== plan.planId ||
run.idempotencyKey !== `plugin-package-workflow:${plan.planId}` ||
run.status !== 'running' ||
run.cancelRequestedAtMs !== undefined ||
!Number.isSafeInteger(run.version) ||
run.version < 1 ||
run.version !== run.eventSequence
) {
invalid('Run does not match the admitted Workflow aggregate');
}
const planStep = plan.steps.find(
({ stepRunId }) => stepRunId === stepRun.id,
);
if (
!planStep ||
stepRun.runId !== run.id ||
stepRun.stepKey !== planStep.stepKey ||
stepRun.kind !== 'task' ||
stepRun.definitionRef !== planStep.taskDefinitionRef ||
stepRun.definitionDigest !== planStep.taskDefinitionDigest ||
stepRun.required !== planStep.required ||
stepRun.status !== 'ready' ||
stepRun.attemptCount >= MAX_STEP_RUN_ATTEMPTS
) {
invalid('StepRun is not one exact ready Task from the plan');
}
if (
taskReconciliation.projectId !== plan.target.projectId ||
taskReconciliation.packageName !== plan.target.packageName ||
taskReconciliation.generation !== plan.target.generation ||
taskReconciliation.generationDigest !== plan.target.generationDigest ||
taskReconciliation.materializedRevisionDigest !==
plan.target.materializedRevisionDigest ||
taskReconciliation.lockDigest !== plan.target.lockDigest
) {
invalid('Task reconciliation does not match the immutable plan');
}
const expectedTaskId = taskIdentity(
plan.target.packageName,
planStep.taskId,
);
const item = taskReconciliation.items.find(
({ taskId }) => taskId === expectedTaskId,
);
if (
!item ||
!ACTIVE_RECONCILIATION_DISPOSITIONS.has(item.disposition) ||
execution.projectId !== plan.target.projectId ||
execution.taskId !== item.taskId ||
execution.taskDefinitionDigest !== item.contentDigest ||
execution.taskRevision !==
`qltd:v1:${item.revision}:${item.contentDigest}`
) {
invalid('execution binding is not the reconciled Task revision');
}
if (
admittedAtMs < plan.plannedAtMs ||
admittedAtMs < taskReconciliation.committedAtMs ||
admittedAtMs < stepRun.updatedAtMs
) {
invalid('admission time precedes durable evidence');
}
if (
run.version >= 2_147_483_647 ||
run.eventSequence >= 2_147_483_647
) {
invalid('Run counters overflowed');
}
const ids = admissionIdentity(
plan.planDigest,
stepRun.id,
stepRun.version,
);
const nextRun = Object.freeze({
...run,
version: run.version + 1,
eventSequence: run.eventSequence + 1,
});
const attempt = Object.freeze({
id: ids.attemptId,
runId: run.id,
stepRunId: stepRun.id,
attempt: attemptNumber,
status: 'claimed' as const,
executorType: execution.executorType,
callbackSequence: 0,
createdAtMs: admittedAtMs,
} satisfies RunAttemptRecord);
const event = Object.freeze({
id: ids.eventId,
runId: run.id,
sequence: nextRun.eventSequence,
type: 'workflow.task_attempt_admitted',
dedupeKey: ids.eventId,
actorType: 'system' as const,
attemptId: attempt.id,
stepRunId: stepRun.id,
payload: Object.freeze({
planDigest: plan.planDigest,
stepRunId: stepRun.id,
stepRunVersion: stepRun.version,
resourceTaskId: planStep.taskId,
taskId: execution.taskId,
taskRevision: execution.taskRevision,
executorType: execution.executorType,
executionDigest: execution.executionDigest,
attemptNumber,
}),
createdAtMs: admittedAtMs,
} satisfies RunEventRecord);
const receiptUnsigned = Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_TASK_ATTEMPT_ADMISSION_SCHEMA,
attemptId: attempt.id,
planDigest: plan.planDigest,
runId: run.id,
stepRunId: stepRun.id,
stepRunVersion: stepRun.version,
stepRunDigest: stepRun.stepRunDigest,
resourceTaskId: planStep.taskId,
taskReconciliationReceiptDigest: taskReconciliation.receiptDigest,
taskId: execution.taskId,
taskRevision: execution.taskRevision,
taskDefinitionDigest: execution.taskDefinitionDigest,
executorType: execution.executorType,
executionDigest: execution.executionDigest,
attemptNumber,
eventId: event.id,
runVersion: nextRun.version,
runEventSequence: nextRun.eventSequence,
admittedAtMs,
});
const receipt =
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt({
...receiptUnsigned,
receiptDigest:
pluginPackageWorkflowTaskAttemptAdmissionReceiptDigest(
receiptUnsigned,
),
});
return Object.freeze({ receipt, attempt, event, run: nextRun });
}
@@ -0,0 +1,391 @@
import { createHash } from 'node:crypto';
import {
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt,
type PluginPackageWorkflowTaskAttemptAdmissionReceipt,
} from './pluginPackageWorkflowTaskAttemptAdmission';
import type { RunAttemptRecord, RunEventRecord, RunRecord } from '../../run/run';
import {
normalizeStepRunRecord,
transitionStepRunMutation,
type StepRunMutation,
type StepRunRecord,
} from '../../run/stepRun';
export const PLUGIN_PACKAGE_WORKFLOW_TASK_RECOVERY_SCHEMA =
'qinglong/plugin-package-workflow-task-recovery@v1' as const;
export type PluginPackageWorkflowTaskRecoveryReason =
| 'unstarted_claim_expired'
| 'execution_not_running';
export interface PluginPackageWorkflowTaskRecoveryInput {
readonly admission:
Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
readonly run: Readonly<RunRecord>;
readonly attempt: Readonly<RunAttemptRecord>;
readonly stepRun: Readonly<StepRunRecord>;
readonly reason: PluginPackageWorkflowTaskRecoveryReason;
readonly observedAtMs: number;
}
export interface PluginPackageWorkflowTaskRecoveryBundle {
readonly schema: typeof PLUGIN_PACKAGE_WORKFLOW_TASK_RECOVERY_SCHEMA;
readonly disposition: 'requeued' | 'failed';
readonly run: Readonly<RunRecord>;
readonly attempt: Readonly<RunAttemptRecord>;
readonly attemptEvent: Readonly<RunEventRecord>;
readonly stepMutations: readonly Readonly<StepRunMutation>[];
}
export class InvalidPluginPackageWorkflowTaskRecoveryError
extends TypeError
{
readonly code = 'PLUGIN_PACKAGE_WORKFLOW_TASK_RECOVERY_INVALID';
constructor(message: string) {
super(`Plugin Package Workflow Task recovery is invalid: ${message}`);
this.name = 'InvalidPluginPackageWorkflowTaskRecoveryError';
}
}
const ACTIVE_ATTEMPT_STATUSES = new Set(['claimed', 'starting', 'running']);
const ID_DOMAIN = Buffer.from(
'qinglong/plugin-package-workflow-task-recovery-id@v1\0',
'utf8',
);
const LOST_ERROR_CODE = Object.freeze({
unstarted_claim_expired:
'CLUSTER_RECOVERY_UNSTARTED_CLAIM_EXPIRED',
execution_not_running:
'CLUSTER_RECOVERY_EXECUTION_NOT_RUNNING',
});
const LOST_ERROR_SUMMARY = Object.freeze({
unstarted_claim_expired:
'The Workflow Task Attempt claim expired before execution started',
execution_not_running:
'Trusted execution evidence proved that the Workflow Task Attempt is not running',
});
function invalid(message: string): never {
throw new InvalidPluginPackageWorkflowTaskRecoveryError(message);
}
function safeInteger(
value: unknown,
minimum: number,
maximum: number,
label: string,
): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < minimum ||
(value as number) > maximum
) {
return invalid(`${label} is invalid`);
}
return value as number;
}
function identity(
attempt: Readonly<RunAttemptRecord>,
stepRun: Readonly<StepRunRecord>,
reason: PluginPackageWorkflowTaskRecoveryReason,
): Readonly<{
attemptEventId: string;
requeueMutationId: string;
requeueEventId: string;
lostMutationId: string;
lostEventId: string;
failedMutationId: string;
failedEventId: string;
}> {
const digest = createHash('sha256')
.update(ID_DOMAIN)
.update(attempt.runId, 'utf8')
.update('\0', 'utf8')
.update(attempt.id, 'utf8')
.update('\0', 'utf8')
.update(String(attempt.callbackSequence), 'utf8')
.update('\0', 'utf8')
.update(stepRun.id, 'utf8')
.update('\0', 'utf8')
.update(String(stepRun.version), 'utf8')
.update('\0', 'utf8')
.update(reason, 'utf8')
.digest('hex')
.slice(0, 32);
return Object.freeze({
attemptEventId: `wra:${digest}`,
requeueMutationId: `wrq:${digest}`,
requeueEventId: `wqe:${digest}`,
lostMutationId: `wrl:${digest}`,
lostEventId: `wle:${digest}`,
failedMutationId: `wrf:${digest}`,
failedEventId: `wfe:${digest}`,
});
}
function validateAuthority(
input: Readonly<PluginPackageWorkflowTaskRecoveryInput>,
admission: Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>,
stepRun: Readonly<StepRunRecord>,
): void {
const { run, attempt, reason } = input;
if (
!run ||
typeof run !== 'object' ||
Array.isArray(run) ||
run.id !== admission.runId ||
run.triggerType !== 'plugin_package_workflow' ||
run.executionOrigin !== 'system' ||
run.executionOwner !== 'runtime' ||
run.status !== 'running' ||
run.cancelRequestedAtMs !== undefined
) {
invalid('Run is not an active runtime-owned Workflow aggregate');
}
const runVersion = safeInteger(
run.version,
1,
2_147_483_644,
'Run version',
);
const runEventSequence = safeInteger(
run.eventSequence,
1,
2_147_483_644,
'Run event sequence',
);
if (
runVersion !== runEventSequence ||
admission.runVersion > runVersion ||
admission.runEventSequence > runEventSequence
) {
invalid('Run aggregate counters do not contain the admission event');
}
if (
!attempt ||
typeof attempt !== 'object' ||
Array.isArray(attempt) ||
attempt.id !== admission.attemptId ||
attempt.runId !== run.id ||
attempt.stepRunId !== admission.stepRunId ||
attempt.attempt !== admission.attemptNumber ||
attempt.executorType !== admission.executorType ||
attempt.createdAtMs !== admission.admittedAtMs ||
!ACTIVE_ATTEMPT_STATUSES.has(attempt.status)
) {
invalid('Attempt is not the active admission-bound Workflow Task Attempt');
}
safeInteger(
attempt.callbackSequence,
0,
2_147_483_647,
'Attempt callback sequence',
);
if (
stepRun.id !== admission.stepRunId ||
stepRun.runId !== run.id ||
stepRun.kind !== 'task'
) {
invalid('StepRun is not the admitted Workflow Task');
}
if (reason === 'unstarted_claim_expired') {
if (
attempt.status !== 'claimed' ||
attempt.startedAtMs !== undefined ||
stepRun.status !== 'ready' ||
stepRun.version !== admission.stepRunVersion ||
stepRun.stepRunDigest !== admission.stepRunDigest
) {
invalid('unstarted recovery crossed the Workflow Task start barrier');
}
} else if (attempt.status === 'starting') {
if (
stepRun.status !== 'ready' ||
stepRun.version !== admission.stepRunVersion ||
stepRun.stepRunDigest !== admission.stepRunDigest
) {
invalid('starting recovery does not match the admitted StepRun epoch');
}
} else if (
attempt.status !== 'running' ||
stepRun.status !== 'running' ||
stepRun.version !== admission.stepRunVersion + 1 ||
stepRun.startedAtMs === null
) {
invalid('running recovery does not match the canonical StepRun');
}
}
function transitionTime(
observedAtMs: number,
run: Readonly<RunRecord>,
attempt: Readonly<RunAttemptRecord>,
stepRun: Readonly<StepRunRecord>,
): number {
const atMs = safeInteger(
observedAtMs,
0,
Number.MAX_SAFE_INTEGER,
'recovery observation time',
);
const lowerBound = Math.max(
run.createdAtMs,
run.startedAtMs ?? 0,
attempt.createdAtMs,
attempt.startedAtMs ?? 0,
attempt.finishedAtMs ?? 0,
stepRun.createdAtMs,
stepRun.updatedAtMs,
stepRun.startedAtMs ?? 0,
stepRun.finishedAtMs ?? 0,
);
if (atMs < lowerBound) {
invalid('recovery observation precedes durable state');
}
return atMs;
}
/**
* Resolves only one immutable, admission-bound Workflow Task Attempt.
*
* An expired pre-start claim is safe to requeue by refreshing the exact
* `ready` StepRun epoch. Once the start barrier has been crossed, v1 fails the
* StepRun after trusted absence evidence instead of silently duplicating an
* external side effect. The Workflow aggregate Run remains `running`; its
* frontier owns final aggregation.
*/
export function buildPluginPackageWorkflowTaskRecovery(
input: Readonly<PluginPackageWorkflowTaskRecoveryInput>,
): Readonly<PluginPackageWorkflowTaskRecoveryBundle> {
let admission:
Readonly<PluginPackageWorkflowTaskAttemptAdmissionReceipt>;
let stepRun: Readonly<StepRunRecord>;
try {
admission =
normalizePluginPackageWorkflowTaskAttemptAdmissionReceipt(
input.admission,
);
stepRun = normalizeStepRunRecord(input.stepRun);
} catch {
return invalid('durable admission or StepRun evidence is invalid');
}
if (
input.reason !== 'unstarted_claim_expired' &&
input.reason !== 'execution_not_running'
) {
invalid('recovery reason is invalid');
}
validateAuthority(input, admission, stepRun);
const atMs = transitionTime(
input.observedAtMs,
input.run,
input.attempt,
stepRun,
);
const ids = identity(input.attempt, stepRun, input.reason);
const errorCode = LOST_ERROR_CODE[input.reason];
const errorSummary = LOST_ERROR_SUMMARY[input.reason];
let runVersion = input.run.version + 1;
let runEventSequence = input.run.eventSequence + 1;
const attempt = Object.freeze({
...input.attempt,
status: 'lost' as const,
finishedAtMs: atMs,
errorCode,
errorSummary,
});
const attemptEvent = Object.freeze({
id: ids.attemptEventId,
runId: input.run.id,
sequence: runEventSequence,
type: 'workflow.task_attempt.lost',
dedupeKey: ids.attemptEventId,
actorType: 'system' as const,
attemptId: attempt.id,
stepRunId: stepRun.id,
payload: Object.freeze({
execution_scope: 'workflow_task',
attempt_id: attempt.id,
step_run_id: stepRun.id,
from_status: input.attempt.status,
to_status: 'lost',
reason: input.reason,
error_code: errorCode,
version: runVersion,
}),
createdAtMs: atMs,
} satisfies RunEventRecord);
const stepMutations: Readonly<StepRunMutation>[] = [];
const transition = (
mutationId: string,
eventId: string,
to: 'ready' | 'lost' | 'failed',
): void => {
const mutation = transitionStepRunMutation(
stepRun,
{
expectedVersion: stepRun.version,
expectedDigest: stepRun.stepRunDigest,
mutationId,
to,
atMs,
...(to === 'ready'
? {}
: {
resultCode: 'cluster_recovery_execution_not_running',
errorSummary,
}),
},
{
expectedRunVersion: runVersion,
expectedRunEventSequence: runEventSequence,
eventId,
dedupeKey: eventId,
actor: { type: 'system' },
},
);
stepMutations.push(mutation);
stepRun = mutation.stepRun;
runVersion += 1;
runEventSequence += 1;
};
if (input.reason === 'unstarted_claim_expired') {
transition(
ids.requeueMutationId,
ids.requeueEventId,
'ready',
);
} else if (input.attempt.status === 'starting') {
transition(
ids.failedMutationId,
ids.failedEventId,
'failed',
);
} else {
transition(ids.lostMutationId, ids.lostEventId, 'lost');
transition(
ids.failedMutationId,
ids.failedEventId,
'failed',
);
}
return Object.freeze({
schema: PLUGIN_PACKAGE_WORKFLOW_TASK_RECOVERY_SCHEMA,
disposition:
input.reason === 'unstarted_claim_expired' ? 'requeued' : 'failed',
run: Object.freeze({
...input.run,
version: runVersion,
eventSequence: runEventSequence,
}),
attempt,
attemptEvent,
stepMutations: Object.freeze(stepMutations),
});
}