Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 349
fix: serialize Run across runtime and workflow VM#1616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
91b207355a171d1e099946191329407a19fFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@workflow/core": minor | ||
| "workflow": minor | ||
| --- | ||
| Use custom class serialization for `Run` across runtime and workflow VM contexts, and add e2e coverage for `Run` instance boundary roundtrips |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -32,6 +32,10 @@ | ||
| "types": "./dist/runtime.d.ts", | ||
| "default": "./dist/runtime.js" | ||
| }, | ||
| "./runtime/run": { | ||
| "types": "./dist/runtime/run.d.ts", | ||
TooTallNate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| "default": "./dist/runtime/run.js" | ||
| }, | ||
| "./runtime/start": { | ||
| "types": "./dist/runtime/start.d.ts", | ||
| "default": "./dist/runtime/start.js" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -4,6 +4,7 @@ import { | ||
| WorkflowRunNotCompletedError, | ||
| WorkflowRunNotFoundError, | ||
| } from '@workflow/errors'; | ||
| import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; | ||
| import { | ||
| SPEC_VERSION_CURRENT, | ||
| type WorkflowRunStatus, | ||
| @@ -69,6 +70,17 @@ export interface WorkflowReadableStreamOptions { | ||
| * A handler class for a workflow run. | ||
| */ | ||
| export class Run<TResult> { | ||
| static [WORKFLOW_SERIALIZE](instance: Run<unknown>) { | ||
| return { runId: instance.runId, resilientStart: instance.#resilientStart }; | ||
| } | ||
| static [WORKFLOW_DESERIALIZE](data: { | ||
| runId: string; | ||
| resilientStart?: boolean; | ||
| }) { | ||
| return new Run(data.runId, { resilientStart: data.resilientStart }); | ||
| } | ||
| /** | ||
| * The ID of the workflow run. | ||
| */ | ||
| @@ -78,14 +90,18 @@ export class Run<TResult> { | ||
| * The world object. | ||
| * @internal | ||
| */ | ||
| private worldPromise: Promise<World>; | ||
| #worldPromise: Promise<World> | undefined; | ||
| get #lazyWorldPromise() { | ||
| if (!this.#worldPromise) this.#worldPromise = getWorld(); | ||
| return this.#worldPromise; | ||
| } | ||
| /** | ||
| * Cached encryption key resolution. Resolved once on first use and | ||
| * reused for returnValue, getReadable(), etc. | ||
| * @internal | ||
| */ | ||
| private encryptionKeyPromise: Promise<CryptoKey | undefined> | null = null; | ||
| #encryptionKeyPromise: Promise<CryptoKey | undefined> | null = null; | ||
| /** | ||
| * When true, run_created failed and the run may not exist yet (the | ||
| @@ -94,12 +110,11 @@ export class Run<TResult> { | ||
| * that normal runs fail fast on 404. | ||
| * @internal | ||
| */ | ||
| private resilientStart = false; | ||
| #resilientStart = false; | ||
| constructor(runId: string, opts?: { resilientStart?: boolean }) { | ||
| this.runId = runId; | ||
| this.worldPromise = getWorld(); | ||
| this.resilientStart = opts?.resilientStart ?? false; | ||
| this.#resilientStart = opts?.resilientStart ?? false; | ||
| } | ||
| /** | ||
| @@ -108,16 +123,16 @@ export class Run<TResult> { | ||
| * to be resolved once. | ||
| * @internal | ||
| */ | ||
| private getEncryptionKey(): Promise<CryptoKey | undefined> { | ||
| if (!this.encryptionKeyPromise) { | ||
| this.encryptionKeyPromise = (async () => { | ||
| const world = await this.worldPromise; | ||
| #getEncryptionKey(): Promise<CryptoKey | undefined> { | ||
| if (!this.#encryptionKeyPromise) { | ||
| this.#encryptionKeyPromise = (async () => { | ||
| const world = await this.#lazyWorldPromise; | ||
| const run = await world.runs.get(this.runId); | ||
| const rawKey = await world.getEncryptionKeyForRun?.(run); | ||
| return rawKey ? await importKey(rawKey) : undefined; | ||
| })(); | ||
| } | ||
| return this.encryptionKeyPromise; | ||
| return this.#encryptionKeyPromise; | ||
| } | ||
| /** | ||
| @@ -128,14 +143,16 @@ export class Run<TResult> { | ||
| * @returns A {@link StopSleepResult} object containing the number of sleep calls that were interrupted. | ||
| */ | ||
| async wakeUp(options?: StopSleepOptions): Promise<StopSleepResult> { | ||
| return wakeUpRun(await this.worldPromise, this.runId, options); | ||
| 'use step'; | ||
| return wakeUpRun(await this.#lazyWorldPromise, this.runId, options); | ||
| } | ||
| /** | ||
| * Cancels the workflow run. | ||
| */ | ||
| async cancel(): Promise<void> { | ||
| const world = await this.worldPromise; | ||
| 'use step'; | ||
| const world = await this.#lazyWorldPromise; | ||
| await world.events.create(this.runId, { | ||
| eventType: 'run_cancelled', | ||
| specVersion: SPEC_VERSION_CURRENT, | ||
| @@ -146,7 +163,8 @@ export class Run<TResult> { | ||
| * Whether the workflow run exists. | ||
| */ | ||
| get exists(): Promise<boolean> { | ||
| return this.worldPromise.then((world) => | ||
| 'use step'; | ||
| return this.#lazyWorldPromise.then((world) => | ||
| world.runs | ||
| .get(this.runId, { resolveData: 'none' }) | ||
| .then(() => true) | ||
| @@ -163,7 +181,8 @@ export class Run<TResult> { | ||
| * The status of the workflow run. | ||
| */ | ||
| get status(): Promise<WorkflowRunStatus> { | ||
| return this.worldPromise.then((world) => | ||
| 'use step'; | ||
| return this.#lazyWorldPromise.then((world) => | ||
| world.runs.get(this.runId).then((run) => run.status) | ||
| ); | ||
| } | ||
| @@ -173,14 +192,16 @@ export class Run<TResult> { | ||
| * Polls the workflow return value until it is completed. | ||
| */ | ||
| get returnValue(): Promise<TResult> { | ||
| return this.pollReturnValue(); | ||
| 'use step'; | ||
| return this.#pollReturnValue(); | ||
| } | ||
| /** | ||
| * The name of the workflow. | ||
| */ | ||
| get workflowName(): Promise<string> { | ||
| return this.worldPromise.then((world) => | ||
| 'use step'; | ||
| return this.#lazyWorldPromise.then((world) => | ||
| world.runs.get(this.runId).then((run) => run.workflowName) | ||
| ); | ||
| } | ||
| @@ -189,7 +210,8 @@ export class Run<TResult> { | ||
| * The timestamp when the workflow run was created. | ||
| */ | ||
| get createdAt(): Promise<Date> { | ||
| return this.worldPromise.then((world) => | ||
| 'use step'; | ||
| return this.#lazyWorldPromise.then((world) => | ||
| world.runs.get(this.runId).then((run) => run.createdAt) | ||
| ); | ||
| } | ||
| @@ -199,7 +221,8 @@ export class Run<TResult> { | ||
| * Returns undefined if the workflow has not started yet. | ||
| */ | ||
| get startedAt(): Promise<Date | undefined> { | ||
| return this.worldPromise.then((world) => | ||
| 'use step'; | ||
| return this.#lazyWorldPromise.then((world) => | ||
| world.runs.get(this.runId).then((run) => run.startedAt) | ||
| ); | ||
| } | ||
| @@ -209,7 +232,8 @@ export class Run<TResult> { | ||
| * Returns undefined if the workflow has not completed yet. | ||
| */ | ||
| get completedAt(): Promise<Date | undefined> { | ||
| return this.worldPromise.then((world) => | ||
| 'use step'; | ||
| return this.#lazyWorldPromise.then((world) => | ||
| world.runs.get(this.runId).then((run) => run.completedAt) | ||
| ); | ||
| } | ||
| @@ -236,11 +260,12 @@ export class Run<TResult> { | ||
| getReadable<R = any>( | ||
| options: WorkflowReadableStreamOptions = {} | ||
| ): WorkflowReadableStream<R> { | ||
| 'use step'; | ||
TooTallNate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const { ops = [], global = globalThis, startIndex, namespace } = options; | ||
| const name = getWorkflowRunStreamId(this.runId, namespace); | ||
| // Pass the key as a promise — it will be resolved lazily inside | ||
| // the first async transform() call of the deserialize stream. | ||
| const encryptionKey = this.getEncryptionKey(); | ||
| const encryptionKey = this.#getEncryptionKey(); | ||
| const stream = getExternalRevivers( | ||
| global, | ||
| ops, | ||
| @@ -251,7 +276,7 @@ export class Run<TResult> { | ||
| startIndex, | ||
| }) as ReadableStream<R>; | ||
| const worldPromise = this.worldPromise; | ||
| const worldPromise = this.#lazyWorldPromise; | ||
| const runId = this.runId; | ||
| return Object.assign(stream, { | ||
| getTailIndex: async (): Promise<number> => { | ||
| @@ -267,24 +292,24 @@ export class Run<TResult> { | ||
| * @internal | ||
| * @returns The workflow return value. | ||
| */ | ||
| private async pollReturnValue(): Promise<TResult> { | ||
| const world = await this.worldPromise; | ||
| async #pollReturnValue(): Promise<TResult> { | ||
| const world = await this.#lazyWorldPromise; | ||
| // When resilientStart is true, run_created failed and the run may | ||
| // not exist yet. Retry on WorkflowRunNotFoundError up to 3 times | ||
| // (1s + 3s + 6s = 10s total) to give the queue time to deliver | ||
| // and the runtime to create the run via run_started. | ||
| // When resilientStart is false, 404 is a real error — fail fast. | ||
| let notFoundRetries = 0; | ||
| const NOT_FOUND_MAX_RETRIES = this.resilientStart ? 3 : 0; | ||
| const NOT_FOUND_MAX_RETRIES = this.#resilientStart ? 3 : 0; | ||
| const NOT_FOUND_DELAYS = [1_000, 3_000, 6_000]; | ||
| while (true) { | ||
| try { | ||
| const run = await world.runs.get(this.runId); | ||
| if (run.status === 'completed') { | ||
| const encryptionKey = await this.getEncryptionKey(); | ||
| const encryptionKey = await this.#getEncryptionKey(); | ||
| return await hydrateWorkflowReturnValue( | ||
| run.output, | ||
| this.runId, | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,16 +1,22 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| EntityConflictError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WorkflowWorldError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WorkflowRunNotFoundError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| WorkflowWorldError, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '@workflow/errors'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Event, World } from '@workflow/world'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Mock version module to avoid missing generated file | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| vi.mock('../version.js', () => ({ version: '0.0.0-test' })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { wakeUpRun } from './runs.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { registerSerializationClass } from '../class-serialization.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dehydrateStepReturnValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hydrateStepReturnValue, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '../serialization.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Run } from './run.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { wakeUpRun } from './runs.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { setWorld } from './world.js'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function createMockWorld( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @@ -225,3 +231,46 @@ describe('Run.wakeUp', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(world.queue).not.toHaveBeenCalled(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('Run custom serialization', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // In production builds, the SWC plugin auto-registers the class via an | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // inline IIFE. In tests (no SWC), we register manually. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| registerSerializationClass('Run', Run); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| afterEach(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setWorld(undefined as unknown as World); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('should expose WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE methods', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const run = new Run('wrun_serialize'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const serialized = Run[WORKFLOW_SERIALIZE](run); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const deserialized = Run[WORKFLOW_DESERIALIZE]({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runId: 'wrun_deserialize', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(serialized).toEqual({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runId: 'wrun_serialize', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resilientStart: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(deserialized).toBeInstanceOf(Run); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(deserialized.runId).toBe('wrun_deserialize'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+246
to
+256
CopilotAI | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| constserialized=Run[WORKFLOW_SERIALIZE](run); | |
| constdeserialized=Run[WORKFLOW_DESERIALIZE]({ | |
| runId: 'wrun_deserialize', | |
| }); | |
| expect(serialized).toEqual({ | |
| runId: 'wrun_serialize', | |
| resilientStart: false, | |
| }); | |
| expect(deserialized).toBeInstanceOf(Run); | |
| expect(deserialized.runId).toBe('wrun_deserialize'); | |
| constserialized=Run[WORKFLOW_SERIALIZE](run); | |
| constresilientRun=newRun('wrun_serialize_resilient',{ | |
| resilientStart: true, | |
| }); | |
| constresilientSerialized=Run[WORKFLOW_SERIALIZE](resilientRun); | |
| constdeserialized=Run[WORKFLOW_DESERIALIZE]({ | |
| runId: 'wrun_deserialize', | |
| }); | |
| constresilientDeserialized=Run[WORKFLOW_DESERIALIZE]({ | |
| runId: 'wrun_deserialize_resilient', | |
| resilientStart: true, | |
| }); | |
| expect(serialized).toEqual({ | |
| runId: 'wrun_serialize', | |
| resilientStart: false, | |
| }); | |
| expect(resilientSerialized).toEqual({ | |
| runId: 'wrun_serialize_resilient', | |
| resilientStart: true, | |
| }); | |
| expect(deserialized).toBeInstanceOf(Run); | |
| expect(deserialized.runId).toBe('wrun_deserialize'); | |
| expect(deserialized.resilientStart).toBe(false); | |
| expect(resilientDeserialized).toBeInstanceOf(Run); | |
| expect(resilientDeserialized.runId).toBe('wrun_deserialize_resilient'); | |
| expect(resilientDeserialized.resilientStart).toBe(true); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -7,17 +7,14 @@ export type { | ||
| WorkflowRun, | ||
| } from '@workflow/core/runtime'; | ||
| export { Run } from '@workflow/core/runtime/run'; | ||
TooTallNate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const workflowStub = (item: string) => { | ||
| throw new Error( | ||
| `The workflow environment doesn't allow this runtime usage of ${item}. Move this call to a step function ("use step") or call it outside the workflow context.` | ||
| ); | ||
| }; | ||
| export class Run { | ||
| constructor() { | ||
| workflowStub('Run'); | ||
| } | ||
| } | ||
| export const getRun = () => workflowStub('getRun'); | ||
| export const getHookByToken = () => workflowStub('getHookByToken'); | ||
| export const resumeHook = () => workflowStub('resumeHook'); | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.