Skip to content

Commit 06f4bc8

Browse files
committed
feat(workflow): support TypeInfo for workflow transitions
1 parent 68c9a75 commit 06f4bc8

6 files changed

Lines changed: 216 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ to docs, or any other relevant information.
2222
### Added
2323

2424
- **Experimental**: Added TypeInfo APIs for transforming application values and supplying converter-specific
25-
metadata during payload conversion, including client-started Workflow inputs and results.
25+
metadata during payload conversion, including client-started Workflow inputs and results, child Workflow calls,
26+
and continue-as-new.
2627

2728
## [1.21.0] - 2026-07-23
2829

packages/test/src/test-type-info.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomUUID } from 'crypto';
22
import type { ExecutionContext } from 'ava';
3-
import { Client, WithStartWorkflowOperation } from '@temporalio/client';
3+
import { Client, WithStartWorkflowOperation, WorkflowFailedError } from '@temporalio/client';
44
import { workflowInterceptorModules } from '@temporalio/testing';
55
import { bundleWorkflowCode } from '@temporalio/worker';
66
import type { TestWorkflowEnvironment } from './helpers';
@@ -12,9 +12,13 @@ import {
1212
makeConfigurableEnvironmentTestFn,
1313
} from './helpers-integration';
1414
import {
15+
parentWorkflowChildDefinition,
16+
parentWorkflowChildDefinitionInvalidCallSiteTypeInfo,
17+
continueAsNewToWorkflowWithTypeInfo,
1518
finishSignal,
1619
finishUpdate,
1720
Order,
21+
parentWorkflowChildString,
1822
Receipt,
1923
workflowTypeInfo,
2024
workflowWithSignalStart,
@@ -158,6 +162,38 @@ test('detached workflow handle uses call-site output type information', async (t
158162
});
159163
});
160164

165+
test('same-type continue-as-new reuses definition-supplied input and output type information', async (t) => {
166+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
167+
const client = makeClient(t.context.env);
168+
const worker = await h.createWorker({ dataConverter });
169+
170+
await worker.runUntil(async () => {
171+
const result = await client.workflow.execute(workflowWithTypeInfo, {
172+
workflowId: `wf-${randomUUID()}`,
173+
taskQueue: h.taskQueue,
174+
args: [new Order('order-1', 12345n, 1)],
175+
});
176+
177+
assertReceipt(t, result);
178+
});
179+
});
180+
181+
test('continue-as-new to a different workflow uses explicit input type information', async (t) => {
182+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
183+
const client = makeClient(t.context.env);
184+
const worker = await h.createWorker({ dataConverter });
185+
186+
await worker.runUntil(async () => {
187+
const result = await client.workflow.execute(continueAsNewToWorkflowWithTypeInfo, {
188+
workflowId: `wf-${randomUUID()}`,
189+
taskQueue: h.taskQueue,
190+
args: [new Order('order-1', 12345n)],
191+
});
192+
193+
assertReceipt(t, result);
194+
});
195+
});
196+
161197
test('signal-with-start carries definition-supplied workflow type information', async (t) => {
162198
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
163199
const client = makeClient(t.context.env);
@@ -195,3 +231,54 @@ test('update-with-start carries definition-supplied workflow type information',
195231
assertReceipt(t, await (await startOperation.workflowHandle()).result());
196232
});
197233
});
234+
235+
test('child workflow uses definition-supplied input and output type information', async (t) => {
236+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
237+
const client = makeClient(t.context.env);
238+
const worker = await h.createWorker({ dataConverter });
239+
240+
await worker.runUntil(async () => {
241+
const result = await client.workflow.execute(parentWorkflowChildDefinition, {
242+
workflowId: `wf-${randomUUID()}`,
243+
taskQueue: h.taskQueue,
244+
args: [new Order('order-1', 12345n)],
245+
});
246+
247+
assertReceipt(t, result);
248+
});
249+
});
250+
251+
test('child workflow uses call-site input and output type information for string workflow type', async (t) => {
252+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
253+
const client = makeClient(t.context.env);
254+
const worker = await h.createWorker({ dataConverter });
255+
256+
await worker.runUntil(async () => {
257+
const result = await client.workflow.execute(parentWorkflowChildString, {
258+
workflowId: `wf-${randomUUID()}`,
259+
taskQueue: h.taskQueue,
260+
args: [new Order('order-1', 12345n)],
261+
});
262+
263+
assertReceipt(t, result);
264+
});
265+
});
266+
267+
test('child workflow definition with call-site type information is invalid', async (t) => {
268+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
269+
const client = makeClient(t.context.env);
270+
const worker = await h.createWorker({ dataConverter });
271+
272+
await worker.runUntil(async () => {
273+
const err = await t.throwsAsync(
274+
client.workflow.execute(parentWorkflowChildDefinitionInvalidCallSiteTypeInfo, {
275+
workflowId: `wf-${randomUUID()}`,
276+
taskQueue: h.taskQueue,
277+
args: [new Order('order-1', 12345n)],
278+
}),
279+
{ instanceOf: WorkflowFailedError }
280+
);
281+
282+
t.regex(err?.cause?.message ?? '', /Workflow type information cannot be supplied at the call site/);
283+
});
284+
});

packages/test/src/workflows/type-info.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import type { PayloadTypeInfo, TypeInfo } from '@temporalio/common';
2-
import { condition, defineSignal, defineUpdate, defineWorkflowOptions, setHandler } from '@temporalio/workflow';
2+
import {
3+
condition,
4+
continueAsNew,
5+
defineSignal,
6+
defineUpdate,
7+
defineWorkflowOptions,
8+
executeChild,
9+
makeContinueAsNewFunc,
10+
setHandler,
11+
} from '@temporalio/workflow';
312

413
export class Order {
514
constructor(
@@ -75,14 +84,72 @@ function assertOrder(order: Order): void {
7584
}
7685
}
7786

87+
function assertReceipt(receipt: Receipt): void {
88+
if (!(receipt instanceof Receipt)) {
89+
throw new Error('Expected Receipt result');
90+
}
91+
if (typeof receipt.totalCents !== 'bigint') {
92+
throw new Error('Expected Receipt.totalCents to be a bigint');
93+
}
94+
}
95+
7896
defineWorkflowOptions(workflowWithTypeInfo, {
7997
staticOptions: { typeInfo: workflowTypeInfo },
8098
});
8199
export async function workflowWithTypeInfo(order: Order): Promise<Receipt> {
82100
assertOrder(order);
101+
if (order.remainingRuns > 0) {
102+
await continueAsNew(new Order(order.id, order.totalCents, order.remainingRuns - 1));
103+
}
83104
return new Receipt(order.id, order.totalCents);
84105
}
85106

107+
export async function parentWorkflowChildDefinition(order: Order): Promise<Receipt> {
108+
assertOrder(order);
109+
const receipt = await executeChild(workflowWithTypeInfo, { args: [order] });
110+
assertReceipt(receipt);
111+
return receipt;
112+
}
113+
defineWorkflowOptions(parentWorkflowChildDefinition, {
114+
staticOptions: { typeInfo: workflowTypeInfo },
115+
});
116+
117+
export async function parentWorkflowChildString(order: Order): Promise<Receipt> {
118+
assertOrder(order);
119+
const receipt = await executeChild('workflowWithTypeInfo', {
120+
args: [order],
121+
typeInfo: workflowTypeInfo,
122+
});
123+
assertReceipt(receipt);
124+
return receipt;
125+
}
126+
defineWorkflowOptions(parentWorkflowChildString, {
127+
staticOptions: { typeInfo: workflowTypeInfo },
128+
});
129+
130+
export async function parentWorkflowChildDefinitionInvalidCallSiteTypeInfo(order: Order): Promise<void> {
131+
await executeChild(workflowWithTypeInfo, {
132+
args: [order],
133+
typeInfo: workflowTypeInfo,
134+
});
135+
}
136+
defineWorkflowOptions(parentWorkflowChildDefinitionInvalidCallSiteTypeInfo, {
137+
workflowDefinitionOptions: { failureExceptionTypes: [TypeError] },
138+
staticOptions: { typeInfo: workflowTypeInfo },
139+
});
140+
141+
export async function continueAsNewToWorkflowWithTypeInfo(order: Order): Promise<Receipt> {
142+
assertOrder(order);
143+
const continueAsTypedWorkflow = makeContinueAsNewFunc<typeof workflowWithTypeInfo>({
144+
workflowType: 'workflowWithTypeInfo',
145+
typeInfo: { inputTypes: workflowTypeInfo.inputTypes },
146+
});
147+
return await continueAsTypedWorkflow(order);
148+
}
149+
defineWorkflowOptions(continueAsNewToWorkflowWithTypeInfo, {
150+
staticOptions: { typeInfo: workflowTypeInfo },
151+
});
152+
86153
export const finishSignal = defineSignal('finish');
87154

88155
export async function workflowWithSignalStart(order: Order): Promise<Receipt> {

packages/workflow/src/interfaces.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
VersioningBehavior,
1717
InitialVersioningBehavior,
1818
SuggestContinueAsNewReason,
19+
PayloadTypeInfo,
1920
} from '@temporalio/common';
2021
import { SymbolBasedInstanceOfError } from '@temporalio/common/lib/type-helpers';
2122
import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow/enums-helpers';
@@ -398,6 +399,17 @@ export interface ContinueAsNewOptions {
398399
* @experimental Versioning semantics with continue-as-new are experimental and may change in the future.
399400
*/
400401
initialVersioningBehavior?: InitialVersioningBehavior;
402+
403+
/**
404+
* Input type information for the next Workflow run.
405+
*
406+
* When continuing as new to the same Workflow type, its definition-supplied input type information is reused.
407+
* The next Workflow must preserve the current Workflow's output contract because clients retain the original
408+
* handle's output type information across the run chain.
409+
*
410+
* @experimental
411+
*/
412+
typeInfo?: Pick<PayloadTypeInfo, 'inputTypes'>;
401413
}
402414

403415
/**
@@ -554,6 +566,13 @@ export const [encodeParentClosePolicy, decodeParentClosePolicy] = makeProtoEnumC
554566
);
555567

556568
export interface ChildWorkflowOptions extends Omit<CommonWorkflowOptions, 'workflowIdConflictPolicy'> {
569+
/**
570+
* Type information used to encode and decode Child Workflow input and output.
571+
*
572+
* @experimental
573+
*/
574+
typeInfo?: PayloadTypeInfo;
575+
557576
/**
558577
* Workflow id to use when starting. If not specified a UUID is generated. Note that it is
559578
* dangerous as in case of client side retries no deduplication will happen based on the

packages/workflow/src/internals.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
WorkflowDefinitionOptions,
1717
WorkflowSerializationContext,
1818
PayloadTypeInfo,
19+
TypeInfo,
1920
} from '@temporalio/common';
2021
import {
2122
defaultFailureConverter,
@@ -116,6 +117,7 @@ export interface Completion<Success, Context = never> {
116117
resolve(val: Success): void;
117118
reject(reason: Error): void;
118119
context?: Context;
120+
outputTypeInfo?: TypeInfo;
119121
}
120122

121123
export interface Condition {
@@ -755,10 +757,15 @@ export class Activator implements ActivationHandler {
755757
if (!activation.result) {
756758
throw new TypeError('Got ResolveChildWorkflowExecution activation with no result');
757759
}
758-
const { resolve, reject, context } = this.consumeCompletion('childWorkflowComplete', getSeq(activation));
760+
const { resolve, reject, context, outputTypeInfo } = this.consumeCompletion(
761+
'childWorkflowComplete',
762+
getSeq(activation)
763+
);
759764
if (activation.result.completed) {
760765
const completed = activation.result.completed;
761-
const result = completed.result ? this.payloadConverter.fromPayload(completed.result, context) : undefined;
766+
const result = completed.result
767+
? fromPayloadsAtIndex(this.payloadConverter, 0, [completed.result], context, outputTypeInfo)
768+
: undefined;
762769
resolve(result);
763770
} else if (activation.result.failed) {
764771
const { failure } = activation.result.failed;

0 commit comments

Comments
 (0)