Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@ to docs, or any other relevant information.
- **Experimental**: `TemporalOperationHandler` can now use Standalone Activities as asynchronous
Nexus Operation backing executions through `TemporalNexusClient.startActivity` and
`typedActivity`.
- **Experimental**: Adding a link from a Nexus Query operation to the destination activity.

### Changed

- Nexus is now generally available (GA) for calling Nexus Operations from Workflows and handling
Workflow-backed Operations with `WorkflowRunOperationHandler`.
- A `common.v1.Link.Workflow` now serializes to the Workflow path
`temporal:///namespaces/{ns}/workflows/{wid}/{rid}` with the optional `reason` as a query param,
rather than reusing the workflow event path with a `/history` suffix and dropping `reason`. The
previous form was indistinguishable from a workflow event link except by its type, and did not match
the other SDKs. Inbound Workflow links are now parsed as well, and a link with a trailing path
segment is rejected.
- `@temporalio/ai-sdk` now requires `ai@>=7.0.59` as a peer dependency, up from `7.0.0`, since
earlier releases threw a `TypeError` on import in runtimes without a global `fetch`.

Expand Down
38 changes: 36 additions & 2 deletions packages/client/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { WorkflowOptions, WorkflowUpdateOptions } from './workflow-options'
import type { ActivityOptions } from './activity-client';
import type { WorkflowHandle, WorkflowUpdateHandle } from './workflow-client';
import type { WorkflowUpdateStage } from './workflow-update-stage';
import type { WorkflowSignalInput } from './interceptors';
import type { WorkflowQueryInput, WorkflowSignalInput } from './interceptors';

/**
* A symbol used to attach extra, SDK-internal options to the `WorkflowClient.start()` call.
Expand Down Expand Up @@ -134,6 +134,39 @@ export interface InternalWorkflowSignalOptions {
*/
export type InternalWorkflowSignalInput = WorkflowSignalInput & InternalWorkflowSignalOptions;

/**
* A symbol used to attach Nexus-specific options to a `WorkflowHandle.query()` call, so the client
* can capture the response link returned on the QueryWorkflowResponse.
*
* Unlike the signal variant there are no request links: a Query writes nothing to history, so
* `QueryWorkflowRequest` has no `links` field and there is no event to link from.
*
* @internal
* @hidden
*/
export const InternalWorkflowQueryOptionsSymbol = Symbol.for('__temporal_internal_client_workflow_query_options');
export interface InternalWorkflowQueryOptions {
[InternalWorkflowQueryOptionsSymbol]?: {
/**
* Response link copied by the client from the QueryWorkflowResponse. Points at the Workflow
* execution that processed the Query rather than at an event, since a Query writes none. Only
* populated by servers that support Query response links; left unset otherwise.
*/
responseLink?: temporal.api.common.v1.ILink;
};
}

/**
* The SDK-internal variant of {@link WorkflowQueryInput} that carries the
* {@link InternalWorkflowQueryOptionsSymbol} payload used by the Temporal Nexus helpers to capture
* the response link. Kept off the public `WorkflowQueryInput` so the symbol does not leak onto the
* interceptor surface.
*
* @internal
* @hidden
*/
export type InternalWorkflowQueryInput = WorkflowQueryInput & InternalWorkflowQueryOptions;

/**
* The SDK-internal surface of a `WorkflowHandle` used by the Temporal Nexus helpers to send a Signal
* while forwarding request links and capturing the response link the server returns.
Expand All @@ -147,7 +180,8 @@ export type InternalWorkflowSignalInput = WorkflowSignalInput & InternalWorkflow
* @hidden
*/
export type InternalWorkflowHandle = WorkflowHandle &
InternalWorkflowSignalOptions & {
InternalWorkflowSignalOptions &
InternalWorkflowQueryOptions & {
/**
* A single, non-overloaded view of {@link WorkflowHandle.startUpdate} used by the Temporal Nexus
* helpers to start an Update-backed operation. The public `startUpdate` is overloaded to
Expand Down
24 changes: 21 additions & 3 deletions packages/client/src/workflow-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,14 @@ import type { BaseClientOptions, LoadedWithDefaults, WithDefaults } from './base
import { BaseClient, defaultBaseClientOptions } from './base-client';
import { mapAsyncIterable } from './iterators-utils';
import { WorkflowUpdateStage, encodeWorkflowUpdateStage } from './workflow-update-stage';
import type { InternalWorkflowHandle, InternalWorkflowSignalInput, InternalWorkflowStartOptions } from './internal';
import type {
InternalWorkflowHandle,
InternalWorkflowQueryInput,
InternalWorkflowSignalInput,
InternalWorkflowStartOptions,
} from './internal';
import {
InternalWorkflowQueryOptionsSymbol,
InternalWorkflowSignalOptionsSymbol,
InternalWorkflowStartOptionsSymbol,
type InternalWorkflowUpdateOptions,
Expand Down Expand Up @@ -1010,6 +1016,7 @@ export class WorkflowClient extends BaseClient {
protected async _queryWorkflowHandler(input: WorkflowQueryInput): Promise<unknown> {
const dataConverter = this.dataConverter;
const context = this.workflowSerializationContext(input.workflowExecution.workflowId!);
const internalOptions = (input as InternalWorkflowQueryInput)[InternalWorkflowQueryOptionsSymbol];
const req: temporal.api.workflowservice.v1.IQueryWorkflowRequest = {
queryRejectCondition: input.queryRejectCondition,
namespace: this.options.namespace,
Expand Down Expand Up @@ -1047,6 +1054,12 @@ export class WorkflowClient extends BaseClient {
this.rethrowGrpcError(err, 'Failed to query Workflow', input.workflowExecution);
}
await visit(response, walkQueryWorkflowResponse, extstoreInboundOptions(externalStorage));
if (internalOptions != null) {
// A Query writes nothing to history, so the server returns a link to the Workflow execution
// that processed it rather than to an event. Captured before the rejection check below so a
// rejected Query still records its link. Older servers leave it unset.
internalOptions.responseLink = response.link ?? undefined;
}
if (response.queryRejected) {
if (response.queryRejected.status === undefined || response.queryRejected.status === null) {
throw new TypeError('Received queryRejected from server with no status');
Expand Down Expand Up @@ -1765,13 +1778,18 @@ export class WorkflowClient extends BaseClient {
async query<Ret, Args extends any[]>(def: QueryDefinition<Ret, Args> | string, ...args: Args): Promise<Ret> {
const next = this.client._queryWorkflowHandler.bind(this.client);
const fn = composeInterceptors(interceptors, 'query', next);
return fn({
const input: InternalWorkflowQueryInput = {
workflowExecution: { workflowId, runId },
queryRejectCondition: encodeQueryRejectCondition(this.client.options.queryRejectCondition),
queryType: typeof def === 'string' ? def : def.name,
args,
headers: {},
}) as Promise<Ret>;
// Forward any SDK-internal query options (e.g. the Nexus response-link slot) that were
// attached to this handle, and let the query handler write the response link back onto the
// same payload.
[InternalWorkflowQueryOptionsSymbol]: (this as InternalWorkflowHandle)[InternalWorkflowQueryOptionsSymbol],
};
return fn(input) as Promise<Ret>;
},
};
}
Expand Down
34 changes: 34 additions & 0 deletions packages/nexus/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type * as nexus from 'nexus-rpc';
import type { HandlerContext } from '../context';

/**
* Builds a minimal {@link nexus.StartOperationContext} for driving a start handler directly, without
* a live worker.
*/
export function makeStartContext(overrides: Partial<nexus.StartOperationContext> = {}): nexus.StartOperationContext {
return {
service: 'service',
operation: 'operation',
headers: {},
abortSignal: new AbortController().signal,
requestId: 'request-id',
inboundLinks: [],
outboundLinks: [],
...overrides,
};
}

/**
* Builds the handler context the worker would otherwise install, backed by the given stand-in client.
* Only the fields the Workflow helpers read are populated.
*/
export function makeHandlerContext(client: HandlerContext['client']): HandlerContext {
return {
log: { trace() {}, debug() {}, info() {}, warn() {}, error() {} } as unknown as HandlerContext['log'],
metrics: {} as HandlerContext['metrics'],
client,
namespace: 'ns',
taskQueue: 'tq',
endpoint: 'endpoint',
};
}
128 changes: 123 additions & 5 deletions packages/nexus/src/__tests__/test-nexus-link-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
convertActivityLinkToNexusLink,
convertNexusLinkToTemporalLink,
convertNexusLinkToWorkflowEventLink,
convertNexusLinkToWorkflowLink,
convertNexusOperationLinkToNexusLink,
convertTemporalLinkToNexusLink,
convertWorkflowEventLinkToNexusLink,
Expand Down Expand Up @@ -140,14 +141,26 @@ test('convertActivityLinkToNexusLink escapes URL path components', (t) => {
t.deepEqual(roundTrip, { activity });
});

test('convertWorkflowLinkToNexusLink produces a history URL with the Workflow link type', (t) => {
test('convertWorkflowLinkToNexusLink produces a workflow URL with the Workflow link type', (t) => {
// A Workflow link addresses the execution itself, so there is no '/history' suffix. That suffix
// belongs to the workflow event form, and its absence is what distinguishes the two paths.
const nexusLink = convertWorkflowLinkToNexusLink({
namespace: 'ns',
workflowId: 'wid',
runId: 'rid',
});
t.is(nexusLink.type, WORKFLOW_TYPE);
t.is(nexusLink.url.toString(), 'temporal:///namespaces/ns/workflows/wid/rid/history');
t.is(nexusLink.url.toString(), 'temporal:///namespaces/ns/workflows/wid/rid');
});

test('convertWorkflowLinkToNexusLink carries reason as a query param', (t) => {
const nexusLink = convertWorkflowLinkToNexusLink({
namespace: 'ns',
workflowId: 'wid',
runId: 'rid',
reason: 'rejected update',
});
t.is(nexusLink.url.toString(), 'temporal:///namespaces/ns/workflows/wid/rid?reason=rejected+update');
});

test('convertWorkflowLinkToNexusLink escapes URL path components', (t) => {
Expand All @@ -156,7 +169,7 @@ test('convertWorkflowLinkToNexusLink escapes URL path components', (t) => {
workflowId: 'work id',
runId: 'run/id',
});
t.is(nexusLink.url.toString(), 'temporal:///namespaces/name%2Fspace/workflows/work%20id/run%2Fid/history');
t.is(nexusLink.url.toString(), 'temporal:///namespaces/name%2Fspace/workflows/work%20id/run%2Fid');
});

test('convertWorkflowLinkToNexusLink throws on missing required fields', (t) => {
Expand All @@ -166,8 +179,8 @@ test('convertWorkflowLinkToNexusLink throws on missing required fields', (t) =>
t.throws(() => convertWorkflowLinkToNexusLink({ namespace: 'ns', workflowId: '', runId: 'rid' }), {
instanceOf: TypeError,
});
// An empty run ID would produce `.../workflows/wid//history`, whose double slash does not resolve
// to a valid UI page, so the converter rejects it rather than emit a malformed URL.
// An empty run ID would address no particular run, so the converter rejects it and lets the
// caller drop the link rather than attach one that resolves nowhere useful.
t.throws(() => convertWorkflowLinkToNexusLink({ namespace: 'ns', workflowId: 'wid', runId: '' }), {
instanceOf: TypeError,
});
Expand Down Expand Up @@ -381,3 +394,108 @@ test('throws on unknown eventType in requestIdRef', (t) => {
};
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink), { message: /Unknown eventType parameter/ });
});

test('convertNexusLinkToWorkflowLink parses a workflow URL', (t) => {
const workflowLink = convertNexusLinkToWorkflowLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid'),
type: WORKFLOW_TYPE,
});
t.deepEqual(workflowLink, { namespace: 'ns', workflowId: 'wid', runId: 'rid' });
});

test('convertNexusLinkToWorkflowLink parses reason', (t) => {
const workflowLink = convertNexusLinkToWorkflowLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid?reason=rejected+update'),
type: WORKFLOW_TYPE,
});
t.is(workflowLink.reason, 'rejected update');
});

test('convertNexusLinkToWorkflowLink finds reason by key, not position', (t) => {
const workflowLink = convertNexusLinkToWorkflowLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid?foo=bar&reason=Query+processed'),
type: WORKFLOW_TYPE,
});
t.is(workflowLink.reason, 'Query processed');
});

test('convertNexusLinkToWorkflowLink leaves reason unset when absent', (t) => {
// A key that merely starts with 'reason' must not be treated as 'reason'.
const workflowLink = convertNexusLinkToWorkflowLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid?reasonx=nope'),
type: WORKFLOW_TYPE,
});
t.is(workflowLink.reason, undefined);
});

test('convertNexusLinkToWorkflowLink rejects a trailing path segment', (t) => {
// The workflow event form addresses an event inside the Workflow, so it must not be accepted as a
// Workflow link even when the type says otherwise.
t.throws(
() =>
convertNexusLinkToWorkflowLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid/history'),
type: WORKFLOW_TYPE,
}),
{ instanceOf: TypeError }
);
});

test('convertNexusLinkToWorkflowLink rejects a missing run ID', (t) => {
t.throws(
() =>
convertNexusLinkToWorkflowLink({
url: new URL('temporal:///namespaces/ns/workflows/wid'),
type: WORKFLOW_TYPE,
}),
{ instanceOf: TypeError }
);
});

test('convertNexusLinkToWorkflowEventLink rejects a suffixless workflow path', (t) => {
// The inverse of the trailing-segment case: a Workflow link must not be readable as a workflow
// event.
t.throws(
() =>
convertNexusLinkToWorkflowEventLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid'),
type: WORKFLOW_EVENT_TYPE,
}),
{ instanceOf: TypeError }
);
});

test('convertNexusLinkToTemporalLink dispatches the Workflow link type', (t) => {
const temporalLink = convertNexusLinkToTemporalLink({
url: new URL('temporal:///namespaces/ns/workflows/wid/rid?reason=Query+processed'),
type: WORKFLOW_TYPE,
});
t.deepEqual(temporalLink, {
workflow: { namespace: 'ns', workflowId: 'wid', runId: 'rid', reason: 'Query processed' },
});
});

test('Workflow link round trips through both converters', (t) => {
// Reserved characters in every field at once: path segments are percent escaped and the reason is a
// query value, so a reason containing '=' and '&' must not be split as query syntax.
const workflowLink = {
namespace: 'ns/with/slash',
workflowId: 'wf id with space',
runId: 'rid',
reason: 'reason with = and &',
};
t.deepEqual(convertNexusLinkToWorkflowLink(convertWorkflowLinkToNexusLink(workflowLink)), workflowLink);
});

test('Workflow link reason round trips a literal plus', (t) => {
// URLSearchParams form encodes, writing a space as '+' and a literal '+' as '%2B', and its reader
// reverses that. Percent decoding alone would turn this reason into 'a b'.
const nexusLink = convertWorkflowLinkToNexusLink({
namespace: 'ns',
workflowId: 'wid',
runId: 'rid',
reason: 'a+b',
});
t.is(nexusLink.url.search, '?reason=a%2Bb');
t.is(convertNexusLinkToWorkflowLink(nexusLink).reason, 'a+b');
});
Loading
Loading