mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+265
@@ -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>>;
|
||||
}
|
||||
+41
@@ -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';
|
||||
}
|
||||
}
|
||||
+206
@@ -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!,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
+254
@@ -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,
|
||||
});
|
||||
}
|
||||
+225
@@ -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),
|
||||
});
|
||||
}
|
||||
+282
@@ -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,
|
||||
});
|
||||
}
|
||||
+339
@@ -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,
|
||||
});
|
||||
}
|
||||
+86
@@ -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;
|
||||
}
|
||||
+7
@@ -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';
|
||||
+547
@@ -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,
|
||||
});
|
||||
}
|
||||
+963
@@ -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,
|
||||
});
|
||||
}
|
||||
+430
@@ -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,
|
||||
});
|
||||
}
|
||||
+688
@@ -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 });
|
||||
}
|
||||
+391
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user