Skip to content

Commit 4633b4f

Browse files
authored
fix: Better error messages, and more (#1126)
- Better error message when `executeWorkflow()` and similar are given a workflowFunction fucntion that is anonymous - Better error message when Workflow-only APIs are called from non-Workflow execution context - On `create`, do not use `@tsconfig/node20` - Include proto comments in API files generated by protobufjs - Minor refactor of schedule's error handling
1 parent 1709d45 commit 4633b4f

File tree

8 files changed

+111
-40
lines changed

8 files changed

+111
-40
lines changed

packages/client/src/schedule-client.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,6 @@ export class ScheduleClient extends BaseClient {
260260
const res = await this.workflowService.createSchedule(req);
261261
return { conflictToken: res.conflictToken };
262262
} catch (err: any) {
263-
if (err instanceof TypeError) throw err;
264263
if (err.code === grpcStatus.ALREADY_EXISTS) {
265264
throw new ScheduleAlreadyRunning('Schedule already exists and is running', opts.scheduleId);
266265
}
@@ -292,21 +291,21 @@ export class ScheduleClient extends BaseClient {
292291
opts: CompiledScheduleUpdateOptions,
293292
header: Headers
294293
): Promise<temporal.api.workflowservice.v1.IUpdateScheduleResponse> {
294+
const req = {
295+
namespace: this.options.namespace,
296+
scheduleId,
297+
schedule: {
298+
spec: encodeScheduleSpec(opts.spec),
299+
action: await encodeScheduleAction(this.dataConverter, opts.action, header),
300+
policies: encodeSchedulePolicies(opts.policies),
301+
state: encodeScheduleState(opts.state),
302+
},
303+
identity: this.options.identity,
304+
requestId: uuid4(),
305+
};
295306
try {
296-
return await this.workflowService.updateSchedule({
297-
namespace: this.options.namespace,
298-
scheduleId,
299-
schedule: {
300-
spec: encodeScheduleSpec(opts.spec),
301-
action: await encodeScheduleAction(this.dataConverter, opts.action, header),
302-
policies: encodeSchedulePolicies(opts.policies),
303-
state: encodeScheduleState(opts.state),
304-
},
305-
identity: this.options.identity,
306-
requestId: uuid4(),
307-
});
307+
return await this.workflowService.updateSchedule(req);
308308
} catch (err: any) {
309-
if (err instanceof TypeError) throw err;
310309
this.rethrowGrpcError(err, scheduleId, 'Failed to update schedule');
311310
}
312311
}
@@ -380,7 +379,7 @@ export class ScheduleClient extends BaseClient {
380379
scheduleId: raw.scheduleId,
381380

382381
spec: decodeScheduleSpec(raw.info?.spec ?? {}),
383-
action: raw.info?.workflowType?.name && {
382+
action: raw.info?.workflowType && {
384383
type: 'startWorkflow',
385384
workflowType: raw.info.workflowType.name,
386385
},

packages/client/src/schedule-helpers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Long from 'long'; // eslint-disable-line import/no-named-as-default
22
import {
33
compileRetryPolicy,
44
decompileRetryPolicy,
5+
extractWorkflowType,
56
LoadedDataConverter,
67
mapFromPayloads,
78
mapToPayloads,
@@ -208,7 +209,7 @@ export function decodeOverlapPolicy(input?: temporal.api.enums.v1.ScheduleOverla
208209

209210
export function compileScheduleOptions(options: ScheduleOptions): CompiledScheduleOptions {
210211
const workflowTypeOrFunc = options.action.workflowType;
211-
const workflowType = typeof workflowTypeOrFunc === 'string' ? workflowTypeOrFunc : workflowTypeOrFunc.name;
212+
const workflowType = extractWorkflowType(workflowTypeOrFunc);
212213
return {
213214
...options,
214215
action: {
@@ -222,7 +223,7 @@ export function compileScheduleOptions(options: ScheduleOptions): CompiledSchedu
222223

223224
export function compileUpdatedScheduleOptions(options: ScheduleUpdateOptions): CompiledScheduleUpdateOptions {
224225
const workflowTypeOrFunc = options.action.workflowType;
225-
const workflowType = typeof workflowTypeOrFunc === 'string' ? workflowTypeOrFunc : workflowTypeOrFunc.name;
226+
const workflowType = extractWorkflowType(workflowTypeOrFunc);
226227
return {
227228
...options,
228229
action: {

packages/client/src/workflow-client.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
WorkflowExecutionAlreadyStartedError,
1919
WorkflowNotFoundError,
2020
WorkflowResultType,
21+
extractWorkflowType,
2122
} from '@temporalio/common';
2223
import { composeInterceptors } from '@temporalio/common/lib/interceptors';
2324
import { History } from '@temporalio/common/lib/proto-utils';
@@ -345,7 +346,7 @@ export class WorkflowClient extends BaseClient {
345346
options: WithWorkflowArgs<T, WorkflowOptions>,
346347
interceptors: WorkflowClientInterceptor[]
347348
): Promise<string> {
348-
const workflowType = typeof workflowTypeOrFunc === 'string' ? workflowTypeOrFunc : workflowTypeOrFunc.name;
349+
const workflowType = extractWorkflowType(workflowTypeOrFunc);
349350
assertRequiredWorkflowOptions(options);
350351
const compiledOptions = compileWorkflowOptions(ensureArgs(options));
351352

@@ -369,7 +370,7 @@ export class WorkflowClient extends BaseClient {
369370
options: WithWorkflowArgs<T, WorkflowSignalWithStartOptions<SA>>,
370371
interceptors: WorkflowClientInterceptor[]
371372
): Promise<string> {
372-
const workflowType = typeof workflowTypeOrFunc === 'string' ? workflowTypeOrFunc : workflowTypeOrFunc.name;
373+
const workflowType = extractWorkflowType(workflowTypeOrFunc);
373374
const { signal, signalArgs, ...rest } = options;
374375
assertRequiredWorkflowOptions(rest);
375376
const compiledOptions = compileWorkflowOptions(ensureArgs(rest));

packages/common/src/workflow-options.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,14 @@ export function compileWorkflowOptions<T extends CommonWorkflowOptions>(options:
154154
workflowTaskTimeout: msOptionalToTs(workflowTaskTimeout),
155155
};
156156
}
157+
158+
export function extractWorkflowType<T extends Workflow>(workflowTypeOrFunc: string | T): string {
159+
if (typeof workflowTypeOrFunc === 'string') return workflowTypeOrFunc as string;
160+
if (typeof workflowTypeOrFunc === 'function') {
161+
if (workflowTypeOrFunc?.name) return workflowTypeOrFunc.name;
162+
throw new TypeError('Invalid workflow type: the workflow function is anonymous');
163+
}
164+
throw new TypeError(
165+
`Invalid workflow type: expected either a string or a function, got '${typeof workflowTypeOrFunc}'`
166+
);
167+
}

packages/create-project/src/helpers/install.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,14 @@ export async function updateNodeVersion({ root }: InstallArgs): Promise<void> {
3535
const versionAlreadyInPackageJson = 16;
3636
const minimumValidVersion = 14;
3737

38-
if (currentNodeVersion >= minimumValidVersion && currentNodeVersion !== versionAlreadyInPackageJson) {
39-
const packageName = `@tsconfig/node${currentNodeVersion}`;
38+
// The @tsconfig/node20 sets "--lib es2023", which require TypeScript 5.x.
39+
// FIXME: Remove this once samples have been updated to TypeScript ^5.0.0.
40+
const maximumValidVersion = 18;
41+
42+
const tsconfigNodeVersion = Math.max(minimumValidVersion, Math.min(currentNodeVersion, maximumValidVersion));
43+
44+
if (tsconfigNodeVersion !== versionAlreadyInPackageJson) {
45+
const packageName = `@tsconfig/node${tsconfigNodeVersion}`;
4046

4147
const packageExists = await isUrlOk(`https://registry.npmjs.org/${packageName}`);
4248
if (packageExists) {

packages/proto/scripts/compile-proto.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { rm } = require('fs/promises');
1+
const { rm, readFile, writeFile } = require('fs/promises');
22
const { resolve } = require('path');
33
const { promisify } = require('util');
44
const dedent = require('dedent');
@@ -42,6 +42,7 @@ async function compileProtos(dtsOutputFile, ...args) {
4242
'commonjs',
4343
'--force-long',
4444
'--no-verify',
45+
'--alt-comment',
4546
'--root',
4647
'__temporal',
4748
resolve(require.resolve('protobufjs'), '../google/protobuf/descriptor.proto'),
@@ -59,6 +60,18 @@ async function compileProtos(dtsOutputFile, ...args) {
5960
console.log(`Creating protobuf TS definitions from ${coreProtoPath} and ${workflowServiceProtoPath}`);
6061
try {
6162
await promisify(pbjs.main)([...pbjsArgs, '--target', 'static-module', '--out', tempFile]);
63+
64+
// pbts internally calls jsdoc, which do strict validation of jsdoc tags.
65+
// Unfortunately, some protobuf comment about cron syntax contains the
66+
// "@every" shorthand at the begining of a line, making it appear as a
67+
// (invalid) jsdoc tag. Similarly, docusaurus trips on <interval> and other
68+
// things that looks like html tags. We fix both cases by rewriting these
69+
// using markdown "inline code" syntax.
70+
let tempFileContent = await readFile(tempFile, 'utf8');
71+
tempFileContent = tempFileContent.replace(/(@(?:yearly|monthly|weekly|daily|hourly|every))/g, '`$1`');
72+
tempFileContent = tempFileContent.replace(/<((?:interval|phase|timezone)(?: [^>]+)?)>/g, '`<$1>`');
73+
await writeFile(tempFile, tempFileContent, 'utf-8');
74+
6275
await promisify(pbts.main)(['--out', dtsOutputFile, tempFile]);
6376
} finally {
6477
await rm(tempFile);

packages/test/scripts/compile-proto.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@ function mtime(path) {
2828
}
2929

3030
async function compileProtos(outputFile, ...args) {
31-
const pbjsArgs = [...args, '--wrap', 'commonjs', '--force-long', '--no-verify', '--out', outputFile, ...protoFiles];
31+
const pbjsArgs = [
32+
...args,
33+
'--wrap',
34+
'commonjs',
35+
'--force-long',
36+
'--no-verify',
37+
'--alt-comment',
38+
'--out',
39+
outputFile,
40+
...protoFiles,
41+
];
3242
return await promisify(pbjs.main)(pbjsArgs);
3343
}
3444

packages/workflow/src/workflow.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ActivityFunction,
33
ActivityOptions,
44
compileRetryPolicy,
5+
extractWorkflowType,
56
IllegalStateError,
67
LocalActivityOptions,
78
mapToPayloads,
@@ -37,7 +38,7 @@ import {
3738
Handler,
3839
WorkflowInfo,
3940
} from './interfaces';
40-
import { LocalActivityDoBackoff, getActivator, maybeGetActivator } from './internals';
41+
import { Activator, LocalActivityDoBackoff, getActivator, maybeGetActivator } from './internals';
4142
import { LoggerSinks, Sinks } from './sinks';
4243
import { untrackPromise } from './stack-helpers';
4344
import { ChildWorkflowHandle, ExternalWorkflowHandle } from './workflow-handle';
@@ -108,7 +109,7 @@ function timerNextHandler(input: TimerInput) {
108109
* If given a negative number or 0, value will be set to 1.
109110
*/
110111
export function sleep(ms: number | string): Promise<void> {
111-
const activator = getActivator();
112+
const activator = assertInWorkflowContext('Workflow.sleep(...) may only be used from a Workflow Execution');
112113
const seq = activator.nextSeqs.timer++;
113114

114115
const durationMs = Math.max(1, msToNumber(ms));
@@ -250,7 +251,9 @@ async function scheduleLocalActivityNextHandler({
250251
* @hidden
251252
*/
252253
export function scheduleActivity<R>(activityType: string, args: any[], options: ActivityOptions): Promise<R> {
253-
const activator = getActivator();
254+
const activator = assertInWorkflowContext(
255+
'Workflow.scheduleActivity(...) may only be used from a Workflow Execution'
256+
);
254257
if (options === undefined) {
255258
throw new TypeError('Got empty activity options');
256259
}
@@ -275,7 +278,9 @@ export async function scheduleLocalActivity<R>(
275278
args: any[],
276279
options: LocalActivityOptions
277280
): Promise<R> {
278-
const activator = getActivator();
281+
const activator = assertInWorkflowContext(
282+
'Workflow.scheduleLocalActivity(...) may only be used from a Workflow Execution'
283+
);
279284
if (options === undefined) {
280285
throw new TypeError('Got empty activity options');
281286
}
@@ -574,7 +579,9 @@ const CONDITION_0_PATCH = '__sdk_internal_patch_number:1';
574579
* It takes a Workflow ID and optional run ID.
575580
*/
576581
export function getExternalWorkflowHandle(workflowId: string, runId?: string): ExternalWorkflowHandle {
577-
const activator = getActivator();
582+
const activator = assertInWorkflowContext(
583+
'Workflow.getExternalWorkflowHandle(...) may only be used from a Workflow Execution. Consider using Client.workflow.getHandle(...) instead.)'
584+
);
578585
return {
579586
workflowId,
580587
runId,
@@ -696,9 +703,11 @@ export async function startChild<T extends Workflow>(
696703
workflowTypeOrFunc: string | T,
697704
options?: WithWorkflowArgs<T, ChildWorkflowOptions>
698705
): Promise<ChildWorkflowHandle<T>> {
699-
const activator = getActivator();
706+
const activator = assertInWorkflowContext(
707+
'Workflow.startChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.start(...) instead.)'
708+
);
700709
const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any));
701-
const workflowType = typeof workflowTypeOrFunc === 'string' ? workflowTypeOrFunc : workflowTypeOrFunc.name;
710+
const workflowType = extractWorkflowType(workflowTypeOrFunc);
702711
const execute = composeInterceptors(
703712
activator.interceptors.outbound,
704713
'startChildWorkflowExecution',
@@ -795,9 +804,11 @@ export async function executeChild<T extends Workflow>(
795804
workflowTypeOrFunc: string | T,
796805
options?: WithWorkflowArgs<T, ChildWorkflowOptions>
797806
): Promise<WorkflowResultType<T>> {
798-
const activator = getActivator();
807+
const activator = assertInWorkflowContext(
808+
'Workflow.executeChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.execute(...) instead.'
809+
);
799810
const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any));
800-
const workflowType = typeof workflowTypeOrFunc === 'string' ? workflowTypeOrFunc : workflowTypeOrFunc.name;
811+
const workflowType = extractWorkflowType(workflowTypeOrFunc);
801812
const execute = composeInterceptors(
802813
activator.interceptors.outbound,
803814
'startChildWorkflowExecution',
@@ -841,7 +852,8 @@ export async function executeChild<T extends Workflow>(
841852
* }
842853
*/
843854
export function workflowInfo(): WorkflowInfo {
844-
return getActivator().info;
855+
const activator = assertInWorkflowContext('Workflow.workflowInfo(...) may only be used from a Workflow Execution.');
856+
return activator.info;
845857
}
846858

847859
/**
@@ -891,7 +903,9 @@ export function proxySinks<T extends Sinks>(): T {
891903
{
892904
get(_, fnName) {
893905
return (...args: any[]) => {
894-
const activator = getActivator();
906+
const activator = assertInWorkflowContext(
907+
'Proxied sinks functions may only be used from a Workflow Execution.'
908+
);
895909
activator.sinkCalls.push({
896910
ifaceName: ifaceName as string,
897911
fnName: fnName as string,
@@ -917,8 +931,10 @@ export function proxySinks<T extends Sinks>(): T {
917931
export function makeContinueAsNewFunc<F extends Workflow>(
918932
options?: ContinueAsNewOptions
919933
): (...args: Parameters<F>) => Promise<never> {
920-
const activator = getActivator();
921-
const info = workflowInfo();
934+
const activator = assertInWorkflowContext(
935+
'Workflow.continueAsNew(...) and Workflow.makeContinueAsNewFunc(...) may only be used from a Workflow Execution.'
936+
);
937+
const info = activator.info;
922938
const { workflowType, taskQueue, ...rest } = options ?? {};
923939
const requiredOptions = {
924940
workflowType: workflowType ?? info.workflowType,
@@ -1041,7 +1057,9 @@ export function deprecatePatch(patchId: string): void {
10411057
}
10421058

10431059
function patchInternal(patchId: string, deprecated: boolean): boolean {
1044-
const activator = getActivator();
1060+
const activator = assertInWorkflowContext(
1061+
'Workflow.patch(...) and Workflow.deprecatePatch may only be used from a Workflow Execution.'
1062+
);
10451063
// Patch operation does not support interception at the moment, if it did,
10461064
// this would be the place to start the interception chain
10471065

@@ -1075,6 +1093,7 @@ export function condition(fn: () => boolean, timeout: number | string): Promise<
10751093
export function condition(fn: () => boolean): Promise<void>;
10761094

10771095
export async function condition(fn: () => boolean, timeout?: number | string): Promise<void | boolean> {
1096+
assertInWorkflowContext('Workflow.condition(...) may only be used from a Workflow Execution.');
10781097
// Prior to 1.5.0, `condition(fn, 0)` was treated as equivalent to `condition(fn, undefined)`
10791098
if (timeout === 0 && !patched(CONDITION_0_PATCH)) {
10801099
return conditionInner(fn);
@@ -1162,7 +1181,7 @@ export function setHandler<Ret, Args extends any[], T extends SignalDefinition<A
11621181
def: T,
11631182
handler: Handler<Ret, Args, T> | undefined
11641183
): void {
1165-
const activator = getActivator();
1184+
const activator = assertInWorkflowContext('Workflow.setHandler(...) may only be used from a Workflow Execution.');
11661185
if (def.type === 'signal') {
11671186
if (typeof handler === 'function') {
11681187
activator.signalHandlers.set(def.name, handler as any);
@@ -1195,7 +1214,9 @@ export function setHandler<Ret, Args extends any[], T extends SignalDefinition<A
11951214
* @param handler a function that will handle signals for non-registered signal names, or `undefined` to unset the handler.
11961215
*/
11971216
export function setDefaultSignalHandler(handler: DefaultSignalHandler | undefined): void {
1198-
const activator = getActivator();
1217+
const activator = assertInWorkflowContext(
1218+
'Workflow.setDefaultSignalHandler(...) may only be used from a Workflow Execution.'
1219+
);
11991220
if (typeof handler === 'function') {
12001221
activator.defaultSignalHandler = handler;
12011222
activator.dispatchBufferedSignals();
@@ -1236,7 +1257,9 @@ export function setDefaultSignalHandler(handler: DefaultSignalHandler | undefine
12361257
* @param searchAttributes The Record to merge. Use a value of `[]` to clear a Search Attribute.
12371258
*/
12381259
export function upsertSearchAttributes(searchAttributes: SearchAttributes): void {
1239-
const activator = getActivator();
1260+
const activator = assertInWorkflowContext(
1261+
'Workflow.upsertSearchAttributes(...) may only be used from a Workflow Execution.'
1262+
);
12401263

12411264
const mergedSearchAttributes = { ...activator.info.searchAttributes, ...searchAttributes };
12421265
if (!mergedSearchAttributes) {
@@ -1281,6 +1304,7 @@ export const log: LoggerSinks['defaultWorkerLogger'] = Object.fromEntries(
12811304
return [
12821305
level,
12831306
(message: string, attrs: Record<string, unknown>) => {
1307+
assertInWorkflowContext('Workflow.log(...) may only be used from a Workflow Execution.)');
12841308
return loggerSinks.defaultWorkerLogger[level](message, {
12851309
// Inject the call time in nanosecond resolution as expected by the worker logger.
12861310
[LogTimestamp]: getActivator().getTimeOfDay(),
@@ -1290,3 +1314,9 @@ export const log: LoggerSinks['defaultWorkerLogger'] = Object.fromEntries(
12901314
];
12911315
})
12921316
) as any;
1317+
1318+
function assertInWorkflowContext(message: string): Activator {
1319+
const activator = maybeGetActivator();
1320+
if (activator == null) throw new IllegalStateError(message);
1321+
return activator;
1322+
}

0 commit comments

Comments
 (0)