import { createHash } from 'node:crypto'; import { InvalidPluginPackageResourceMaterializationError, PluginPackageResourceMaterializationConflictError, PluginPackageResourceMaterializationUnavailableError, normalizePluginPackageMaterializedRevision, normalizePluginPackagePromptResource, normalizePluginPackageWorkflowResource, type PluginPackageMaterializedRevision, type PluginPackageMaterializedRevisionRepository, type PluginPackagePromptResource, type PluginPackageWorkflowResource, } from './pluginPackageResourceMaterialization'; import type { PluginPackageResourceGenerationSource } from './pluginPackageResourceGeneration'; import { TaskSpecSemanticRegistry } from '../task-definition/taskSpecSemantic'; export const PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA = 'qinglong/plugin-package-automation-publication@v1' as const; export const PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_STATES = [ 'active', 'withdrawn', 'absent', ] as const; export const MAX_PLUGIN_PACKAGE_AUTOMATION_WORKFLOWS = 128; export const MAX_PLUGIN_PACKAGE_AUTOMATION_PROMPTS = 128; export const MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES = 12 * 1024 * 1024; export type PluginPackageAutomationPublicationState = (typeof PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_STATES)[number]; export interface PluginPackageAutomationPublicationTarget { readonly projectId: string; readonly packageName: string; readonly installationId: string; readonly lockDigest: string; readonly generation: number; readonly generationDigest: string; readonly materializedRevisionDigest: string; } export interface PluginPackageAutomationDefinitions { readonly workflows: readonly Readonly[]; readonly prompts: readonly Readonly[]; } export interface PluginPackageAutomationPublication { readonly schema: typeof PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA; readonly target: Readonly; readonly state: PluginPackageAutomationPublicationState; readonly version: number; readonly previousPublicationDigest: string | null; readonly lifecycleEventDigest: string | null; readonly definitions: Readonly; readonly publishedAtMs: number; readonly publicationDigest: string; } export interface PluginPackageAutomationPublicationRepository { findCurrent( projectId: string, packageName: string, ): Promise | null>; findByDigest( publicationDigest: string, ): Promise | null>; publish( publication: Readonly, ): Promise< Readonly<{ status: 'created' | 'existing'; publication: Readonly; }> >; } export interface PluginPackageAutomationPublicationStartGuard { isStartAllowed( projectId: string, packageName: string, publicationDigest: string, ): Promise; } export class InvalidPluginPackageAutomationPublicationError extends TypeError { readonly code = 'PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_INVALID'; constructor(message: string) { super(`Plugin Package automation publication is invalid: ${message}`); this.name = 'InvalidPluginPackageAutomationPublicationError'; } } export class PluginPackageAutomationPublicationConflictError extends Error { readonly code = 'PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_CONFLICT'; constructor(message: string) { super( `Plugin Package automation publication conflicts with state: ${message}`, ); this.name = 'PluginPackageAutomationPublicationConflictError'; } } export class PluginPackageAutomationPublicationUnavailableError extends Error { readonly code = 'PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_UNAVAILABLE'; constructor(options?: ErrorOptions) { super('Plugin Package automation publication is unavailable', options); this.name = 'PluginPackageAutomationPublicationUnavailableError'; } } 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 DIGEST = /^[0-9a-f]{64}$/; const PUBLICATION_DIGEST_DOMAIN = 'qinglong/plugin-package-automation-publication-digest@v1\0'; function invalid(message: string): never { throw new InvalidPluginPackageAutomationPublicationError(message); } function dataRecord(value: unknown, label: string): Record { 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; } 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 identifier(value: unknown, label: string): string { if (typeof value !== 'string' || !IDENTIFIER.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, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0) { return invalid(`${label} is invalid`); } return value as number; } function nullableDigest(value: unknown, label: string): string | null { return value === null ? null : digest(value, label); } function normalizeTarget( value: PluginPackageAutomationPublicationTarget, ): Readonly { const target = dataRecord(value, 'publication target'); exactKeys( target, [ 'generation', 'generationDigest', 'installationId', 'lockDigest', 'materializedRevisionDigest', 'packageName', 'projectId', ], 'publication 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', ), }); } function normalizeDenseArray( value: unknown, maximum: number, label: string, ): readonly unknown[] { if ( !Array.isArray(value) || value.length > maximum || Object.keys(value).some((key, index) => key !== String(index)) ) { return invalid(`${label} is invalid`); } return value; } function normalizeDefinitions( value: PluginPackageAutomationDefinitions, ): Readonly { const definitions = dataRecord(value, 'automation definitions'); exactKeys(definitions, ['prompts', 'workflows'], 'automation definitions'); const workflows = normalizeDenseArray( value.workflows, MAX_PLUGIN_PACKAGE_AUTOMATION_WORKFLOWS, 'workflows', ) .map(normalizePluginPackageWorkflowResource) .sort((left, right) => left.id.localeCompare(right.id)); const prompts = normalizeDenseArray( value.prompts, MAX_PLUGIN_PACKAGE_AUTOMATION_PROMPTS, 'prompts', ) .map(normalizePluginPackagePromptResource) .sort((left, right) => left.id.localeCompare(right.id)); if ( workflows.some( (workflow, index) => index > 0 && workflows[index - 1]!.id === workflow.id, ) || prompts.some( (prompt, index) => index > 0 && prompts[index - 1]!.id === prompt.id, ) ) { return invalid('automation definition identity is duplicated'); } return Object.freeze({ workflows: Object.freeze(workflows), prompts: Object.freeze(prompts), }); } function fields( value: Omit, ): object { return { schema: value.schema, target: value.target, state: value.state, version: value.version, previousPublicationDigest: value.previousPublicationDigest, lifecycleEventDigest: value.lifecycleEventDigest, definitions: value.definitions, publishedAtMs: value.publishedAtMs, }; } export function pluginPackageAutomationPublicationDigest( value: Omit, ): string { return createHash('sha256') .update(PUBLICATION_DIGEST_DOMAIN) .update(JSON.stringify(fields(value))) .digest('hex'); } function bounded( value: Readonly, ): Readonly { if ( Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_BYTES ) { return invalid('encoded publication exceeds the size limit'); } return value; } function normalizedWithoutDigest( value: Omit, ): Omit { if ( typeof value.state !== 'string' || !PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_STATES.includes( value.state as PluginPackageAutomationPublicationState, ) ) { return invalid('publication state is invalid'); } const version = positiveInteger(value.version, 'version'); const previousPublicationDigest = nullableDigest( value.previousPublicationDigest, 'previousPublicationDigest', ); const lifecycleEventDigest = nullableDigest( value.lifecycleEventDigest, 'lifecycleEventDigest', ); const definitions = normalizeDefinitions(value.definitions); const definitionCount = definitions.workflows.length + definitions.prompts.length; if ( (version === 1 && ((value.state !== 'active' && value.state !== 'absent') || previousPublicationDigest !== null || lifecycleEventDigest !== null)) || (version > 1 && previousPublicationDigest === null) || (value.state === 'absent' ? definitionCount !== 0 : definitionCount === 0) || (value.state === 'withdrawn' && lifecycleEventDigest === null) ) { return invalid('publication version chain is invalid'); } return Object.freeze({ schema: PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA, target: normalizeTarget(value.target), state: value.state as PluginPackageAutomationPublicationState, version, previousPublicationDigest, lifecycleEventDigest, definitions, publishedAtMs: timestamp(value.publishedAtMs, 'publishedAtMs'), }); } export function normalizePluginPackageAutomationPublication( value: PluginPackageAutomationPublication, ): Readonly { const publication = dataRecord(value, 'automation publication'); exactKeys( publication, [ 'definitions', 'lifecycleEventDigest', 'previousPublicationDigest', 'publicationDigest', 'publishedAtMs', 'schema', 'state', 'target', 'version', ], 'automation publication', ); if (value.schema !== PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA) { return invalid('publication schema is invalid'); } const unsigned = normalizedWithoutDigest(value); const publicationDigest = pluginPackageAutomationPublicationDigest(unsigned); if ( typeof value.publicationDigest !== 'string' || value.publicationDigest !== publicationDigest ) { return invalid('publicationDigest does not match publication'); } return bounded(Object.freeze({ ...unsigned, publicationDigest })); } export function pluginPackageAutomationDefinitionsFromRevision( revisionValue: PluginPackageMaterializedRevision, registry: TaskSpecSemanticRegistry, ): Readonly | null { if (!(registry instanceof TaskSpecSemanticRegistry)) { return invalid('TaskSpec semantic registry is invalid'); } const revision = normalizePluginPackageMaterializedRevision( revisionValue, registry, ); const workflows = revision.resources .filter(({ kind }) => kind === 'workflow') .map(({ value }) => normalizePluginPackageWorkflowResource(value), ) .sort((left, right) => left.id.localeCompare(right.id)); const prompts = revision.resources .filter(({ kind }) => kind === 'prompt') .map(({ value }) => normalizePluginPackagePromptResource(value)) .sort((left, right) => left.id.localeCompare(right.id)); if (workflows.length === 0 && prompts.length === 0) return null; return Object.freeze({ workflows: Object.freeze(workflows), prompts: Object.freeze(prompts), }); } export function createInitialPluginPackageAutomationPublication( revisionValue: PluginPackageMaterializedRevision, registry: TaskSpecSemanticRegistry, publishedAtMsValue: number, ): Readonly { const revision = normalizePluginPackageMaterializedRevision( revisionValue, registry, ); const definitions = pluginPackageAutomationDefinitionsFromRevision(revision, registry) ?? Object.freeze({ workflows: Object.freeze([]), prompts: Object.freeze([]), }); const state = definitions.workflows.length > 0 || definitions.prompts.length > 0 ? ('active' as const) : ('absent' as const); const unsigned = normalizedWithoutDigest({ schema: PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA, target: { projectId: revision.generation.projectId, packageName: revision.generation.packageName, installationId: revision.generation.installationId, lockDigest: revision.generation.lockDigest, generation: revision.generation.generation, generationDigest: revision.generation.generationDigest, materializedRevisionDigest: revision.revisionDigest, }, state, version: 1, previousPublicationDigest: null, lifecycleEventDigest: null, definitions, publishedAtMs: publishedAtMsValue, }); return bounded( Object.freeze({ ...unsigned, publicationDigest: pluginPackageAutomationPublicationDigest(unsigned), }), ); } export function assertPluginPackageAutomationPublicationSuccessor( previousValue: Readonly, nextValue: Readonly, ): void { const previous = normalizePluginPackageAutomationPublication(previousValue); const next = normalizePluginPackageAutomationPublication(nextValue); if ( next.previousPublicationDigest !== previous.publicationDigest || next.version !== previous.version + 1 || next.publishedAtMs < previous.publishedAtMs ) { invalid('publication successor fence is invalid'); } const samePackage = next.target.projectId === previous.target.projectId && next.target.packageName === previous.target.packageName; if (!samePackage) { invalid('publication successor changes Package ownership'); } if (next.lifecycleEventDigest === null) { if ( (next.state !== 'active' && next.state !== 'absent') || next.target.generation <= previous.target.generation || next.target.installationId === previous.target.installationId || next.target.lockDigest === previous.target.lockDigest || next.target.generationDigest === previous.target.generationDigest || next.target.materializedRevisionDigest === previous.target.materializedRevisionDigest ) { invalid('generation successor is invalid'); } return; } if ( JSON.stringify(next.target) !== JSON.stringify(previous.target) || JSON.stringify(next.definitions) !== JSON.stringify(previous.definitions) || !( (previous.state === 'active' && next.state === 'withdrawn') || (previous.state === 'withdrawn' && next.state === 'active') ) ) { invalid('lifecycle successor is invalid'); } } export function createNextPluginPackageAutomationPublication( revisionValue: PluginPackageMaterializedRevision, registry: TaskSpecSemanticRegistry, previousValue: Readonly, publishedAtMsValue: number, ): Readonly { const revision = normalizePluginPackageMaterializedRevision( revisionValue, registry, ); const previous = normalizePluginPackageAutomationPublication(previousValue); const definitions = pluginPackageAutomationDefinitionsFromRevision(revision, registry) ?? Object.freeze({ workflows: Object.freeze([]), prompts: Object.freeze([]), }); const state = definitions.workflows.length > 0 || definitions.prompts.length > 0 ? ('active' as const) : ('absent' as const); const unsigned = normalizedWithoutDigest({ schema: PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA, target: { projectId: revision.generation.projectId, packageName: revision.generation.packageName, installationId: revision.generation.installationId, lockDigest: revision.generation.lockDigest, generation: revision.generation.generation, generationDigest: revision.generation.generationDigest, materializedRevisionDigest: revision.revisionDigest, }, state, version: previous.version + 1, previousPublicationDigest: previous.publicationDigest, lifecycleEventDigest: null, definitions, publishedAtMs: publishedAtMsValue, }); const next = bounded( Object.freeze({ ...unsigned, publicationDigest: pluginPackageAutomationPublicationDigest(unsigned), }), ); assertPluginPackageAutomationPublicationSuccessor(previous, next); return next; } export function createPluginPackageAutomationLifecyclePublication(input: { readonly previous: Readonly; readonly state: PluginPackageAutomationPublicationState; readonly lifecycleEventDigest: string; readonly publishedAtMs: number; }): Readonly { const value = dataRecord(input, 'automation lifecycle publication input'); exactKeys( value, ['lifecycleEventDigest', 'previous', 'publishedAtMs', 'state'], 'automation lifecycle publication input', ); const previous = normalizePluginPackageAutomationPublication( input.previous, ); if ( previous.state === 'absent' || (previous.state === 'active' && input.state !== 'withdrawn') || (previous.state === 'withdrawn' && input.state !== 'active') ) { return invalid('lifecycle publication must toggle its state'); } const publishedAtMs = timestamp(input.publishedAtMs, 'publishedAtMs'); if (publishedAtMs < previous.publishedAtMs) { return invalid('publishedAtMs precedes the previous publication'); } const unsigned = normalizedWithoutDigest({ schema: PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_SCHEMA, target: previous.target, state: input.state, version: previous.version + 1, previousPublicationDigest: previous.publicationDigest, lifecycleEventDigest: digest( input.lifecycleEventDigest, 'lifecycleEventDigest', ), definitions: previous.definitions, publishedAtMs, }); const next = bounded( Object.freeze({ ...unsigned, publicationDigest: pluginPackageAutomationPublicationDigest(unsigned), }), ); assertPluginPackageAutomationPublicationSuccessor(previous, next); return next; } export const MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_RECOVERY_PAGE_SIZE = 64; export const MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_RECOVERY_PAGES = 64; export interface PluginPackageAutomationPublicationPendingCandidate { readonly projectId: string; readonly packageName: string; } export interface PluginPackageAutomationPublicationRecoveryCursor { readonly projectId: string; readonly packageName: string; } export interface PluginPackageAutomationPublicationRecoveryPage { readonly candidates: readonly Readonly[]; readonly truncated: boolean; readonly next?: Readonly; } export interface PluginPackageAutomationPublicationRecoverySource { listPendingPage(options: { readonly limit: number; readonly after?: Readonly; }): Promise>; } export type PluginPackageAutomationPublicationCurrentResult = Readonly<{ status: 'current'; publication: 'created' | 'existing'; generationDigest: string; record: Readonly; }>; export type PluginPackageAutomationPublicationResult = | PluginPackageAutomationPublicationCurrentResult | Readonly<{ status: 'absent' }> | Readonly<{ status: 'superseded'; generationDigest: string }>; export type PluginPackageAutomationPublicationRecoveryItemStatus = | 'manual_required' | 'retry' | 'settled' | 'superseded'; export interface PluginPackageAutomationPublicationRecoveryItem { readonly projectId: string; readonly packageName: string; readonly status: PluginPackageAutomationPublicationRecoveryItemStatus; } export interface PluginPackageAutomationPublicationRecoveryCycleResult { readonly pages: number; readonly scanned: number; readonly settled: number; readonly retry: number; readonly manualRequired: number; readonly superseded: number; readonly remaining: boolean; readonly safeToAdmit: boolean; } function boundedPositiveInteger( value: unknown, maximum: number, label: string, ): number { if ( !Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > maximum ) { return invalid(`${label} is invalid`); } return value as number; } export function assertPluginPackageAutomationPublicationRecoveryPageSize( value: number, ): void { boundedPositiveInteger( value, MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_RECOVERY_PAGE_SIZE, 'automation recovery page size', ); } function normalizeRecoveryCandidate( value: PluginPackageAutomationPublicationPendingCandidate, ): Readonly { const candidate = dataRecord(value, 'automation recovery candidate'); exactKeys( candidate, ['packageName', 'projectId'], 'automation recovery candidate', ); return Object.freeze({ projectId: identifier(value.projectId, 'projectId'), packageName: packageName(value.packageName), }); } export function normalizePluginPackageAutomationPublicationRecoveryCursor( value: PluginPackageAutomationPublicationRecoveryCursor, ): Readonly { const cursor = dataRecord(value, 'automation recovery cursor'); exactKeys( cursor, ['packageName', 'projectId'], 'automation recovery cursor', ); return Object.freeze({ projectId: identifier(value.projectId, 'projectId'), packageName: packageName(value.packageName), }); } function compareRecoveryCandidates( left: Readonly, right: Readonly, ): number { return ( left.projectId.localeCompare(right.projectId) || left.packageName.localeCompare(right.packageName) ); } function normalizeRecoveryPage( value: PluginPackageAutomationPublicationRecoveryPage, limit: number, ): Readonly { const page = dataRecord(value, 'automation recovery page'); const keys = Reflect.ownKeys(page); if ( keys.some((key) => typeof key !== 'string') || keys.some((key) => key !== 'candidates' && key !== 'next' && key !== 'truncated') || !keys.includes('candidates') || !keys.includes('truncated') || !Array.isArray(value.candidates) || value.candidates.length > limit || typeof value.truncated !== 'boolean' ) { return invalid('automation recovery page is invalid'); } const candidates = Object.freeze( value.candidates.map(normalizeRecoveryCandidate), ); if ( candidates.some( (candidate, index) => index > 0 && compareRecoveryCandidates(candidates[index - 1]!, candidate) >= 0, ) ) { return invalid('automation recovery candidates must be uniquely sorted'); } const next = value.next === undefined ? undefined : normalizePluginPackageAutomationPublicationRecoveryCursor(value.next); const last = candidates.at(-1); if ( value.truncated !== (next !== undefined) || (next && (!last || next.projectId !== last.projectId || next.packageName !== last.packageName)) ) { return invalid('automation recovery continuation is invalid'); } return Object.freeze({ candidates, truncated: value.truncated, ...(next ? { next } : {}), }); } export class PluginPackageAutomationPublicationCoordinator { readonly #generationSource: PluginPackageResourceGenerationSource; readonly #materializedRepository: PluginPackageMaterializedRevisionRepository; readonly #repository: PluginPackageAutomationPublicationRepository; readonly #registry: TaskSpecSemanticRegistry; readonly #now: () => number | Promise; constructor(options: { readonly generationSource: PluginPackageResourceGenerationSource; readonly materializedRepository: PluginPackageMaterializedRevisionRepository; readonly repository: PluginPackageAutomationPublicationRepository; readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry; readonly now: () => number | Promise; }) { const value = dataRecord(options, 'automation publication coordinator options'); exactKeys( value, [ 'generationSource', 'materializedRepository', 'now', 'repository', 'taskSpecSemanticRegistry', ], 'automation publication coordinator options', ); if ( !options.generationSource || typeof options.generationSource.findActiveResourceGeneration !== 'function' || !options.materializedRepository || typeof options.materializedRepository.find !== 'function' || !options.repository || typeof options.repository.findCurrent !== 'function' || typeof options.repository.publish !== 'function' || !(options.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry) || typeof options.now !== 'function' ) { invalid('automation publication coordinator authority is invalid'); } this.#generationSource = options.generationSource; this.#materializedRepository = options.materializedRepository; this.#repository = options.repository; this.#registry = options.taskSpecSemanticRegistry; this.#now = options.now; } async publishActive( projectIdValue: string, packageNameValue: string, ): Promise> { const identity = { projectId: identifier(projectIdValue, 'projectId'), packageName: packageName(packageNameValue), }; try { const first = await this.#generationSource.findActiveResourceGeneration( identity.projectId, identity.packageName, ); if (first === null) return Object.freeze({ status: 'absent' as const }); if ( first.projectId !== identity.projectId || first.packageName !== identity.packageName ) { throw new PluginPackageAutomationPublicationConflictError( 'generation source returned another Package identity', ); } const stored = await this.#materializedRepository.find( first.generationDigest, ); if (stored === null) { throw new PluginPackageAutomationPublicationUnavailableError(); } const revision = normalizePluginPackageMaterializedRevision( stored, this.#registry, ); if ( revision.generation.projectId !== identity.projectId || revision.generation.packageName !== identity.packageName || revision.generation.generationDigest !== first.generationDigest ) { throw new PluginPackageAutomationPublicationConflictError( 'materialized revision does not match the active generation', ); } const current = await this.#repository.findCurrent( identity.projectId, identity.packageName, ); let publicationStatus: 'created' | 'existing' = 'existing'; let publication: Readonly; if (current?.target.generationDigest === first.generationDigest) { if ( current.target.materializedRevisionDigest !== revision.revisionDigest || current.target.installationId !== revision.generation.installationId || current.target.lockDigest !== revision.generation.lockDigest ) { throw new PluginPackageAutomationPublicationConflictError( 'current publication does not match the materialized revision', ); } publication = current; } else { if ( current && current.target.generation >= revision.generation.generation ) { throw new PluginPackageAutomationPublicationConflictError( 'automation publication head is not behind the active generation', ); } const publishedAtMs = Math.max( timestamp(await this.#now(), 'publishedAtMs'), current?.publishedAtMs ?? 0, ); const next = current ? createNextPluginPackageAutomationPublication( revision, this.#registry, current, publishedAtMs, ) : createInitialPluginPackageAutomationPublication( revision, this.#registry, publishedAtMs, ); const result = await this.#repository.publish(next); publicationStatus = result.status; publication = result.publication; } const finalGeneration = await this.#generationSource.findActiveResourceGeneration( identity.projectId, identity.packageName, ); if ( finalGeneration === null || finalGeneration.generationDigest !== first.generationDigest ) { return Object.freeze({ status: 'superseded' as const, generationDigest: first.generationDigest, }); } return Object.freeze({ status: 'current' as const, publication: publicationStatus, generationDigest: first.generationDigest, record: publication, }); } catch (error) { if ( error instanceof InvalidPluginPackageAutomationPublicationError || error instanceof PluginPackageAutomationPublicationConflictError || error instanceof PluginPackageAutomationPublicationUnavailableError ) { throw error; } if (error instanceof InvalidPluginPackageResourceMaterializationError) { throw new InvalidPluginPackageAutomationPublicationError(error.message); } if (error instanceof PluginPackageResourceMaterializationConflictError) { throw new PluginPackageAutomationPublicationConflictError( 'active generation materialization fence changed', ); } if (error instanceof PluginPackageResourceMaterializationUnavailableError) { throw new PluginPackageAutomationPublicationUnavailableError({ cause: error, }); } throw new PluginPackageAutomationPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); } } } export class PluginPackageAutomationPublicationRecoveryCoordinator { readonly #source: PluginPackageAutomationPublicationRecoverySource; readonly #publisher: PluginPackageAutomationPublicationCoordinator; constructor(options: { readonly source: PluginPackageAutomationPublicationRecoverySource; readonly publisher: PluginPackageAutomationPublicationCoordinator; }) { const value = dataRecord(options, 'automation recovery coordinator options'); exactKeys( value, ['publisher', 'source'], 'automation recovery coordinator options', ); if ( !options.source || typeof options.source.listPendingPage !== 'function' || !(options.publisher instanceof PluginPackageAutomationPublicationCoordinator) ) { invalid('automation recovery coordinator authority is invalid'); } this.#source = options.source; this.#publisher = options.publisher; } async recoverPage(options: { readonly limit: number; readonly after?: Readonly; }): Promise< Readonly<{ items: readonly Readonly[]; truncated: boolean; next?: Readonly; }> > { const value = dataRecord(options, 'automation recovery page options'); const keys = Reflect.ownKeys(value); if ( keys.some((key) => typeof key !== 'string') || keys.some((key) => key !== 'after' && key !== 'limit') || !keys.includes('limit') ) { invalid('automation recovery page options shape is invalid'); } const limit = boundedPositiveInteger( options.limit, MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_RECOVERY_PAGE_SIZE, 'automation recovery page size', ); const after = options.after === undefined ? undefined : normalizePluginPackageAutomationPublicationRecoveryCursor( options.after, ); let page: Readonly; try { page = normalizeRecoveryPage( await this.#source.listPendingPage({ limit, ...(after ? { after } : {}), }), limit, ); } catch (error) { if (error instanceof InvalidPluginPackageAutomationPublicationError) { throw error; } throw new PluginPackageAutomationPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); } const items: Readonly[] = []; for (const candidate of page.candidates) { let status: PluginPackageAutomationPublicationRecoveryItemStatus; try { const published = await this.#publisher.publishActive( candidate.projectId, candidate.packageName, ); status = published.status === 'superseded' ? 'superseded' : 'settled'; } catch (error) { status = error instanceof InvalidPluginPackageAutomationPublicationError || error instanceof PluginPackageAutomationPublicationConflictError ? 'manual_required' : 'retry'; } items.push(Object.freeze({ ...candidate, status })); } return Object.freeze({ items: Object.freeze(items), truncated: page.truncated, ...(page.next ? { next: page.next } : {}), }); } async recover( options: { readonly pageSize?: number; readonly maxPages?: number; } = {}, ): Promise> { const value = dataRecord(options, 'automation recovery cycle options'); const keys = Reflect.ownKeys(value); if ( keys.some((key) => typeof key !== 'string') || keys.some((key) => key !== 'maxPages' && key !== 'pageSize') ) { invalid('automation recovery cycle options shape is invalid'); } const pageSize = boundedPositiveInteger( options.pageSize ?? 8, MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_RECOVERY_PAGE_SIZE, 'automation recovery page size', ); const maxPages = boundedPositiveInteger( options.maxPages ?? 8, MAX_PLUGIN_PACKAGE_AUTOMATION_PUBLICATION_RECOVERY_PAGES, 'automation recovery page count', ); const counts = { pages: 0, scanned: 0, settled: 0, retry: 0, manualRequired: 0, superseded: 0, }; let after: | Readonly | undefined; let exhausted = false; while (counts.pages < maxPages) { const page = await this.recoverPage({ limit: pageSize, ...(after ? { after } : {}), }); counts.pages += 1; counts.scanned += page.items.length; for (const item of page.items) { if (item.status === 'settled') counts.settled += 1; else if (item.status === 'retry') counts.retry += 1; else if (item.status === 'manual_required') counts.manualRequired += 1; else counts.superseded += 1; } if (!page.truncated) { exhausted = true; break; } after = page.next; } let probe: Readonly; try { probe = normalizeRecoveryPage( await this.#source.listPendingPage({ limit: 1 }), 1, ); } catch (error) { throw new PluginPackageAutomationPublicationUnavailableError({ cause: error instanceof Error ? error : undefined, }); } const remaining = !exhausted || probe.candidates.length > 0; return Object.freeze({ ...counts, remaining, safeToAdmit: !remaining && counts.retry === 0 && counts.manualRequired === 0, }); } }