Skip to content

Commit b732e91

Browse files
authored
feat(core): side-effect-free serialization of workflow VM values (#3257)
* feat(core): side-effect-free serialization of workflow VM values Serialization runs on the host but inspects values constructed inside the node:vm sandbox, so ordinary dynamic operations dispatch into the sandbox realm and execute workflow code: `value.toISOString()`, `Array.from(map)`, `Object.prototype.toString` (via Symbol.toStringTag), `.source`/`.flags`, `.href`, view `.buffer`/`.byteOffset`/`.byteLength`, and error `.message`/`.stack`/`.cause` reads. That is a determinism hazard. A payload is serialized exactly once and is never re-serialized on replay, so any workflow-visible side effect it triggers exists only on the live path — a patched `Date.prototype.toISOString` that consumes a seeded `Math.random()` draw, for example, shifts every subsequent draw and diverges from replay. This makes serialization side-effect free where the data allows it, and observable where it does not: - Classification uses engine brand checks (node:util types, internal-slot probes) instead of `instanceof global.X` and Object.prototype.toString, so it is immune to Symbol.hasInstance, reassigned sandbox globals, and Symbol.toStringTag spoofs. An unbranded value claiming a brand-decided tag is now classified as a plain object instead of being routed into an extractor that requires the real internal slot (unhardened devalue crashes on that input). - Extraction goes through intrinsics captured at module load — host boot, before any workflow bundle runs — invoked with explicit receivers. Internal slots are realm-agnostic, so host intrinsics read VM-realm objects without touching the sandbox's patchable prototypes. - Property access reads through descriptors, so plain data never invokes anything. Where workflow code must run because the data lives behind it — getters, proxies, custom [WORKFLOW_SERIALIZE] methods, toString() on toStringTag-branded objects like Temporal polyfills — the execution is preserved for compatibility and recorded in a new `CodecOptions.guestCodeStats` sink, surfaced as workflow.serialization.guest_code_{executions,details} span attributes. Consumers that retain a VM across steps can treat a non-empty report as "serialization may have perturbed VM state". Engine-provided accessors are deliberately not reported: V8 defines `stack` as an own accessor on every Error instance, so reporting it would flag every serialized error. Nativeness is decided with the captured host Function.prototype.toString; the bound-function caveat is documented in hardened.ts. Requires devalue 5.9.0 for the pluggable `operations` option. * chore: shorten changeset * fix(core): close review gaps in hardened serialization Five correctness fixes, all with repros: - Callable proxies were treated as engine accessors. V8 returns `function () { [native code] }` from Function.prototype.toString for a proxy around a function rather than throwing, so a proxy-wrapped getter was cached as engine-provided and invoked unreported. Gate on types.isProxy first. - Host builtins implemented in JavaScript were reported as workflow code. Node's DOMException.prototype.message/name are ordinary functions, so the nativeness test failed and every serialized DOMException reported two getter executions. They belong to the *host* realm, though, and workflow code cannot author a host-realm function — so provenance is now decided by nativeness OR host-realm `Function.prototype`, which are disjoint and together cover both cases (V8 installs `stack` per realm, so a VM error's getter is native but VM-realm). - The extraReducers at the two VM call sites were still unhardened, and they run on every value the earlier reducers do not claim — which is exactly where the report has to be complete. `instanceof global.ReadableStream/WritableStream/Request/Response` consulted Symbol.hasInstance on the sandbox class (14 invocations for an ordinary payload once the classes are patched), and AbortController's guard did a bare `value.signal` read, so a non-enumerable `signal` getter ran with an empty report. All five now walk the prototype chain and read through descriptors. - `__closureVarsFn` was invoked unreported on a purity argument that nothing checked: the property is reachable from workflow code, which can replace the compiler-generated function. step.ts now registers the generated function as trusted when it builds the proxy, so provenance is verified rather than assumed, and an unrecognized function is reported. - The URL/URLSearchParams test patched prototypes of *host* classes injected into the sandbox, mutating them for the rest of the worker process. Restored in a finally. Also, per review: - `dehydrateStepArguments` / `dehydrateWorkflowReturnValue` take an optional GuestCodeStats out-param, so a retained-VM gate can consume the report instead of it being spent on span attributes. The report-completeness tests use it to exercise the real dehydrate path. - Every intrinsic capture is now optional. The table is built at module scope, so a missing member was an import-time crash of @workflow/core rather than a degraded path; only SharedArrayBuffer was guarded, while URLSearchParams.prototype.size (Node 19.8+) and the WHATWG classes were assumed. Absent captures now make the corresponding reducer decline to match. - Documented that recording is not prevention (a recorded getter calling Math.random() still advances the run's seeded PRNG), and that a `{ kind: 'proxy' }` report implies a silent shape change (a proxied Map serializes as a plain object). - Parity coverage extended to DataView, boxed primitives, null-prototype objects, setter-only properties, DOMException, AggregateError, an accessor-valued Symbol.toStringTag, both RetryableError retryAfter paths, and a WORKFLOW_SERIALIZE class instance. * fix(core): keep identifying proxied host classes Every Next.js e2e job failed on the two webhook tests: the hook POST returned 404 because `resumeWebhook` could not serialize its step return value ("Cannot stringify arbitrary non-POJOs"), so no hook was ever registered. The value was a `NextRequest`, which Next.js hands over as a **Proxy**. `isInstanceOfPrototype` rejected proxies outright, so the Request reducer answered "not a Request" and devalue fell through to the POJO check. The reasoning behind rejecting them — that proxied built-ins were never serializable, because internal-slot reads throw on a proxy receiver — is true for `Map`/`Date`/`URL`, whose reducers read internal slots, but not for `Request`/`Response`/streams, whose reducers read ordinary properties. Next's proxy forwards those with the target as receiver, so they serialized fine before this PR. Identification now walks through proxies, matching `instanceof`, and records the traps rather than suppressing the answer. The three reducers that do read internal slots (URL, URLSearchParams, Headers) fall back to the dynamic read when the value is a proxy, so their behavior is exactly what it was before — including throwing for a bare proxy over a built-in, which threw before too. Verified against the real thing: the full nextjs-turbopack e2e suite (135 tests) passes locally, having reproduced the failure first and confirmed a reverted `serialization.ts` fixed it. The regression test uses a receiver-correcting proxy, which is what makes NextRequest work in practice; a comment records that a bare `new Proxy(request, {})` throws on undici's private slots with or without this change. * fix(core): state what the closure-fn mark proves, and correct stale docs - `isInstanceOfPrototype`'s JSDoc still described the behavior removed in 8bc462f (proxies rejected without firing traps), which is the opposite of what it now does. - The `__closureVarsFn` provenance check proves the function was passed to `useStep`, not that this package generated it: `useStep` is published on the sandbox global, so workflow code can call it with a function of its own and have it marked. Renamed `registerTrustedFunction` / `isTrustedFunction` to `markUseStepClosureFn` / `isUseStepClosureFn` so the name states the boundary, and documented the laundering caveat alongside the existing ones. Marking still earns its keep — reporting every step that captures a variable would bury the signal — and closing the gap properly needs a compiler-emitted marker, which is a compiler change. - Added the missing coverage for both sides of that check: an unmarked `__closureVarsFn` is invoked and reported, a marked one is invoked and not. - `guestCodeStats` was documented as something a retained-VM gate consumes, but no runtime caller passes a sink; the executions reach telemetry from every dehydrate path regardless. Reworded both docs to say that, so the out-param is not mistaken for wiring that already exists.
1 parent ee944d2 commit b732e91

15 files changed

Lines changed: 2051 additions & 154 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@workflow/core': patch
3+
'workflow': patch
4+
---
5+
6+
Serializing values built inside the `node:vm` workflow VM no longer executes workflow code, using engine brand checks and host intrinsics captured at boot.

‎packages/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
"@workflow/world-local": "workspace:*",
103103
"@workflow/world-vercel": "workspace:*",
104104
"debug": "4.4.3",
105-
"devalue": "5.8.1",
105+
"devalue": "5.9.0",
106106
"ms": "2.1.3",
107107
"nanoid": "5.1.6",
108108
"seedrandom": "3.0.5",

‎packages/core/src/serialization.ts‎

Lines changed: 199 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ import {
6262
isEncrypted,
6363
peekFormatPrefix,
6464
} from './serialization/format.js';
65+
import {
66+
type GuestCodeStats,
67+
isInstanceOfPrototype,
68+
readProperty,
69+
} from './serialization/hardened.js';
6570
import {
6671
getClassReducers,
6772
getClassRevivers,
@@ -233,6 +238,34 @@ async function recordCompression(
233238
}
234239
}
235240

241+
/**
242+
* Emits OTel span attributes for workflow (guest) code executions that the
243+
* hardened serializer could not avoid (getters, proxies, custom
244+
* serializers). No-ops when serialization was fully side-effect free —
245+
* the common case. Same never-break-the-data-path contract as
246+
* `recordCompression` above.
247+
*/
248+
async function recordGuestCodeExecutions(stats: GuestCodeStats): Promise<void> {
249+
if (stats.executions.length === 0) return;
250+
try {
251+
const span = await getActiveSpan();
252+
if (!span) return;
253+
const details = [
254+
...new Set(
255+
stats.executions.map((e) =>
256+
e.detail ? `${e.kind} (${e.detail})` : e.kind
257+
)
258+
),
259+
];
260+
span.setAttributes({
261+
...Attr.SerializationGuestCodeExecutions(stats.executions.length),
262+
...Attr.SerializationGuestCodeDetails(details),
263+
});
264+
} catch {
265+
// ignore telemetry failures
266+
}
267+
}
268+
236269
export function getSerializeStream(
237270
reducers: Partial<Reducers>,
238271
cryptoKey: EncryptionKeyParam
@@ -1568,15 +1601,23 @@ function getAllBaseReducers(
15681601
// Request and Response reducers are mode-specific and added by
15691602
// getExternalReducers / getWorkflowReducers / getStepReducers below.
15701603
Request: (value) => {
1571-
if (!(value instanceof global.Request)) return false;
1604+
// Chain walk rather than `instanceof global.Request`: see the
1605+
// ReadableStream reducer in getWorkflowReducers for why. Reads go
1606+
// through descriptors so a getter cannot run unreported.
1607+
if (
1608+
!isInstanceOfPrototype(value, getHostClassPrototype(global, 'Request'))
1609+
)
1610+
return false;
15721611
const data: SerializableSpecial['Request'] = {
1573-
method: value.method,
1574-
url: value.url,
1575-
headers: value.headers,
1576-
body: value.body,
1577-
duplex: value.duplex,
1612+
method: readProperty(value, 'method') as string,
1613+
url: readProperty(value, 'url') as string,
1614+
headers: readProperty(value, 'headers') as Headers,
1615+
body: readProperty(value, 'body') as ReadableStream | null,
1616+
duplex: readProperty(value, 'duplex') as 'half',
15781617
};
1579-
const responseWritable = value[WEBHOOK_RESPONSE_WRITABLE];
1618+
const responseWritable = readProperty(value, WEBHOOK_RESPONSE_WRITABLE) as
1619+
| WritableStream<Response>
1620+
| undefined;
15801621
if (responseWritable) {
15811622
data.responseWritable = responseWritable;
15821623
}
@@ -1589,25 +1630,30 @@ function getAllBaseReducers(
15891630
// Plain non-aborted native signals are intentionally dropped (would
15901631
// mint stream infra for every Request, including the auto-generated
15911632
// signal on `new Request(url)`).
1633+
const signal = readProperty(value, 'signal');
15921634
if (
1593-
value.signal &&
1594-
(value.signal.aborted ||
1595-
(value.signal as AbortInternals)[ABORT_STREAM_NAME])
1635+
signal &&
1636+
(readProperty(signal, 'aborted') ||
1637+
readProperty(signal, ABORT_STREAM_NAME))
15961638
) {
1597-
data.signal = value.signal;
1639+
data.signal = signal as AbortSignal;
15981640
}
15991641
return data;
16001642
},
16011643
Response: (value) => {
1602-
if (!(value instanceof global.Response)) return false;
1644+
// See the Request reducer above.
1645+
if (
1646+
!isInstanceOfPrototype(value, getHostClassPrototype(global, 'Response'))
1647+
)
1648+
return false;
16031649
return {
1604-
type: value.type,
1605-
url: value.url,
1606-
status: value.status,
1607-
statusText: value.statusText,
1608-
headers: value.headers,
1609-
body: value.body,
1610-
redirected: value.redirected,
1650+
type: readProperty(value, 'type') as Response['type'],
1651+
url: readProperty(value, 'url') as string,
1652+
status: readProperty(value, 'status') as number,
1653+
statusText: readProperty(value, 'statusText') as string,
1654+
headers: readProperty(value, 'headers') as Headers,
1655+
body: readProperty(value, 'body') as ReadableStream | null,
1656+
redirected: readProperty(value, 'redirected') as boolean,
16111657
};
16121658
},
16131659
};
@@ -1937,6 +1983,51 @@ export function getExternalReducers(
19371983
* @param global
19381984
* @returns
19391985
*/
1986+
/**
1987+
* Prototypes used for brand-style identification in the reducers below.
1988+
*
1989+
* The stream and abort classes are host classes injected into the sandbox, so
1990+
* instances carry the host prototype in their chain and a chain walk
1991+
* identifies them without consulting `Symbol.hasInstance` on the sandbox
1992+
* class. `undefined` when the runtime lacks the class, in which case
1993+
* identification falls back to the infrastructure symbols alone.
1994+
*/
1995+
function getStreamPrototype(
1996+
global: Record<string, any>,
1997+
kind: 'Readable' | 'Writable'
1998+
): object | undefined {
1999+
return getHostClassPrototype(global, `${kind}Stream`);
2000+
}
2001+
2002+
/**
2003+
* The prototype of a host class that may also be injected into the sandbox.
2004+
* Prefers the sandbox binding (the same host class object in practice) and
2005+
* falls back to the host's own, so a chain walk identifies instances from
2006+
* either realm without consulting `Symbol.hasInstance`.
2007+
*/
2008+
function getHostClassPrototype(
2009+
global: Record<string, any>,
2010+
name: string
2011+
): object | undefined {
2012+
const ctor =
2013+
global[name] ?? (globalThis as Record<string, any>)[name] ?? undefined;
2014+
return typeof ctor === 'function' ? ctor.prototype : undefined;
2015+
}
2016+
2017+
function getAbortControllerPrototype(
2018+
global: Record<string, any>
2019+
): object | undefined {
2020+
const ctor = global.AbortController ?? globalThis.AbortController;
2021+
return typeof ctor === 'function' ? ctor.prototype : undefined;
2022+
}
2023+
2024+
function getAbortSignalPrototype(
2025+
global: Record<string, any>
2026+
): object | undefined {
2027+
const ctor = global.AbortSignal ?? globalThis.AbortSignal;
2028+
return typeof ctor === 'function' ? ctor.prototype : undefined;
2029+
}
2030+
19402031
export function getWorkflowReducers(
19412032
global: Record<string, any> = globalThis
19422033
): Partial<Reducers> {
@@ -1946,45 +2037,68 @@ export function getWorkflowReducers(
19462037
// Readable/Writable streams from within the workflow execution environment
19472038
// are simply "handles" that can be passed around to other steps.
19482039
ReadableStream: (value) => {
1949-
if (!(value instanceof global.ReadableStream)) return false;
2040+
// Walk the prototype chain instead of `instanceof global.ReadableStream`:
2041+
// the class is host-provided (injected into the sandbox), so its
2042+
// prototype is in the chain of both real streams and the
2043+
// `Object.create(ReadableStream.prototype)` handles used for request
2044+
// bodies — but a chain walk never consults `Symbol.hasInstance`, which
2045+
// the sandbox can define and which ran for every value the earlier
2046+
// reducers did not claim. Reads below go through descriptors so a
2047+
// getter on a step argument cannot run unreported.
2048+
if (!isInstanceOfPrototype(value, getStreamPrototype(global, 'Readable')))
2049+
return false;
19502050

19512051
// Check if this is a fake stream storing BodyInit from Request/Response constructor
1952-
const bodyInit = value[BODY_INIT_SYMBOL];
2052+
const bodyInit = readProperty(value, BODY_INIT_SYMBOL);
19532053
if (bodyInit !== undefined) {
19542054
// This is a fake stream - serialize the BodyInit directly
19552055
// devalue will handle serializing strings, Uint8Array, etc.
19562056
return { bodyInit };
19572057
}
19582058

1959-
const name = value[STREAM_NAME_SYMBOL];
2059+
const name = readProperty(value, STREAM_NAME_SYMBOL) as string;
19602060
if (!name) {
19612061
throw new WorkflowRuntimeError('ReadableStream `name` is not set');
19622062
}
1963-
const s: SerializableSpecial['ReadableStream'] = { name };
1964-
const type = value[STREAM_TYPE_SYMBOL];
2063+
const s: Extract<
2064+
SerializableSpecial['ReadableStream'],
2065+
{ name: string }
2066+
> = { name };
2067+
const type = readProperty(value, STREAM_TYPE_SYMBOL) as
2068+
| 'bytes'
2069+
| undefined;
19652070
if (type) s.type = type;
1966-
const framing: ByteStreamFraming | undefined =
1967-
value[STREAM_FRAMING_SYMBOL];
2071+
const framing = readProperty(value, STREAM_FRAMING_SYMBOL) as
2072+
| ByteStreamFraming
2073+
| undefined;
19682074
if (framing) s.framing = framing;
19692075
return s;
19702076
},
19712077
WritableStream: (value) => {
1972-
if (!(value instanceof global.WritableStream)) return false;
1973-
const name = value[STREAM_NAME_SYMBOL];
2078+
// See the ReadableStream reducer above for why this walks the chain.
2079+
if (!isInstanceOfPrototype(value, getStreamPrototype(global, 'Writable')))
2080+
return false;
2081+
const name = readProperty(value, STREAM_NAME_SYMBOL) as string;
19742082
if (!name) {
19752083
throw new WorkflowRuntimeError('WritableStream `name` is not set');
19762084
}
19772085
const s: SerializableSpecial['WritableStream'] = { name };
19782086
// When the handle was forwarded from another run (parent → child
19792087
// via `start()`), preserve the foreign runId so the step-side
19802088
// reviver opens the writable against the original stream.
1981-
const foreignRunId = value[STREAM_SERVER_RUN_ID_SYMBOL];
2089+
const foreignRunId = readProperty(value, STREAM_SERVER_RUN_ID_SYMBOL);
19822090
if (typeof foreignRunId === 'string') s.runId = foreignRunId;
1983-
const foreignDeploymentId = value[STREAM_SERVER_DEPLOYMENT_ID_SYMBOL];
2091+
const foreignDeploymentId = readProperty(
2092+
value,
2093+
STREAM_SERVER_DEPLOYMENT_ID_SYMBOL
2094+
);
19842095
if (typeof foreignDeploymentId === 'string') {
19852096
s.deploymentId = foreignDeploymentId;
19862097
}
1987-
const foreignPublicKey = value[STREAM_SERVER_PUBLIC_KEY_SYMBOL];
2098+
const foreignPublicKey = readProperty(
2099+
value,
2100+
STREAM_SERVER_PUBLIC_KEY_SYMBOL
2101+
);
19882102
if (typeof foreignPublicKey === 'string') {
19892103
s.encryptionPublicKey = foreignPublicKey;
19902104
}
@@ -1996,26 +2110,42 @@ export function getWorkflowReducers(
19962110
// is a plain object (not a class), so instanceof checks won't work for signals.
19972111
// Detect instances by the presence of the ABORT_STREAM_NAME symbol instead.
19982112
AbortController: (value) => {
1999-
if (!value || !value.signal) return false;
2113+
// `value.signal` was a bare read, so a `signal` getter on any object
2114+
// reaching this reducer executed unreported. Read through descriptors
2115+
// and gate on the infrastructure symbol / prototype first.
2116+
if (value === null || typeof value !== 'object') return false;
20002117
const holder = value as AbortController & AbortHolder;
2001-
const hasAbortSymbol =
2002-
holder[ABORT_STREAM_NAME] ?? holder.signal?.[ABORT_STREAM_NAME];
2003-
const isNativeAbortController =
2004-
global.AbortController &&
2005-
typeof global.AbortController === 'function' &&
2006-
value instanceof global.AbortController;
2007-
if (!hasAbortSymbol && !isNativeAbortController) return false;
2008-
return reduceAbortBySymbol(value.signal, holder);
2118+
const ownSymbol = readProperty(value, ABORT_STREAM_NAME);
2119+
const isNativeAbortController = isInstanceOfPrototype(
2120+
value,
2121+
getAbortControllerPrototype(global)
2122+
);
2123+
if (ownSymbol === undefined && !isNativeAbortController) {
2124+
// Not ours and not a native controller — but a foreign controller
2125+
// may still carry the symbol on its signal.
2126+
const maybeSignal = readProperty(value, 'signal');
2127+
if (
2128+
maybeSignal === null ||
2129+
typeof maybeSignal !== 'object' ||
2130+
readProperty(maybeSignal, ABORT_STREAM_NAME) === undefined
2131+
) {
2132+
return false;
2133+
}
2134+
}
2135+
const signal = readProperty(value, 'signal');
2136+
if (!signal) return false;
2137+
return reduceAbortBySymbol(signal as AbortSignal, holder);
20092138
},
20102139
AbortSignal: (value) => {
2011-
const signal = value as (AbortSignal & AbortInternals) | undefined;
2012-
const hasAbortSymbol = signal?.[ABORT_STREAM_NAME];
2013-
const isNativeAbortSignal =
2014-
global.AbortSignal &&
2015-
typeof global.AbortSignal === 'function' &&
2016-
value instanceof global.AbortSignal;
2140+
if (value === null || typeof value !== 'object') return false;
2141+
const hasAbortSymbol =
2142+
readProperty(value, ABORT_STREAM_NAME) !== undefined;
2143+
const isNativeAbortSignal = isInstanceOfPrototype(
2144+
value,
2145+
getAbortSignalPrototype(global)
2146+
);
20172147
if (!hasAbortSymbol && !isNativeAbortSignal) return false;
2018-
return reduceAbortBySymbol(value, value as AbortHolder);
2148+
return reduceAbortBySymbol(value as AbortSignal, value as AbortHolder);
20192149
},
20202150
};
20212151
}
@@ -3420,21 +3550,34 @@ export async function dehydrateWorkflowReturnValue(
34203550
key: PayloadKey | undefined,
34213551
global: Record<string, any> = globalThis,
34223552
v1Compat = false,
3423-
compression = false
3553+
compression = false,
3554+
/**
3555+
* Optional sink receiving every workflow-code execution serialization could
3556+
* not avoid, for callers that need them programmatically (e.g. a
3557+
* retained-VM gate deciding whether the VM is still reusable). The
3558+
* executions are emitted as span attributes either way, so omitting this
3559+
* loses nothing observability-wise. No runtime caller passes one yet.
3560+
*/
3561+
guestCodeStatsOut?: GuestCodeStats
34243562
): Promise<Uint8Array | unknown> {
34253563
if (v1Compat) {
34263564
const str = stringify(value, getWorkflowReducers(global));
34273565
return revive(str);
34283566
}
34293567
try {
34303568
const compressionStats: CompressionStats = {};
3569+
const guestCodeStats: GuestCodeStats = guestCodeStatsOut ?? {
3570+
executions: [],
3571+
};
34313572
const result = await stepModule.serialize(value, key, {
34323573
global,
34333574
extraReducers: getStreamAndRequestReducers(getWorkflowReducers(global)),
34343575
compression,
34353576
compressionStats,
3577+
guestCodeStats,
34363578
});
34373579
await recordCompression(compressionStats, 'serialize');
3580+
await recordGuestCodeExecutions(guestCodeStats);
34383581
return result;
34393582
} catch (error) {
34403583
const cause = unwrapSerializationCause(error);
@@ -3483,21 +3626,28 @@ export async function dehydrateStepArguments(
34833626
key: PayloadKey | undefined,
34843627
global: Record<string, any> = globalThis,
34853628
v1Compat = false,
3486-
compression = false
3629+
compression = false,
3630+
/** See `dehydrateWorkflowReturnValue`. */
3631+
guestCodeStatsOut?: GuestCodeStats
34873632
): Promise<Uint8Array | unknown> {
34883633
if (v1Compat) {
34893634
const str = stringify(value, getWorkflowReducers(global));
34903635
return revive(str);
34913636
}
34923637
try {
34933638
const compressionStats: CompressionStats = {};
3639+
const guestCodeStats: GuestCodeStats = guestCodeStatsOut ?? {
3640+
executions: [],
3641+
};
34943642
const result = await stepModule.serialize(value, key, {
34953643
global,
34963644
extraReducers: getStreamAndRequestReducers(getWorkflowReducers(global)),
34973645
compression,
34983646
compressionStats,
3647+
guestCodeStats,
34993648
});
35003649
await recordCompression(compressionStats, 'serialize');
3650+
await recordGuestCodeExecutions(guestCodeStats);
35013651
return result;
35023652
} catch (error) {
35033653
const cause = unwrapSerializationCause(error);

0 commit comments

Comments
 (0)