diff --git a/.changeset/fix-hook-resume-encryption-compat.md b/.changeset/fix-hook-resume-encryption-compat.md new file mode 100644 index 0000000000..87d3ee099d --- /dev/null +++ b/.changeset/fix-hook-resume-encryption-compat.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Fix `resumeHook()`/`resumeWebhook()` failing on workflow runs from pre-encryption deployments by checking the target run's `workflowCoreVersion` capabilities before encoding the payload diff --git a/packages/core/package.json b/packages/core/package.json index 1a1f215713..e125080cc0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -93,12 +93,14 @@ "ms": "2.1.3", "nanoid": "5.1.6", "seedrandom": "3.0.5", + "semver": "catalog:", "ulid": "catalog:", "zod": "catalog:" }, "devDependencies": { "@opentelemetry/api": "1.9.0", "@types/debug": "4.1.12", + "@types/semver": "7.7.1", "@types/node": "catalog:", "@types/seedrandom": "3.0.8", "@workflow/tsconfig": "workspace:*", diff --git a/packages/core/src/capabilities.test.ts b/packages/core/src/capabilities.test.ts new file mode 100644 index 0000000000..50b48bebc4 --- /dev/null +++ b/packages/core/src/capabilities.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { getRunCapabilities } from './capabilities.js'; +import { SerializationFormat } from './serialization.js'; + +describe('getRunCapabilities', () => { + describe('undefined version (very old runs)', () => { + it('only supports baseline formats', () => { + const { supportedFormats } = getRunCapabilities(undefined); + expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(false); + }); + }); + + describe('pre-encryption versions', () => { + it.each([ + '4.1.0-beta.63', + '4.0.1-beta.27', + '3.0.0', + ])('%s does not support encryption', (version) => { + const { supportedFormats } = getRunCapabilities(version); + expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(false); + }); + }); + + describe('encryption-capable versions', () => { + it('supports encryption at the exact cutoff version', () => { + const { supportedFormats } = getRunCapabilities('4.2.0-beta.64'); + expect(supportedFormats.has(SerializationFormat.DEVALUE_V1)).toBe(true); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(true); + }); + + it.each([ + '4.2.0-beta.74', + '4.2.0', + '5.0.0', + ])('%s supports encryption', (version) => { + const { supportedFormats } = getRunCapabilities(version); + expect(supportedFormats.has(SerializationFormat.ENCRYPTED)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts new file mode 100644 index 0000000000..3b9fc5e724 --- /dev/null +++ b/packages/core/src/capabilities.ts @@ -0,0 +1,84 @@ +/** + * Capabilities table for workflow runs based on their `@workflow/core` version. + * + * When resuming a hook or webhook, the payload must be encoded in a format + * that the *target* workflow run's deployment can decode. This module provides + * a way to look up what serialization formats a given `@workflow/core` version + * supports, so that newer deployments can avoid encoding payloads in formats + * that older deployments don't understand (e.g., the `encr` encryption format). + * + * ## Adding a new format + * + * When a new serialization format is introduced: + * 1. Add the format constant to `SerializationFormat` in `serialization.ts` + * 2. Add an entry to `FORMAT_VERSION_TABLE` below with the minimum + * `@workflow/core` version that supports it + * 3. The `getRunCapabilities()` function will automatically include it + */ + +import semver from 'semver'; +import { + SerializationFormat, + type SerializationFormatType, +} from './serialization.js'; + +/** + * Capabilities of a workflow run based on its `@workflow/core` version. + */ +export interface RunCapabilities { + /** + * The set of serialization format prefixes that the target run can decode. + * Use `supportedFormats.has(SerializationFormat.ENCRYPTED)` to check + * if encryption is supported, etc. + */ + supportedFormats: ReadonlySet; +} + +/** + * Maps serialization format identifiers to the minimum `@workflow/core` + * version that introduced support for them. Formats not listed here are + * assumed to be supported by all specVersion 2 runs (e.g., `devl`). + */ +const FORMAT_VERSION_TABLE: ReadonlyArray<{ + format: SerializationFormatType; + minVersion: string; +}> = [ + { format: SerializationFormat.ENCRYPTED, minVersion: '4.2.0-beta.64' }, + // Future entries: + // { format: SerializationFormat.CBOR, minVersion: '5.x.y' }, + // { format: SerializationFormat.ENCRYPTED_V2, minVersion: '5.x.y' }, +]; + +/** + * The set of formats supported by all specVersion 2 runs, regardless of + * `@workflow/core` version. These are the baseline formats that were present + * from the start of the specVersion 2 protocol. + */ +const BASELINE_FORMATS: ReadonlySet = new Set([ + SerializationFormat.DEVALUE_V1, +]); + +/** + * Look up what serialization capabilities a workflow run supports based on + * its `@workflow/core` version string (from `executionContext.workflowCoreVersion`). + * + * When the version is `undefined` (e.g. very old runs that predate the field), + * we assume the most conservative capabilities (baseline formats only). + */ +export function getRunCapabilities( + workflowCoreVersion: string | undefined +): RunCapabilities { + if (!workflowCoreVersion) { + return { supportedFormats: BASELINE_FORMATS }; + } + + const formats = new Set(BASELINE_FORMATS); + + for (const { format, minVersion } of FORMAT_VERSION_TABLE) { + if (semver.gte(workflowCoreVersion, minVersion)) { + formats.add(format); + } + } + + return { supportedFormats: formats }; +} diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index fedc1de3da..6c51ba1fb2 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -11,10 +11,12 @@ import { type WorkflowInvokePayload, type WorkflowRun, } from '@workflow/world'; +import { getRunCapabilities } from '../capabilities.js'; import { type CryptoKey, importKey } from '../encryption.js'; import { dehydrateStepReturnValue, hydrateStepArguments, + SerializationFormat, } from '../serialization.js'; import { WEBHOOK_RESPONSE_WRITABLE } from '../symbols.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; @@ -123,6 +125,17 @@ export async function resumeHook( ...Attribute.WorkflowRunId(hook.runId), }); + // Check the target run's capabilities to ensure we encode the + // payload in a format the run's deployment can decode. For example, + // runs created before encryption support was added cannot decode + // the 'encr' serialization format. + const { supportedFormats } = getRunCapabilities( + workflowRun.executionContext?.workflowCoreVersion + ); + if (!supportedFormats.has(SerializationFormat.ENCRYPTED)) { + encryptionKey = undefined; + } + // Dehydrate the payload for storage const ops: Promise[] = []; const v1Compat = isLegacySpecVersion(hook.specVersion); diff --git a/packages/next/package.json b/packages/next/package.json index 2b0e3490cf..c2e431c472 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -37,7 +37,7 @@ "@workflow/builders": "workspace:*", "@workflow/core": "workspace:*", "@workflow/swc-plugin": "workspace:*", - "semver": "7.7.4", + "semver": "catalog:", "watchpack": "2.5.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70311f2e81..47678829b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ catalogs: nitro: specifier: 3.0.1-alpha.1 version: 3.0.1-alpha.1 + semver: + specifier: 7.7.4 + version: 7.7.4 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -588,6 +591,9 @@ importers: seedrandom: specifier: 3.0.5 version: 3.0.5 + semver: + specifier: 'catalog:' + version: 7.7.4 ulid: specifier: 'catalog:' version: 3.0.1 @@ -607,6 +613,9 @@ importers: '@types/seedrandom': specifier: 3.0.8 version: 3.0.8 + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 '@workflow/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -737,7 +746,7 @@ importers: specifier: workspace:* version: link:../swc-plugin-workflow semver: - specifier: 7.7.4 + specifier: 'catalog:' version: 7.7.4 watchpack: specifier: 2.5.1 @@ -13619,11 +13628,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -21881,7 +21885,7 @@ snapshots: fast-glob: 3.3.3 minimatch: 9.0.5 piscina: 4.9.2 - semver: 7.7.3 + semver: 7.7.4 slash: 3.0.0 source-map: 0.7.6 optionalDependencies: @@ -30219,8 +30223,6 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} - semver@7.7.4: {} send@0.19.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 80605c1166..667f4878f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: ai: 6.0.116 esbuild: ^0.27.3 nitro: 3.0.1-alpha.1 + semver: 7.7.4 typescript: ^5.9.3 ulid: ~3.0.1 undici: 7.22.0