Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/lazy-hook-replay-preload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/world': minor
'@workflow/world-vercel': patch
'@workflow/core': patch
---

Initialize lazy hook resume replay from the `hook_received` write via the new advisory `preloadEvents` param, skipping `run_started` and the initial `events.list`; safe fallback otherwise.
230 changes: 227 additions & 3 deletions packages/core/src/runtime.ts

Large diffs are not rendered by default.

197 changes: 197 additions & 0 deletions packages/core/src/runtime/quickjs-entrypoint.preload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* Pins the QuickJS engine's event-log sourcing for the lazy hook resume
* fast path: a caller-attested complete preload (`preloadedEventsComplete`)
* is trusted as the full log — no `events.list` — while a non-attested
* hook-containing preload is NOT trusted (the first-invocation heuristic
* only recognizes run_created/run_started-only preloads) and the engine
* fetches the log itself. Also pins the non-empty guard on the attestation.
*
* The QuickJS VM itself is mocked (its WASM import chain is irrelevant to
* the sourcing decision): `startQuickJSWorkflow` records which events the
* engine handed it and completes immediately.
*/
import {
type CreateEventRequest,
type Event,
SPEC_VERSION_CURRENT,
type WorkflowRun,
type World,
} from '@workflow/world';
import { monotonicFactory } from 'ulid';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { dehydrateStepReturnValue } from '../serialization.js';
import { setWorld } from './world.js';

vi.mock('@vercel/functions', () => ({ waitUntil: vi.fn() }));
vi.mock('./get-port-lazy.js', () => ({
getPortLazy: vi.fn().mockResolvedValue(3000),
}));

const startQuickJSWorkflow = vi.fn();
vi.mock('./quickjs-runtime.js', () => ({
startQuickJSWorkflow: (...args: unknown[]) => startQuickJSWorkflow(...args),
}));

async function runQuickJSScenario(options: {
preloadedEvents?: Event[];
preloadedEventsComplete?: boolean;
}) {
const runId = 'wrun_quickjs_preload';
const workflowName = 'workflow';
const startedAt = new Date('2026-05-19T12:00:00.000Z');

const workflowRun: WorkflowRun = {
runId,
workflowName,
status: 'running',
input: [],
deploymentId: 'dpl_quickjs_preload',
specVersion: SPEC_VERSION_CURRENT,
startedAt,
createdAt: startedAt,
updatedAt: startedAt,
};

const durableEvents = options.preloadedEvents ?? [];

const createdEvents: CreateEventRequest[] = [];
let listCallCount = 0;
const listEvents = vi.fn(async () => {
// Model real pagination: the full log on the first page, then an empty
// terminal page (a fake that always returns rows would loop the
// engine's fetch-all forever).
listCallCount++;
if (listCallCount === 1) {
return {
data: [...durableEvents],
cursor: durableEvents.at(-1)?.eventId ?? null,
hasMore: false,
};
}
return { data: [], cursor: null, hasMore: false };
});
const createEvent = vi.fn(
async (_runId: string, request: CreateEventRequest) => {
createdEvents.push(request);
return { event: { ...request, runId, eventId: 'evnt_created' } };
}
);

setWorld({
specVersion: SPEC_VERSION_CURRENT,
capabilities: {},
events: { list: listEvents, create: createEvent },
runs: { get: vi.fn(async () => workflowRun) },
queue: vi.fn().mockResolvedValue({ messageId: 'msg_quickjs' }),
getEncryptionKeyForRun: vi.fn().mockResolvedValue(undefined),
} as unknown as World);

const completedResult = await dehydrateStepReturnValue(
'done',
runId,
undefined
);
startQuickJSWorkflow.mockResolvedValue({
result: { completed: { result: completedResult } },
continueWithEvents: vi.fn(),
dispose: vi.fn(),
});

const { runWorkflowWithQuickJS } = await import('./quickjs-entrypoint.js');
await runWorkflowWithQuickJS({
workflowCode: '// not evaluated: the VM is mocked',
workflowName,
workflowRun,
preloadedEvents: options.preloadedEvents,
preloadedEventsComplete: options.preloadedEventsComplete,
});

expect(startQuickJSWorkflow).toHaveBeenCalledTimes(1);
const vmEvents = (
startQuickJSWorkflow.mock.calls[0][0] as { events: Event[] }
).events;

return { listEvents, createdEvents, vmEvents };
}

function makeHookResumeLog(runId: string): Event[] {
const hostUlid = monotonicFactory();
const startedAt = new Date('2026-05-19T12:00:00.000Z');
let eventIndex = 0;
const event = (data: Record<string, unknown>): Event => {
const t = +startedAt + ++eventIndex * 100;
return {
specVersion: SPEC_VERSION_CURRENT,
...data,
runId,
eventId: `evnt_${hostUlid(t)}`,
createdAt: new Date(t),
} as Event;
};
return [
event({
eventType: 'run_created',
eventData: {
deploymentId: 'dpl_quickjs_preload',
workflowName: 'workflow',
input: [],
},
}),
event({ eventType: 'run_started' }),
event({
eventType: 'hook_created',
correlationId: 'hook_1',
eventData: { token: 'tok-quickjs' },
}),
event({
eventType: 'hook_received',
correlationId: 'hook_1',
resumeId: 'resume-quickjs-1',
eventData: { token: 'tok-quickjs', payload: new Uint8Array() },
}),
];
}

describe('QuickJS lazy hook preload sourcing', () => {
afterEach(() => {
setWorld(undefined);
vi.clearAllMocks();
});

it('trusts an attested complete preload: no events.list, VM gets the provided log', async () => {
const log = makeHookResumeLog('wrun_quickjs_preload');
const { listEvents, createdEvents, vmEvents } = await runQuickJSScenario({
preloadedEvents: log,
preloadedEventsComplete: true,
});

expect(listEvents).not.toHaveBeenCalled();
expect(vmEvents.map((e) => e.eventId)).toEqual(log.map((e) => e.eventId));
// The engine never posts run_started itself; the only write on this
// invocation is the completion.
expect(createdEvents.map((e) => e.eventType)).toEqual(['run_completed']);
});

it('does not trust a hook-containing preload without the attestation: fetches via events.list', async () => {
const log = makeHookResumeLog('wrun_quickjs_preload');
const { listEvents, vmEvents } = await runQuickJSScenario({
preloadedEvents: log,
preloadedEventsComplete: false,
});

// The first-invocation heuristic rejects a log with hook events, so the
// engine fetched the authoritative log itself...
expect(listEvents).toHaveBeenCalled();
// ...and replayed what the fetch returned.
expect(vmEvents.map((e) => e.eventId)).toEqual(log.map((e) => e.eventId));
});

it('does not trust an attested but empty preload: fetches via events.list', async () => {
const { listEvents } = await runQuickJSScenario({
preloadedEvents: [],
preloadedEventsComplete: true,
});

expect(listEvents).toHaveBeenCalled();
});
});
27 changes: 21 additions & 6 deletions packages/core/src/runtime/quickjs-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,12 +539,21 @@ export async function runWorkflowWithQuickJS(params: {
workflowName: string;
workflowRun: WorkflowRun;
/**
* Events returned inline by `events.create('run_started', ...)`. When
* they indicate a first invocation, they are used as the event log
* instead of fetching via `events.list`, matching the node:vm engine's
* fast path.
* Events returned inline by `events.create('run_started', ...)` or by
* the lazy hook fast path's `hook_received` preload. When they indicate
* a first invocation — or when `preloadedEventsComplete` attests they
* are the complete log — they are used as the event log instead of
* fetching via `events.list`, matching the node:vm engine's fast path.
*/
preloadedEvents?: Event[];
/**
* True when the caller has validated that `preloadedEvents` is the run's
* COMPLETE event log (e.g. the lazy hook fast path's hasMore-false
* replay preload). The first-invocation heuristic below only recognizes
* run_created/run_started-only preloads, so without this attestation a
* hook-resume preload would be discarded and refetched.
*/
preloadedEventsComplete?: boolean;
/**
* Run input carried through the queue message on first delivery. Used
* as a last-resort fallback for `run_created.eventData.input` when
Expand Down Expand Up @@ -601,6 +610,7 @@ export async function runWorkflowWithQuickJS(params: {
workflowName,
workflowRun,
preloadedEvents,
preloadedEventsComplete,
runInput,
parentSpan,
maxEventsLimit,
Expand Down Expand Up @@ -672,10 +682,15 @@ export async function runWorkflowWithQuickJS(params: {

// Load the FULL event log for the run. On first invocation the
// preloaded events from the run_started response are the complete log
// and save the events.list round-trips.
// and save the events.list round-trips; a caller-attested complete
// preload (lazy hook fast path) is trusted the same way.
let events: Event[];
let eventsFetchedPages = 0;
const usePreloaded = isFirstInvocation(preloadedEvents);
const usePreloaded =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: the new consumer-preload tests drive only the node:vm engine, so this gate — trusting an attested-complete preload as the full log, and (critically) NOT trusting a fallback run_started preload beyond first invocation — has zero coverage. If a future edit drops the preloadedEventsComplete === true arm or the length guard, QuickJS hook resumes would replay from a bounded run_started page and silently drop the log tail. One QuickJS-engine test each for "attested complete → used as full log" and "fallback preload → refetches via events.list" would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ce3581c with focused QuickJS sourcing tests. They assert that an attested non-empty complete preload is used verbatim with no events.list, a non-attested hook-containing preload is refetched, and even an attested empty preload is rejected and refetched. This pins both the preloadedEventsComplete === true arm and the non-empty guard.

(preloadedEventsComplete === true &&
Array.isArray(preloadedEvents) &&
preloadedEvents.length > 0) ||
isFirstInvocation(preloadedEvents);
if (usePreloaded && preloadedEvents) {
events = preloadedEvents;
} else {
Expand Down
Loading
Loading