Skip to content

Commit a8bd7b6

Browse files
THardy98mjameswh
andauthored
User metadata (#1657)
Co-authored-by: James Watkins-Harvey <[email protected]>
1 parent a1d91be commit a8bd7b6

File tree

18 files changed

+567
-55
lines changed

18 files changed

+567
-55
lines changed

packages/client/src/schedule-helpers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
extractWorkflowType,
88
LoadedDataConverter,
99
} from '@temporalio/common';
10+
import { encodeUserMetadata, decodeUserMetadata } from '@temporalio/common/lib/user-metadata';
1011
import {
1112
encodeUnifiedSearchAttributes,
1213
decodeSearchAttributes,
@@ -196,8 +197,7 @@ export function decodeOptionalStructuredCalendarSpecs(
196197
}
197198

198199
export function compileScheduleOptions(options: ScheduleOptions): CompiledScheduleOptions {
199-
const workflowTypeOrFunc = options.action.workflowType;
200-
const workflowType = extractWorkflowType(workflowTypeOrFunc);
200+
const workflowType = extractWorkflowType(options.action.workflowType);
201201
return {
202202
...options,
203203
action: {
@@ -270,6 +270,7 @@ export async function encodeScheduleAction(
270270
}
271271
: undefined,
272272
header: { fields: headers },
273+
userMetadata: await encodeUserMetadata(dataConverter, action.staticSummary, action.staticDetails),
273274
priority: action.priority ? compilePriority(action.priority) : undefined,
274275
},
275276
};
@@ -320,6 +321,7 @@ export async function decodeScheduleAction(
320321
pb: temporal.api.schedule.v1.IScheduleAction
321322
): Promise<ScheduleDescriptionAction> {
322323
if (pb.startWorkflow) {
324+
const { staticSummary, staticDetails } = await decodeUserMetadata(dataConverter, pb.startWorkflow?.userMetadata);
323325
return {
324326
type: 'startWorkflow',
325327
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -336,6 +338,8 @@ export async function decodeScheduleAction(
336338
workflowExecutionTimeout: optionalTsToMs(pb.startWorkflow.workflowExecutionTimeout),
337339
workflowRunTimeout: optionalTsToMs(pb.startWorkflow.workflowRunTimeout),
338340
workflowTaskTimeout: optionalTsToMs(pb.startWorkflow.workflowTaskTimeout),
341+
staticSummary,
342+
staticDetails,
339343
priority: decodePriority(pb.startWorkflow.priority),
340344
};
341345
}

packages/client/src/schedule-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,8 @@ export type ScheduleOptionsStartWorkflowAction<W extends Workflow> = {
783783
| 'workflowExecutionTimeout'
784784
| 'workflowRunTimeout'
785785
| 'workflowTaskTimeout'
786+
| 'staticDetails'
787+
| 'staticSummary'
786788
> & {
787789
/**
788790
* Workflow id to use when starting. Assign a meaningful business id.
@@ -815,6 +817,8 @@ export type ScheduleDescriptionStartWorkflowAction = ScheduleSummaryStartWorkflo
815817
| 'workflowExecutionTimeout'
816818
| 'workflowRunTimeout'
817819
| 'workflowTaskTimeout'
820+
| 'staticSummary'
821+
| 'staticDetails'
818822
| 'priority'
819823
>;
820824

packages/client/src/types.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,23 @@ export type WorkflowExecutionDescription = Replace<
6969
{
7070
raw: DescribeWorkflowExecutionResponse;
7171
}
72-
>;
72+
> & {
73+
/**
74+
* General fixed details for this workflow execution that may appear in UI/CLI.
75+
* This can be in Temporal markdown format and can span multiple lines.
76+
*
77+
* @experimental User metadata is a new API and suspectible to change.
78+
*/
79+
staticDetails: () => Promise<string | undefined>;
80+
81+
/**
82+
* A single-line fixed summary for this workflow execution that may appear in the UI/CLI.
83+
* This can be in single-line Temporal markdown format.
84+
*
85+
* @experimental User metadata is a new API and suspectible to change.
86+
*/
87+
staticSummary: () => Promise<string | undefined>;
88+
};
7389

7490
export type WorkflowService = proto.temporal.api.workflowservice.v1.WorkflowService;
7591
export const { WorkflowService } = proto.temporal.api.workflowservice.v1;

packages/client/src/workflow-client.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
WorkflowIdConflictPolicy,
2525
compilePriority,
2626
} from '@temporalio/common';
27+
import { encodeUserMetadata } from '@temporalio/common/lib/user-metadata';
2728
import { encodeUnifiedSearchAttributes } from '@temporalio/common/lib/converter/payload-search-attributes';
2829
import { composeInterceptors } from '@temporalio/common/lib/interceptors';
2930
import { History } from '@temporalio/common/lib/proto-utils';
@@ -32,6 +33,7 @@ import {
3233
decodeArrayFromPayloads,
3334
decodeFromPayloadsAtIndex,
3435
decodeOptionalFailureToOptionalError,
36+
decodeOptionalSinglePayload,
3537
encodeMapToPayloads,
3638
encodeToPayloads,
3739
} from '@temporalio/common/lib/internal-non-workflow';
@@ -509,7 +511,7 @@ export class WorkflowClient extends BaseClient {
509511

510512
protected async _start<T extends Workflow>(
511513
workflowTypeOrFunc: string | T,
512-
options: WithWorkflowArgs<T, WorkflowOptions>,
514+
options: WorkflowStartOptions<T>,
513515
interceptors: WorkflowClientInterceptor[]
514516
): Promise<string> {
515517
const workflowType = extractWorkflowType(workflowTypeOrFunc);
@@ -1220,6 +1222,7 @@ export class WorkflowClient extends BaseClient {
12201222
: undefined,
12211223
cronSchedule: options.cronSchedule,
12221224
header: { fields: headers },
1225+
userMetadata: await encodeUserMetadata(this.dataConverter, options.staticSummary, options.staticDetails),
12231226
priority: options.priority ? compilePriority(options.priority) : undefined,
12241227
versioningOverride: options.versioningOverride ?? undefined,
12251228
};
@@ -1262,7 +1265,6 @@ export class WorkflowClient extends BaseClient {
12621265
protected async createStartWorkflowRequest(input: WorkflowStartInput): Promise<StartWorkflowExecutionRequest> {
12631266
const { options: opts, workflowType, headers } = input;
12641267
const { identity, namespace } = this.options;
1265-
12661268
return {
12671269
namespace,
12681270
identity,
@@ -1290,6 +1292,7 @@ export class WorkflowClient extends BaseClient {
12901292
: undefined,
12911293
cronSchedule: opts.cronSchedule,
12921294
header: { fields: headers },
1295+
userMetadata: await encodeUserMetadata(this.dataConverter, opts.staticSummary, opts.staticDetails),
12931296
priority: opts.priority ? compilePriority(opts.priority) : undefined,
12941297
versioningOverride: opts.versioningOverride ?? undefined,
12951298
};
@@ -1425,8 +1428,13 @@ export class WorkflowClient extends BaseClient {
14251428
workflowExecution: { workflowId, runId },
14261429
});
14271430
const info = await executionInfoFromRaw(raw.workflowExecutionInfo ?? {}, this.client.dataConverter, raw);
1431+
const userMetadata = raw.executionConfig?.userMetadata;
14281432
return {
14291433
...info,
1434+
staticDetails: async () =>
1435+
(await decodeOptionalSinglePayload(this.client.dataConverter, userMetadata?.details)) ?? undefined,
1436+
staticSummary: async () =>
1437+
(await decodeOptionalSinglePayload(this.client.dataConverter, userMetadata?.summary)) ?? undefined,
14301438
raw,
14311439
};
14321440
},

packages/common/src/activity-options.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ export interface ActivityOptions {
125125
*/
126126
versioningIntent?: VersioningIntent; // eslint-disable-line deprecation/deprecation
127127

128+
/**
129+
* A fixed, single-line summary for this workflow execution that may appear in the UI/CLI.
130+
* This can be in single-line Temporal markdown format.
131+
*
132+
* @experimental User metadata is a new API and suspectible to change.
133+
*/
134+
summary?: string;
135+
128136
/**
129137
* Priority of this activity
130138
*/
@@ -193,4 +201,12 @@ export interface LocalActivityOptions {
193201
* - `ABANDON` - Do not request cancellation of the activity and immediately report cancellation to the workflow.
194202
*/
195203
cancellationType?: ActivityCancellationType;
204+
205+
/**
206+
* A fixed, single-line summary for this workflow execution that may appear in the UI/CLI.
207+
* This can be in single-line Temporal markdown format.
208+
*
209+
* @experimental User metadata is a new API and suspectible to change.
210+
*/
211+
summary?: string;
196212
}

packages/common/src/converter/payload-converter.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ export function toPayloads(converter: PayloadConverter, ...values: unknown[]): P
4444
return values.map((value) => converter.toPayload(value));
4545
}
4646

47+
/**
48+
* Run {@link PayloadConverter.toPayload} on an optional value, and then encode it.
49+
*/
50+
export function convertOptionalToPayload(
51+
payloadConverter: PayloadConverter,
52+
value: unknown
53+
): Payload | null | undefined {
54+
if (value == null) return value;
55+
56+
return payloadConverter.toPayload(value);
57+
}
58+
4759
/**
4860
* Run {@link PayloadConverter.toPayload} on each value in the map.
4961
*

packages/common/src/internal-non-workflow/codec-helpers.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Payload } from '../interfaces';
2-
import { arrayFromPayloads, fromPayloadsAtIndex, toPayloads } from '../converter/payload-converter';
2+
import { arrayFromPayloads, fromPayloadsAtIndex, PayloadConverter, toPayloads } from '../converter/payload-converter';
33
import { PayloadConverterError } from '../errors';
44
import { PayloadCodec } from '../converter/payload-codec';
55
import { ProtoFailure } from '../failure';
@@ -72,6 +72,17 @@ export async function decodeOptionalSingle(
7272
return await decodeSingle(codecs, payload);
7373
}
7474

75+
/** Run {@link PayloadCodec.decode} and convert from a single Payload */
76+
export async function decodeOptionalSinglePayload<T>(
77+
dataConverter: LoadedDataConverter,
78+
payload?: Payload | null | undefined
79+
): Promise<T | null | undefined> {
80+
const { payloadConverter, payloadCodecs } = dataConverter;
81+
const decoded = await decodeOptionalSingle(payloadCodecs, payload);
82+
if (decoded == null) return decoded;
83+
return payloadConverter.fromPayload(decoded);
84+
}
85+
7586
/**
7687
* Run {@link PayloadConverter.toPayload} on value, and then encode it.
7788
*/
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
export * from './enums-helpers';
2-
export * from './objects-helpers';
2+
export {
3+
filterNullAndUndefined,
4+
mergeObjects,
5+
// ts-prune-ignore-next
6+
deepMerge,
7+
} from './objects-helpers';

packages/common/src/internal-workflow/objects-helpers.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,33 @@ export function mergeObjects<T extends Record<string, any>>(
3535

3636
return changed ? (merged as T) : original;
3737
}
38+
39+
function isObject(item: any): item is Record<string, any> {
40+
return item && typeof item === 'object' && !Array.isArray(item);
41+
}
42+
43+
/**
44+
* Recursively merges two objects, returning a new object.
45+
*
46+
* Properties from `source` will overwrite properties on `target`.
47+
* Nested objects are merged recursively.
48+
*
49+
* Object fields in the returned object are references, as in,
50+
* the returned object is not completely fresh.
51+
*/
52+
export function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
53+
const output = { ...target };
54+
55+
if (isObject(target) && isObject(source)) {
56+
for (const key of Object.keys(source)) {
57+
const sourceValue = source[key];
58+
if (isObject(sourceValue) && key in target && isObject(target[key] as any)) {
59+
output[key as keyof T] = deepMerge(target[key], sourceValue);
60+
} else {
61+
(output as any)[key] = sourceValue;
62+
}
63+
}
64+
}
65+
66+
return output;
67+
}

packages/common/src/user-metadata.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { temporal } from '@temporalio/proto';
2+
import { convertOptionalToPayload, PayloadConverter } from './converter/payload-converter';
3+
import { LoadedDataConverter } from './converter/data-converter';
4+
import { decodeOptionalSinglePayload, encodeOptionalSingle } from './internal-non-workflow';
5+
6+
/**
7+
* User metadata that can be attached to workflow commands.
8+
*/
9+
export interface UserMetadata {
10+
/** @experimental A fixed, single line summary of the command's purpose */
11+
staticSummary?: string;
12+
/** @experimental Fixed additional details about the command for longer-text description, can span multiple lines */
13+
staticDetails?: string;
14+
}
15+
16+
export function userMetadataToPayload(
17+
payloadConverter: PayloadConverter,
18+
staticSummary: string | undefined,
19+
staticDetails: string | undefined
20+
): temporal.api.sdk.v1.IUserMetadata | undefined {
21+
if (staticSummary == null && staticDetails == null) return undefined;
22+
23+
const summary = convertOptionalToPayload(payloadConverter, staticSummary);
24+
const details = convertOptionalToPayload(payloadConverter, staticDetails);
25+
26+
if (summary == null && details == null) return undefined;
27+
28+
return { summary, details };
29+
}
30+
31+
export async function encodeUserMetadata(
32+
dataConverter: LoadedDataConverter,
33+
staticSummary: string | undefined,
34+
staticDetails: string | undefined
35+
): Promise<temporal.api.sdk.v1.IUserMetadata | undefined> {
36+
if (staticSummary == null && staticDetails == null) return undefined;
37+
38+
const { payloadConverter, payloadCodecs } = dataConverter;
39+
const summary = await encodeOptionalSingle(payloadCodecs, convertOptionalToPayload(payloadConverter, staticSummary));
40+
const details = await encodeOptionalSingle(payloadCodecs, convertOptionalToPayload(payloadConverter, staticDetails));
41+
42+
if (summary == null && details == null) return undefined;
43+
44+
return { summary, details };
45+
}
46+
47+
export async function decodeUserMetadata(
48+
dataConverter: LoadedDataConverter,
49+
metadata: temporal.api.sdk.v1.IUserMetadata | undefined | null
50+
): Promise<UserMetadata> {
51+
const res = { staticSummary: undefined, staticDetails: undefined };
52+
if (metadata == null) return res;
53+
54+
const staticSummary = (await decodeOptionalSinglePayload<string>(dataConverter, metadata.summary)) ?? undefined;
55+
const staticDetails = (await decodeOptionalSinglePayload<string>(dataConverter, metadata.details)) ?? undefined;
56+
57+
return { staticSummary, staticDetails };
58+
}

0 commit comments

Comments
 (0)