Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
45c7bcd
perf(core): retain workflow VM across inline steps
NathanColosimo Jul 17, 2026
bab996c
refactor(core): simplify retained-session control flow
NathanColosimo Jul 17, 2026
bc5a318
fix(core): decline retention for VMs that ran host-timed async work
NathanColosimo Jul 17, 2026
106936a
fix(core): track all host-timed async VM APIs for retention
NathanColosimo Jul 17, 2026
9001a31
feat(core): compute crypto.subtle.digest synchronously in the sandbox
NathanColosimo Jul 17, 2026
2abf8b8
fix(core): remove WeakRef and FinalizationRegistry from the sandbox
NathanColosimo Jul 17, 2026
4905577
fix(core): enforce the BufferSource contract in the sandbox digest
NathanColosimo Jul 17, 2026
73ee4ae
fix(core): demote retention when suspension serialization draws rando…
NathanColosimo Jul 17, 2026
d83b651
refactor(core): make VM quiescence unconditional, cut tracking machinery
NathanColosimo Jul 17, 2026
b2c33d2
refactor(core): gate retention on passively cloneable step inputs
NathanColosimo Jul 17, 2026
744f0b7
fix(core): harden the passive step-input walker
NathanColosimo Jul 17, 2026
8d96711
fix(core): guard proxied constructors in the passive-input walker
NathanColosimo Jul 17, 2026
fd73574
fix(core): preserve retention gate after rebase
NathanColosimo Jul 17, 2026
bd07e11
fix(core): all-or-nothing clone batches; reject SAB views in digest
NathanColosimo Jul 17, 2026
fe425e6
fix(core): narrow the fast path to prototype-independent types
NathanColosimo Jul 17, 2026
c6d6cb5
Merge branch 'main' into nathanc/workflow-replay-perf-43e0a8
VaguelySerious Jul 17, 2026
9a5f070
fix(core): freeze serialization-consulted sandbox intrinsics
NathanColosimo Jul 17, 2026
3bda890
fix(core): freeze every non-shared serialization constructor
NathanColosimo Jul 17, 2026
43788b8
fix(core): build retained clones in a pristine realm
NathanColosimo Jul 17, 2026
2b73676
fix(core): verify host dispatch pristineness before retained cloning
NathanColosimo Jul 17, 2026
caf6a1a
fix(core): reject symbol properties from retained step inputs
NathanColosimo Jul 17, 2026
e961466
fix(core): retained inputs accept only own enumerable data properties
NathanColosimo Jul 17, 2026
3498706
fix(core): freeze binding prototype chains for hasInstance lookup
NathanColosimo Jul 17, 2026
33f8e1a
Merge branch 'main' into nathanc/workflow-replay-perf-43e0a8
VaguelySerious Jul 20, 2026
89494d1
Merge branch 'main' into nathanc/workflow-replay-perf-43e0a8
VaguelySerious Jul 20, 2026
59aaebb
refactor(core): single-path retained serialization via pinned members…
NathanColosimo Jul 21, 2026
58514fd
refactor(core): freeze built-in prototypes instead of pinning members…
NathanColosimo Jul 21, 2026
93aa3a5
fix(core): harden the passivity checker's own execution surface
NathanColosimo Jul 21, 2026
fb78452
fix(core): checker uses module-load primordials; verify inherited ser…
NathanColosimo Jul 21, 2026
59e21d6
fix(core): stale-suspension generation token; cover BigInt toString
NathanColosimo Jul 21, 2026
2e78951
Merge branch 'main' into nathanc/workflow-replay-perf-43e0a8
NathanColosimo Jul 21, 2026
b908241
feat(core): deterministic sandbox hardening
NathanColosimo Jul 22, 2026
4765dcf
Merge retained-VM feature branch (vm sandbox files resolve to PR1's p…
NathanColosimo Jul 22, 2026
bb2098a
feat(core): retain the workflow VM across inline steps (primitive args)
NathanColosimo Jul 22, 2026
c61f988
chore: retrigger vercel deployments
NathanColosimo Jul 23, 2026
788ad2e
Merge remote-tracking branch 'origin/main' into nathanc/vm-determinis…
NathanColosimo Jul 23, 2026
9395b2d
Drop serialization intrinsic freezing from the sandbox
NathanColosimo Jul 23, 2026
c574d51
Merge branch 'nathanc/vm-determinism-hardening' into nathanc/retained…
NathanColosimo Jul 23, 2026
8e23cfa
Document and lock in why async crypto.subtle methods cannot break qui…
NathanColosimo Jul 23, 2026
b1183fc
Merge branch 'nathanc/vm-determinism-hardening' into nathanc/retained…
NathanColosimo Jul 23, 2026
9abf488
simplify sandbox hardening: lean digest input conversion, async diges…
NathanColosimo Jul 24, 2026
9e46069
Merge branch 'nathanc/vm-determinism-hardening' into nathanc/retained…
NathanColosimo Jul 24, 2026
3cd4d07
simplify retention: single decision site in suspension catch, steps-o…
NathanColosimo Jul 24, 2026
db1665f
mark sandbox API removals as a major change
NathanColosimo Jul 24, 2026
7ecf79c
Merge branch 'nathanc/vm-determinism-hardening' into nathanc/retained…
NathanColosimo Jul 24, 2026
d5be01d
Merge remote-tracking branch 'origin/main' into nathanc/retained-vm-core
NathanColosimo Jul 27, 2026
8cea528
simplify retention further: one staleness mechanism (generation bump …
NathanColosimo Jul 27, 2026
a16acd3
simplify session API and tests: replace executeWorkflow overloads wit…
NathanColosimo Jul 28, 2026
d9e3b71
add parallel-batch retention test (sibling signal absorption) and doc…
NathanColosimo Jul 29, 2026
5bc0d11
simplify workflow.ts types: 5 named types (WorkflowResult/WorkflowRes…
NathanColosimo Jul 29, 2026
ad78ad1
Merge remote-tracking branch 'origin/main' into nathanc/retained-vm-core
NathanColosimo Jul 30, 2026
e1e5399
add retention-interleaving e2e (retained/demoted/wait/hook boundaries…
NathanColosimo Jul 30, 2026
43a6347
Merge remote-tracking branch 'origin/main' into nathanc/retained-vm-core
NathanColosimo Aug 2, 2026
e0dea14
discard the retained session on every in-process 412 restart
NathanColosimo Aug 3, 2026
e7cfaa7
review round 2: set suspensionGeneration in typed test harness contex…
NathanColosimo Aug 3, 2026
eec9a09
simplify pass: reuse once() from @workflow/utils for the open-hook/wa…
NathanColosimo Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/retain-workflow-vm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': minor
'workflow': minor
---

Retain workflow execution across inline steps within one invocation; `WORKFLOW_RETAINED_VM=0` disables retention. Boundaries whose step arguments are not primitive values fall back to ordinary replay.
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ This method runs inside the workflow context and is subject to the same constrai
- No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.)
- No non-deterministic operations (like `Math.random()` or `Date.now()`)
- No external network calls
- No side effects on workflow state — the method may run outside deterministic replay, so mutations would not be reconstructed

Keep this method simple and focused on extracting data from the instance.
</Callout>
8 changes: 8 additions & 0 deletions docs/content/docs/v5/configuration/runtime-tuning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
- Use only when step side effects are idempotent.
- Set `0` or `false` to force it off, including the first-delivery fast path used by `WORKFLOW_TURBO`.

### `WORKFLOW_RETAINED_VM`

- Default: enabled
- Keeps the suspended workflow VM alive across inline steps within one invocation, so each iteration of the inline loop appends only the newly written events instead of replaying the whole event log in a fresh VM.
- Suspensions involving hooks, waits, or attributes — and any replay divergence — always fall back to a full replay.
- Step inputs of primitive values remain retainable; anything whose serialization could execute workflow code falls back to replay for that boundary. (Support for plain objects, arrays, and standard built-ins lands in a follow-up.)
- Set `0` or `false` to replay from scratch in a fresh VM on every iteration.

### `WORKFLOW_INLINE_OWNERSHIP`

- Default: enabled
Expand Down
25 changes: 25 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,31 @@ describe('e2e', () => {
expect(returnValue).toBe('B');
});

// Interleaves VM-retention modes: retained boundaries (primitive step
// args), demoted boundaries (object args), sleeps, a step-vs-sleep race,
// and a hook resolved in parallel with a step. Asserts the exact composite
// result so a dropped/duplicated/misordered boundary fails loudly.
test('retainedInterleavingWorkflow', { timeout: 90_000 }, async () => {
const token = Math.random().toString(36).slice(2);
const run = await start(await e2e('retainedInterleavingWorkflow'), [token]);

const hook = await waitForHook(token, { runId: run.runId });
await resumeHook(hook, { delta: 5 });

const returnValue = await run.returnValue;
expect(returnValue).toEqual({
a: 3,
b: 3,
c: 13,
d: 23,
e: 13,
f: 24,
winner: 'step',
g: 137,
h: 142,
});
});

test.skipIf(!isNext)(
'importedStepOnlyWorkflow',
{ timeout: 60_000 },
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/delivery-barrier-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
const promiseQueueHolder = { current: Promise.resolve() };
const ctxRef: { current?: WorkflowOrchestratorContext } = {};
const ctx: WorkflowOrchestratorContext = {
suspensionGeneration: 0,
runId: 'wrun_test',
encryptionKey: undefined,
replayPayloadCache: new ReplayPayloadCache(undefined),
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/events-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export interface EventsConsumerOptions {

export class EventsConsumer {
eventIndex: number;
readonly events: Event[] = [];
readonly events: Event[];
readonly callbacks: EventConsumerCallback[] = [];
private onConsumedEvent?: (event: Event) => void;
private onUnconsumedEvent: (event: Event) => void;
Expand All @@ -79,13 +79,21 @@ export class EventsConsumer {
private unconsumedCheckVersion = 0;

constructor(events: Event[], options: EventsConsumerOptions) {
this.events = events;
// Own copy: the runtime mutates its event array in place, and a retained
// session must only observe new events through append() so resume()'s
// strict-extension check stays meaningful.
this.events = [...events];
this.eventIndex = 0;
this.onConsumedEvent = options.onConsumedEvent;
this.onUnconsumedEvent = options.onUnconsumedEvent;
this.getPromiseQueue = options.getPromiseQueue;
}

append(events: Event[]): void {
for (const event of events) this.events.push(event);
process.nextTick(this.consume);
}

/**
* Registers a callback function to be called after an event has been consumed
* by a different callback. The callback can return:
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/hook-sleep-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext {
// silently dropping them.
const ctxRef: { current?: WorkflowOrchestratorContext } = {};
const ctx: WorkflowOrchestratorContext = {
suspensionGeneration: 0,
runId: 'wrun_test',
encryptionKey: undefined,
replayPayloadCache: new ReplayPayloadCache(undefined),
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ export interface WorkflowOrchestratorContext {
encryptionKey: PayloadKey | undefined;
worldCapabilities?: WorldCapabilities;
globalThis: typeof globalThis;
/**
* Increments when a suspension is accepted and on every retained-session
* resume. STEP suspension signals capture it when scheduled and no-op if
* it moved (see step.ts) — this drops same-boundary sibling signals and
* timers queued at boundary N that would fire after the session resumed
* into boundary N+1. Sleep/hook/attribute signals are intentionally
* unguarded: their presence makes the boundary unretainable, so a late
* signal correctly demotes the session (workflow.ts `onWorkflowError`).
*/
suspensionGeneration: number;
eventsConsumer: EventsConsumer;
/**
* Map of pending invocations keyed by correlationId.
Expand Down
Loading
Loading