@xemahq/temporal-runtime 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -8
- package/src/index.ts +0 -32
- package/src/lib/activity-auth-context.ts +0 -59
- package/src/lib/auth-kind.ts +0 -50
- package/src/lib/base-temporal-client.service.ts +0 -153
- package/src/lib/connection.ts +0 -121
- package/src/lib/on-behalf-of-interceptor.ts +0 -58
- package/src/lib/schedule.ts +0 -115
- package/src/lib/search-attributes.ts +0 -130
- package/src/lib/service-http-client.ts +0 -286
- package/src/lib/temporal-sdk.ts +0 -31
- package/src/lib/worker.ts +0 -126
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/temporal-runtime",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Temporal workers and clients for Xema — connection management, on-behalf-of auth interceptors, schedules, and search attributes.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -18,23 +18,22 @@
|
|
|
18
18
|
"main": "dist/index.js",
|
|
19
19
|
"types": "dist/index.d.ts",
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
22
|
-
"src"
|
|
21
|
+
"dist"
|
|
23
22
|
],
|
|
24
23
|
"devDependencies": {
|
|
25
|
-
"@nestjs/common": "11.1.
|
|
24
|
+
"@nestjs/common": "11.1.18",
|
|
26
25
|
"@types/node": "25.2.3",
|
|
27
26
|
"prettier": "3.6.2",
|
|
28
27
|
"reflect-metadata": "0.2.2",
|
|
29
28
|
"typescript": "5.9.3",
|
|
30
29
|
"@xemahq/identity-client": "^0.9.1",
|
|
31
|
-
"@xemahq/kernel-contracts": "^
|
|
30
|
+
"@xemahq/kernel-contracts": "^13.0.0"
|
|
32
31
|
},
|
|
33
32
|
"peerDependencies": {
|
|
34
|
-
"@nestjs/common": "^
|
|
33
|
+
"@nestjs/common": "^11.0.0",
|
|
35
34
|
"reflect-metadata": "^0.2",
|
|
36
|
-
"@xemahq/identity-client": "
|
|
37
|
-
"@xemahq/kernel-contracts": "
|
|
35
|
+
"@xemahq/identity-client": "^0.9.0",
|
|
36
|
+
"@xemahq/kernel-contracts": "^12.0.0"
|
|
38
37
|
},
|
|
39
38
|
"dependencies": {
|
|
40
39
|
"@temporalio/client": "^1.16.2",
|
package/src/index.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// @xemahq/temporal-runtime — shared runtime glue for Temporal internal-workflow
|
|
3
|
-
// workers + clients.
|
|
4
|
-
//
|
|
5
|
-
// Every service that runs an internal-workflow worker or starts an internal
|
|
6
|
-
// workflow builds on this package — one battle-tested setup, no per-service
|
|
7
|
-
// drift:
|
|
8
|
-
// - connection — mTLS / plaintext cluster connection (worker + client)
|
|
9
|
-
// - activity auth — the on-behalf-of ALS context (activities
|
|
10
|
-
// mint fresh credentials; tokens never ride a workflow)
|
|
11
|
-
// - worker factory — `createPlatformWorker` wires the interceptor, the
|
|
12
|
-
// spill codec, and Worker Versioning
|
|
13
|
-
// - service HTTP — `ServiceHttpClient`: the generic authenticated client
|
|
14
|
-
// every activity uses to call its domain service's
|
|
15
|
-
// internal endpoint (activities never touch a DB)
|
|
16
|
-
// - schedule — `upsertPlatformSchedule`: idempotent create-or-
|
|
17
|
-
// reconcile of a platform Temporal Schedule
|
|
18
|
-
// - search attrs — `registerPlatformSearchAttributes` +
|
|
19
|
-
// `buildPlatformSearchAttributes`: the closed set of
|
|
20
|
-
// `xema`-namespace Visibility attributes
|
|
21
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
-
|
|
23
|
-
export * from './lib/connection';
|
|
24
|
-
export * from './lib/base-temporal-client.service';
|
|
25
|
-
export * from './lib/activity-auth-context';
|
|
26
|
-
export * from './lib/on-behalf-of-interceptor';
|
|
27
|
-
export * from './lib/worker';
|
|
28
|
-
export * from './lib/auth-kind';
|
|
29
|
-
export * from './lib/service-http-client';
|
|
30
|
-
export * from './lib/schedule';
|
|
31
|
-
export * from './lib/search-attributes';
|
|
32
|
-
export * from './lib/temporal-sdk';
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Per-activity authentication context (token lifetime).
|
|
5
|
-
*
|
|
6
|
-
* Populated by {@link OnBehalfOfActivityInterceptor} at the start of every
|
|
7
|
-
* activity execution from the activity's first-argument `context`. Read by a
|
|
8
|
-
* service's Orval-client / HTTP-helper `getAuthToken` callbacks to decide
|
|
9
|
-
* whether to mint a user-scoped (on-behalf-of) access token or fall back to
|
|
10
|
-
* the worker's service-account token — **always minted fresh, at activity
|
|
11
|
-
* execution time**, never carried on the durable workflow.
|
|
12
|
-
*
|
|
13
|
-
* Semantics:
|
|
14
|
-
* - `actorSubject: string` — the user the workflow runs on behalf of. Every
|
|
15
|
-
* outbound call made while this context is active acts on behalf of that
|
|
16
|
-
* user via RFC 8693 token-exchange at identity-api.
|
|
17
|
-
* - `actorSubject: null` — a cluster-scoped activity (schedule-fired,
|
|
18
|
-
* reconcile, …). Outbound calls use the worker's service-account token.
|
|
19
|
-
*
|
|
20
|
-
* The ALS propagates automatically across `await` boundaries inside an
|
|
21
|
-
* activity, so clients called from deep in an activity's call stack still see
|
|
22
|
-
* the same context.
|
|
23
|
-
*
|
|
24
|
-
* Naming note: this `actorSubject` is the SUBJECT of the minted token (the
|
|
25
|
-
* user), i.e. the `userId` argument of `getUserAccessToken`. It is NOT
|
|
26
|
-
* identity-api's `actorSubject` mint parameter, which names the party ACTING
|
|
27
|
-
* for that user — that is always the worker's own registered service identity.
|
|
28
|
-
*/
|
|
29
|
-
export interface ActivityAuthContext {
|
|
30
|
-
/** User subject (Keycloak sub). Null for system-scoped activities. */
|
|
31
|
-
readonly actorSubject: string | null;
|
|
32
|
-
/** Org the activity runs for (optional — downstream services stamp it from headers). */
|
|
33
|
-
readonly orgId?: string;
|
|
34
|
-
/** Correlation id for the downstream audit trail. */
|
|
35
|
-
readonly correlationId?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const storage = new AsyncLocalStorage<ActivityAuthContext>();
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Read the current activity auth context. Returns `undefined` when called
|
|
42
|
-
* outside an activity execution (e.g. during worker bootstrap) — the
|
|
43
|
-
* service-account fallback is then the right default.
|
|
44
|
-
*/
|
|
45
|
-
export function getActivityAuthContext(): ActivityAuthContext | undefined {
|
|
46
|
-
return storage.getStore();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Run `fn` inside an activity auth context. Typically only the Temporal
|
|
51
|
-
* activity interceptor calls this; activities read the context via
|
|
52
|
-
* {@link getActivityAuthContext}.
|
|
53
|
-
*/
|
|
54
|
-
export async function runWithActivityAuthContext<T>(
|
|
55
|
-
context: ActivityAuthContext,
|
|
56
|
-
fn: () => Promise<T>,
|
|
57
|
-
): Promise<T> {
|
|
58
|
-
return storage.run(context, fn);
|
|
59
|
-
}
|
package/src/lib/auth-kind.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Closed-set classifier for the auth path the {@link ServiceHttpClient} should
|
|
3
|
-
* follow on a given request. Activities never construct bearer strings
|
|
4
|
-
* themselves; they declare *intent* and the client resolves the token via
|
|
5
|
-
* `IdentityBootstrapService`.
|
|
6
|
-
*
|
|
7
|
-
* - {@link OnBehalfOfActor} — used for activities that should attribute
|
|
8
|
-
* work to the user who launched the run. The client reads the
|
|
9
|
-
* activity's AsyncLocalStorage `actorSubject`. When set (a user-launched
|
|
10
|
-
* workflow) it mints an RFC 8693 on-behalf-of token; when null (a
|
|
11
|
-
* schedule- / reconcile-fired workflow) it falls back to the worker's
|
|
12
|
-
* service token.
|
|
13
|
-
* - {@link Service} — used for cluster-internal flows that have no user
|
|
14
|
-
* actor by definition (reconcile, scheduled work). Always the worker's
|
|
15
|
-
* service token; never on-behalf-of.
|
|
16
|
-
* - {@link ExternalBearer} — used for outbound calls to third-party
|
|
17
|
-
* endpoints (e.g. a user-supplied webhook target). The bearer is supplied
|
|
18
|
-
* by the caller and never goes through identity-api.
|
|
19
|
-
*/
|
|
20
|
-
export enum ServiceAuthKind {
|
|
21
|
-
OnBehalfOfActor = 'on-behalf-of-actor',
|
|
22
|
-
Service = 'service',
|
|
23
|
-
ExternalBearer = 'external-bearer',
|
|
24
|
-
/**
|
|
25
|
-
* No `Authorization` header is set on the outbound request. Use only
|
|
26
|
-
* for genuinely unauthenticated outbound calls or for calls that
|
|
27
|
-
* authenticate via a non-Bearer mechanism (HMAC signature, mTLS,
|
|
28
|
-
* caller-supplied custom header). The client never falls through to
|
|
29
|
-
* this kind silently — callers must declare it explicitly.
|
|
30
|
-
*/
|
|
31
|
-
None = 'none',
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type ServiceAuthSpec =
|
|
35
|
-
| {
|
|
36
|
-
readonly kind: ServiceAuthKind.OnBehalfOfActor;
|
|
37
|
-
/** Optional Keycloak audience claim narrowing. */
|
|
38
|
-
readonly audience?: string;
|
|
39
|
-
}
|
|
40
|
-
| {
|
|
41
|
-
readonly kind: ServiceAuthKind.Service;
|
|
42
|
-
readonly audience?: string;
|
|
43
|
-
}
|
|
44
|
-
| {
|
|
45
|
-
readonly kind: ServiceAuthKind.ExternalBearer;
|
|
46
|
-
readonly token: string;
|
|
47
|
-
}
|
|
48
|
-
| {
|
|
49
|
-
readonly kind: ServiceAuthKind.None;
|
|
50
|
-
};
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Injectable,
|
|
3
|
-
Logger,
|
|
4
|
-
type OnApplicationBootstrap,
|
|
5
|
-
type OnModuleDestroy,
|
|
6
|
-
} from '@nestjs/common';
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
connectTemporalClient,
|
|
10
|
-
resolveTemporalConnectionOptionsFromEnv,
|
|
11
|
-
type TemporalConnectionOptions,
|
|
12
|
-
} from './connection';
|
|
13
|
-
import { Client, Connection, type ClientOptions } from './temporal-sdk';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Base NestJS Temporal **client** service.
|
|
17
|
-
*
|
|
18
|
-
* Several domain services hand-rolled the identical lifecycle: on bootstrap,
|
|
19
|
-
* open a {@link Connection} (resolving address + opt-in mTLS from env via
|
|
20
|
-
* {@link resolveTemporalConnectionOptionsFromEnv}), build a {@link Client}
|
|
21
|
-
* for the service's namespace, expose a null-checked `getClient()`, and close
|
|
22
|
-
* the connection on shutdown. That boilerplate — connect / build / guard /
|
|
23
|
-
* close — lives here once.
|
|
24
|
-
*
|
|
25
|
-
* What stays per-service is the only thing that genuinely varies: the
|
|
26
|
-
* namespace, the task queue, and the domain-specific `workflow.start(...)`
|
|
27
|
-
* methods. A subclass implements {@link resolveNamespace} and adds its own
|
|
28
|
-
* `start*Workflow()` methods on top of {@link getClient}.
|
|
29
|
-
*
|
|
30
|
-
* Connection-vs-client split: this base owns the CLIENT side (workflow start /
|
|
31
|
-
* signal / query). A service that ALSO hosts a worker uses
|
|
32
|
-
* `connectTemporalWorker` / `createPlatformWorker` separately — the worker
|
|
33
|
-
* channel is a different connection by design.
|
|
34
|
-
*
|
|
35
|
-
* Lifecycle is `OnApplicationBootstrap` (not `OnModuleInit`) so the cluster
|
|
36
|
-
* connection is opened after the full DI graph is constructed and never blocks
|
|
37
|
-
* module wiring. Connection close on `OnModuleDestroy` is idempotent.
|
|
38
|
-
*
|
|
39
|
-
* Adoption:
|
|
40
|
-
*
|
|
41
|
-
* ```ts
|
|
42
|
-
* @Injectable()
|
|
43
|
-
* export class OrgDbTemporalClientService extends BaseTemporalClientService {
|
|
44
|
-
* protected resolveNamespace(): string {
|
|
45
|
-
* return process.env.TEMPORAL_NAMESPACE ?? ORG_DB_TEMPORAL_NAMESPACE;
|
|
46
|
-
* }
|
|
47
|
-
*
|
|
48
|
-
* async startProvisionDatabaseWorkflow(input: ProvisionInput) {
|
|
49
|
-
* await this.getClient().workflow.start('ProvisionDatabaseWorkflow', {
|
|
50
|
-
* workflowId: `org-db-provision:${input.orgId}:${input.databaseName}`,
|
|
51
|
-
* taskQueue: OrgDbTemporalTaskQueue.Main,
|
|
52
|
-
* args: [input],
|
|
53
|
-
* });
|
|
54
|
-
* }
|
|
55
|
-
* }
|
|
56
|
-
* ```
|
|
57
|
-
*/
|
|
58
|
-
@Injectable()
|
|
59
|
-
export abstract class BaseTemporalClientService
|
|
60
|
-
implements OnApplicationBootstrap, OnModuleDestroy
|
|
61
|
-
{
|
|
62
|
-
protected readonly logger = new Logger(this.constructor.name);
|
|
63
|
-
|
|
64
|
-
private connection: Connection | null = null;
|
|
65
|
-
private client: Client | null = null;
|
|
66
|
-
private resolvedNamespace: string | null = null;
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The Temporal namespace this service's client binds to. Implemented by the
|
|
70
|
-
* subclass — resolved once at bootstrap (env var, ConfigService, constant).
|
|
71
|
-
*/
|
|
72
|
-
protected abstract resolveNamespace(): string;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Connection options for the cluster. Defaults to the standard env-var
|
|
76
|
-
* resolver every Temporal participant shares; override only when a service
|
|
77
|
-
* sources its connection settings differently (rare).
|
|
78
|
-
*/
|
|
79
|
-
protected resolveConnectionOptions(): TemporalConnectionOptions {
|
|
80
|
-
return resolveTemporalConnectionOptionsFromEnv();
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Extra {@link Client} options merged on top of `{ connection, namespace }`.
|
|
85
|
-
* Override to attach a `dataConverter` (e.g. a payload codec) or interceptors.
|
|
86
|
-
* Returns `undefined` by default — a plain client.
|
|
87
|
-
*/
|
|
88
|
-
protected resolveClientOptions():
|
|
89
|
-
| Omit<ClientOptions, 'connection' | 'namespace'>
|
|
90
|
-
| undefined {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async onApplicationBootstrap(): Promise<void> {
|
|
95
|
-
const namespace = this.resolveNamespace();
|
|
96
|
-
this.connection = await connectTemporalClient(
|
|
97
|
-
this.resolveConnectionOptions(),
|
|
98
|
-
);
|
|
99
|
-
this.resolvedNamespace = namespace;
|
|
100
|
-
this.client = new Client({
|
|
101
|
-
connection: this.connection,
|
|
102
|
-
namespace,
|
|
103
|
-
...this.resolveClientOptions(),
|
|
104
|
-
});
|
|
105
|
-
this.logger.log(`Temporal client connected (namespace=${namespace}).`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async onModuleDestroy(): Promise<void> {
|
|
109
|
-
if (this.connection) {
|
|
110
|
-
await this.connection.close();
|
|
111
|
-
this.connection = null;
|
|
112
|
-
this.client = null;
|
|
113
|
-
this.resolvedNamespace = null;
|
|
114
|
-
this.logger.log('Temporal client connection closed.');
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The connected client. Fail-fast: throws if called before bootstrap or
|
|
120
|
-
* after shutdown — never returns a half-initialized client.
|
|
121
|
-
*/
|
|
122
|
-
protected getClient(): Client {
|
|
123
|
-
if (!this.client) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`${this.constructor.name} is not initialized — getClient() called ` +
|
|
126
|
-
'before onApplicationBootstrap or after onModuleDestroy.',
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
return this.client;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** The underlying connection. Same fail-fast contract as {@link getClient}. */
|
|
133
|
-
protected getConnection(): Connection {
|
|
134
|
-
if (!this.connection) {
|
|
135
|
-
throw new Error(
|
|
136
|
-
`${this.constructor.name} is not initialized — getConnection() called ` +
|
|
137
|
-
'before onApplicationBootstrap or after onModuleDestroy.',
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
return this.connection;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** The namespace the client is bound to. Fail-fast before bootstrap. */
|
|
144
|
-
protected getNamespace(): string {
|
|
145
|
-
if (!this.resolvedNamespace) {
|
|
146
|
-
throw new Error(
|
|
147
|
-
`${this.constructor.name} is not initialized — getNamespace() called ` +
|
|
148
|
-
'before onApplicationBootstrap or after onModuleDestroy.',
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
return this.resolvedNamespace;
|
|
152
|
-
}
|
|
153
|
-
}
|
package/src/lib/connection.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
|
|
3
|
-
import { Connection } from '@temporalio/client';
|
|
4
|
-
import { NativeConnection } from '@temporalio/worker';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Connection settings for the Temporal cluster, shared by every service that
|
|
8
|
-
* runs an internal-workflow worker or starts internal workflows.
|
|
9
|
-
*
|
|
10
|
-
* TLS is opt-in (prod): a client-cert pair for mTLS, rotated out-of-band by
|
|
11
|
-
* cert-manager. When `tls` is omitted the connection is plaintext — valid for
|
|
12
|
-
* dev / docker-compose where the cluster + service share a network segment
|
|
13
|
-
* and gRPC `:7233` is cluster-internal.
|
|
14
|
-
*
|
|
15
|
-
* `worker ↔ Temporal cluster` auth is exactly this mTLS channel — never a
|
|
16
|
-
* bearer token: tokens expire long before a durable workflow
|
|
17
|
-
* does. Per-activity service auth is minted fresh inside activities.
|
|
18
|
-
*/
|
|
19
|
-
export interface TemporalConnectionOptions {
|
|
20
|
-
/** gRPC frontend address, e.g. `temporal-api.xema-prod.svc.cluster.local:7233`. */
|
|
21
|
-
readonly address: string;
|
|
22
|
-
/** mTLS material. Omit for a plaintext (dev) connection. */
|
|
23
|
-
readonly tls?: {
|
|
24
|
-
readonly clientCertPath: string;
|
|
25
|
-
readonly clientKeyPath: string;
|
|
26
|
-
/** Optional SNI override when the cert CN differs from `address`. */
|
|
27
|
-
readonly serverName?: string;
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ResolvedTls {
|
|
32
|
-
clientCertPair: { crt: Buffer; key: Buffer };
|
|
33
|
-
serverNameOverride?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Read the client-cert pair off disk. Fail-fast: a missing/unreadable cert
|
|
38
|
-
* throws here rather than surfacing as an opaque handshake error later.
|
|
39
|
-
*/
|
|
40
|
-
function readTlsPair(
|
|
41
|
-
tls: NonNullable<TemporalConnectionOptions['tls']>,
|
|
42
|
-
): ResolvedTls {
|
|
43
|
-
return {
|
|
44
|
-
clientCertPair: {
|
|
45
|
-
crt: readFileSync(tls.clientCertPath),
|
|
46
|
-
key: readFileSync(tls.clientKeyPath),
|
|
47
|
-
},
|
|
48
|
-
...(tls.serverName !== undefined
|
|
49
|
-
? { serverNameOverride: tls.serverName }
|
|
50
|
-
: {}),
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* A `NativeConnection` for a Temporal **Worker** (the queue-polling side).
|
|
56
|
-
* Used by every service hosting an internal-workflow worker.
|
|
57
|
-
*/
|
|
58
|
-
export async function connectTemporalWorker(
|
|
59
|
-
options: TemporalConnectionOptions,
|
|
60
|
-
): Promise<NativeConnection> {
|
|
61
|
-
if (!options.tls) {
|
|
62
|
-
return NativeConnection.connect({ address: options.address });
|
|
63
|
-
}
|
|
64
|
-
return NativeConnection.connect({
|
|
65
|
-
address: options.address,
|
|
66
|
-
tls: readTlsPair(options.tls),
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* A `Connection` for a Temporal **Client** (the workflow start / signal /
|
|
72
|
-
* query side). Used wherever a service starts an internal workflow — e.g.
|
|
73
|
-
* `SessionService.create()` starting `SessionLaunchWorkflow`.
|
|
74
|
-
*/
|
|
75
|
-
export async function connectTemporalClient(
|
|
76
|
-
options: TemporalConnectionOptions,
|
|
77
|
-
): Promise<Connection> {
|
|
78
|
-
if (!options.tls) {
|
|
79
|
-
return Connection.connect({ address: options.address });
|
|
80
|
-
}
|
|
81
|
-
return Connection.connect({
|
|
82
|
-
address: options.address,
|
|
83
|
-
tls: readTlsPair(options.tls),
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Build {@link TemporalConnectionOptions} from the standard env vars every
|
|
89
|
-
* Temporal participant reads: `TEMPORAL_ADDRESS` (required) plus the opt-in
|
|
90
|
-
* `TEMPORAL_TLS_*` set. One resolver so a worker and a schedule-creating
|
|
91
|
-
* domain service connect with identical semantics — no per-service drift.
|
|
92
|
-
*
|
|
93
|
-
* Fail-fast: a missing address, or `TEMPORAL_TLS_ENABLED=true` without both
|
|
94
|
-
* cert paths, throws here rather than degrading to a wrong connection.
|
|
95
|
-
*/
|
|
96
|
-
export function resolveTemporalConnectionOptionsFromEnv(): TemporalConnectionOptions {
|
|
97
|
-
const address = process.env.TEMPORAL_ADDRESS?.trim();
|
|
98
|
-
if (!address) {
|
|
99
|
-
throw new Error('TEMPORAL_ADDRESS is required for a Temporal connection.');
|
|
100
|
-
}
|
|
101
|
-
if (process.env.TEMPORAL_TLS_ENABLED !== 'true') {
|
|
102
|
-
return { address };
|
|
103
|
-
}
|
|
104
|
-
const clientCertPath = process.env.TEMPORAL_TLS_CLIENT_CERT_PATH?.trim();
|
|
105
|
-
const clientKeyPath = process.env.TEMPORAL_TLS_CLIENT_KEY_PATH?.trim();
|
|
106
|
-
if (!clientCertPath || !clientKeyPath) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
'TEMPORAL_TLS_ENABLED=true requires both TEMPORAL_TLS_CLIENT_CERT_PATH ' +
|
|
109
|
-
'and TEMPORAL_TLS_CLIENT_KEY_PATH.',
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
const serverName = process.env.TEMPORAL_TLS_SERVER_NAME?.trim();
|
|
113
|
-
return {
|
|
114
|
-
address,
|
|
115
|
-
tls: {
|
|
116
|
-
clientCertPath,
|
|
117
|
-
clientKeyPath,
|
|
118
|
-
...(serverName ? { serverName } : {}),
|
|
119
|
-
},
|
|
120
|
-
};
|
|
121
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type ActivityAuthContext,
|
|
3
|
-
runWithActivityAuthContext,
|
|
4
|
-
} from './activity-auth-context';
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
ActivityExecuteInput,
|
|
8
|
-
ActivityInboundCallsInterceptor,
|
|
9
|
-
Next,
|
|
10
|
-
} from '@temporalio/worker';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Temporal ActivityInbound interceptor that populates
|
|
14
|
-
* {@link ActivityAuthContext} from the activity's first argument.
|
|
15
|
-
*
|
|
16
|
-
* Every activity is dispatched with a `context` object on its first argument
|
|
17
|
-
* carrying `actorSubject` (the user the workflow runs on behalf of) plus
|
|
18
|
-
* `orgId` and `correlationId` — the calling workflow supplies it. Wrapping
|
|
19
|
-
* `execute()` in the ALS means every outbound call inside the activity can
|
|
20
|
-
* read the actor without changing each activity's signature, and mint a
|
|
21
|
-
* fresh on-behalf-of token at call time.
|
|
22
|
-
*
|
|
23
|
-
* For activities with no user attribution (cluster-scoped: reconcile,
|
|
24
|
-
* schedule-fired) the ALS is still set but `actorSubject` is null, so the
|
|
25
|
-
* client `getAuthToken` callback cleanly falls back to the worker's
|
|
26
|
-
* service-account token.
|
|
27
|
-
*/
|
|
28
|
-
export class OnBehalfOfActivityInterceptor
|
|
29
|
-
implements ActivityInboundCallsInterceptor
|
|
30
|
-
{
|
|
31
|
-
async execute(
|
|
32
|
-
input: ActivityExecuteInput,
|
|
33
|
-
next: Next<ActivityInboundCallsInterceptor, 'execute'>,
|
|
34
|
-
): Promise<unknown> {
|
|
35
|
-
const context = extractAuthContext(input);
|
|
36
|
-
return runWithActivityAuthContext(context, () => next(input));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function extractAuthContext(input: ActivityExecuteInput): ActivityAuthContext {
|
|
41
|
-
const first = input.args[0] as
|
|
42
|
-
| {
|
|
43
|
-
context?: {
|
|
44
|
-
actorSubject?: string | null;
|
|
45
|
-
orgId?: string;
|
|
46
|
-
correlationId?: string;
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
| undefined;
|
|
50
|
-
const ctx = first?.context;
|
|
51
|
-
return {
|
|
52
|
-
actorSubject: ctx?.actorSubject ?? null,
|
|
53
|
-
...(ctx?.orgId !== undefined ? { orgId: ctx.orgId } : {}),
|
|
54
|
-
...(ctx?.correlationId !== undefined
|
|
55
|
-
? { correlationId: ctx.correlationId }
|
|
56
|
-
: {}),
|
|
57
|
-
};
|
|
58
|
-
}
|
package/src/lib/schedule.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { ScheduleAlreadyRunning } from '@temporalio/client';
|
|
2
|
-
|
|
3
|
-
import type { Client } from '@temporalio/client';
|
|
4
|
-
import type { Duration, Workflow } from '@temporalio/common';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Code-owned description of a platform Temporal Schedule.
|
|
8
|
-
*
|
|
9
|
-
* Captures only the half a service *owns in code* — the cadence, the
|
|
10
|
-
* workflow action, the overlap policy. Operator-owned state (`paused`, and
|
|
11
|
-
* the schedule `note` after first create) is never overwritten by
|
|
12
|
-
* {@link upsertPlatformSchedule}: it is read from the live schedule and
|
|
13
|
-
* preserved verbatim.
|
|
14
|
-
*/
|
|
15
|
-
export interface PlatformScheduleSpec {
|
|
16
|
-
/** Stable schedule id — also reused as the per-run base workflow id. */
|
|
17
|
-
readonly scheduleId: string;
|
|
18
|
-
/** Fixed-interval cadence between runs. */
|
|
19
|
-
readonly every: Duration;
|
|
20
|
-
/**
|
|
21
|
-
* Catch-up window after worker/cluster downtime. Keep this short — one
|
|
22
|
-
* sweep is enough, so a recovered cluster never replays a backlog.
|
|
23
|
-
*/
|
|
24
|
-
readonly catchupWindow: Duration;
|
|
25
|
-
/** Registered workflow type the schedule starts. */
|
|
26
|
-
readonly workflowType: string;
|
|
27
|
-
/** `xema-platform.*` task queue the workflow runs on. */
|
|
28
|
-
readonly taskQueue: string;
|
|
29
|
-
/**
|
|
30
|
-
* Workflow start args. For platform schedules this is the single
|
|
31
|
-
* `{ callbackBaseUrl }` object — the worker holds no service URLs.
|
|
32
|
-
*/
|
|
33
|
-
readonly args: readonly unknown[];
|
|
34
|
-
/**
|
|
35
|
-
* Human-readable note stamped on the schedule the first time it is
|
|
36
|
-
* created. On a later reconcile the operator's live note is preserved
|
|
37
|
-
* instead, so an operator annotation is never clobbered.
|
|
38
|
-
*/
|
|
39
|
-
readonly note: string;
|
|
40
|
-
/**
|
|
41
|
-
* Visibility search attributes tagged onto every workflow the schedule
|
|
42
|
-
* starts — build with `buildPlatformSearchAttributes`. The
|
|
43
|
-
* referenced attributes must already be registered on the namespace
|
|
44
|
-
* (`registerPlatformSearchAttributes`), or the cluster rejects the start.
|
|
45
|
-
*/
|
|
46
|
-
readonly searchAttributes?: Record<string, string[]>;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Outcome of an upsert — `created` is `true` only on the first-ever boot. */
|
|
50
|
-
export interface UpsertPlatformScheduleResult {
|
|
51
|
-
readonly created: boolean;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Create — or, if it already exists, reconcile — a platform Temporal
|
|
56
|
-
* Schedule. Idempotent and multi-replica safe: a create race resolves via
|
|
57
|
-
* `ScheduleAlreadyRunning` → the update path, and concurrent updates all
|
|
58
|
-
* write the same code-owned definition.
|
|
59
|
-
*
|
|
60
|
-
* The code-owned half (cadence + action + policies) is always written; the
|
|
61
|
-
* operator-owned half (`paused` + `note`) is read from the live schedule and
|
|
62
|
-
* preserved. Any non-`ScheduleAlreadyRunning` error is rethrown so a service
|
|
63
|
-
* whose schedule cannot be registered fails its boot loudly.
|
|
64
|
-
*/
|
|
65
|
-
export async function upsertPlatformSchedule(
|
|
66
|
-
client: Client,
|
|
67
|
-
spec: PlatformScheduleSpec,
|
|
68
|
-
): Promise<UpsertPlatformScheduleResult> {
|
|
69
|
-
const definition = {
|
|
70
|
-
spec: { intervals: [{ every: spec.every }] },
|
|
71
|
-
action: {
|
|
72
|
-
type: 'startWorkflow' as const,
|
|
73
|
-
workflowType: spec.workflowType,
|
|
74
|
-
taskQueue: spec.taskQueue,
|
|
75
|
-
args: spec.args as unknown[],
|
|
76
|
-
// Temporal appends the scheduled time so every run gets a distinct id.
|
|
77
|
-
workflowId: spec.scheduleId,
|
|
78
|
-
...(spec.searchAttributes
|
|
79
|
-
? { searchAttributes: spec.searchAttributes }
|
|
80
|
-
: {}),
|
|
81
|
-
},
|
|
82
|
-
policies: {
|
|
83
|
-
// A slow tick is compensated by the next one — never pile up.
|
|
84
|
-
overlap: 'SKIP' as const,
|
|
85
|
-
catchupWindow: spec.catchupWindow,
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
// Explicit `<Workflow>` pins the schedule-action generic to the loose
|
|
91
|
-
// base type — `workflowType` is a string, not a workflow reference, so
|
|
92
|
-
// without this TS narrows the generic and demands every optional field.
|
|
93
|
-
await client.schedule.create<Workflow>({
|
|
94
|
-
scheduleId: spec.scheduleId,
|
|
95
|
-
...definition,
|
|
96
|
-
// Initial operator-owned state — applied only on first create.
|
|
97
|
-
state: { paused: false, note: spec.note },
|
|
98
|
-
});
|
|
99
|
-
return { created: true };
|
|
100
|
-
} catch (err) {
|
|
101
|
-
if (!(err instanceof ScheduleAlreadyRunning)) {
|
|
102
|
-
// Any other error is load-bearing — the caller must fail boot so
|
|
103
|
-
// operators see why the schedule is not firing.
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
// Schedule exists — reconcile the code-owned definition. Operator-owned
|
|
107
|
-
// state (paused, note) is read from the live schedule and preserved.
|
|
108
|
-
const handle = client.schedule.getHandle(spec.scheduleId);
|
|
109
|
-
await handle.update<Workflow>((previous) => ({
|
|
110
|
-
...definition,
|
|
111
|
-
state: { paused: previous.state.paused, note: previous.state.note },
|
|
112
|
-
}));
|
|
113
|
-
return { created: false };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { temporal } from '@temporalio/proto';
|
|
2
|
-
|
|
3
|
-
import type { Connection } from '@temporalio/client';
|
|
4
|
-
import type { PlatformWorkflowDomain } from '@xemahq/kernel-contracts/workflow';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Custom Temporal Visibility search attributes the platform tags onto every
|
|
8
|
-
* internal workflow run in the `xema` namespace. A closed set,
|
|
9
|
-
* registered idempotently before any producer starts a workflow:
|
|
10
|
-
*
|
|
11
|
-
* - `OrgId` / `ProjectId` — the owning tenant (absent on cluster-wide
|
|
12
|
-
* sweeps such as the reconcile / checkpoint schedules);
|
|
13
|
-
* - `WorkflowDomain` — the {@link PlatformWorkflowDomain} the run belongs
|
|
14
|
-
* to (session-lifecycle, studio, workload, …);
|
|
15
|
-
* - `SubjectId` — the generic resource the run operates on (sessionId,
|
|
16
|
-
* studioId, …).
|
|
17
|
-
*
|
|
18
|
-
* Single source of truth — never mirror this list in helm values, compose
|
|
19
|
-
* files, or external bootstrap scripts. Adding an attribute is a one-line
|
|
20
|
-
* change here; the next registration call picks it up.
|
|
21
|
-
*/
|
|
22
|
-
export const SEARCH_ATTR_ORG_ID = 'OrgId';
|
|
23
|
-
export const SEARCH_ATTR_PROJECT_ID = 'ProjectId';
|
|
24
|
-
export const SEARCH_ATTR_WORKFLOW_DOMAIN = 'WorkflowDomain';
|
|
25
|
-
export const SEARCH_ATTR_SUBJECT_ID = 'SubjectId';
|
|
26
|
-
/** Connector provider id — for outbox forwarders, OAuth saga runs. */
|
|
27
|
-
export const SEARCH_ATTR_INTEGRATION_PROVIDER_ID = 'IntegrationProviderId';
|
|
28
|
-
/** Registry kind ('npm' | 'container') — for image/package workflows. */
|
|
29
|
-
export const SEARCH_ATTR_REGISTRY_KIND = 'RegistryKind';
|
|
30
|
-
/** Container image digest ('sha256:…') — for image-build/publish flows. */
|
|
31
|
-
export const SEARCH_ATTR_IMAGE_DIGEST = 'ImageDigest';
|
|
32
|
-
|
|
33
|
-
interface PlatformSearchAttribute {
|
|
34
|
-
readonly name: string;
|
|
35
|
-
readonly type: temporal.api.enums.v1.IndexedValueType;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const KEYWORD =
|
|
39
|
-
temporal.api.enums.v1.IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD;
|
|
40
|
-
|
|
41
|
-
/** The closed set of platform search attributes — all `KEYWORD`-typed. */
|
|
42
|
-
export const PLATFORM_SEARCH_ATTRIBUTES: readonly PlatformSearchAttribute[] = [
|
|
43
|
-
{ name: SEARCH_ATTR_ORG_ID, type: KEYWORD },
|
|
44
|
-
{ name: SEARCH_ATTR_PROJECT_ID, type: KEYWORD },
|
|
45
|
-
{ name: SEARCH_ATTR_WORKFLOW_DOMAIN, type: KEYWORD },
|
|
46
|
-
{ name: SEARCH_ATTR_SUBJECT_ID, type: KEYWORD },
|
|
47
|
-
{ name: SEARCH_ATTR_INTEGRATION_PROVIDER_ID, type: KEYWORD },
|
|
48
|
-
{ name: SEARCH_ATTR_REGISTRY_KIND, type: KEYWORD },
|
|
49
|
-
{ name: SEARCH_ATTR_IMAGE_DIGEST, type: KEYWORD },
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
/** Identity references a platform workflow is tagged with. */
|
|
53
|
-
export interface PlatformSearchAttributeInput {
|
|
54
|
-
/** The domain the run belongs to — always set. */
|
|
55
|
-
readonly workflowDomain: PlatformWorkflowDomain;
|
|
56
|
-
/** Owning org — omit for a cluster-wide sweep. */
|
|
57
|
-
readonly orgId?: string;
|
|
58
|
-
/** Owning project — omit for a cluster-wide sweep. */
|
|
59
|
-
readonly projectId?: string;
|
|
60
|
-
/** The resource the run operates on (sessionId, studioId, …). */
|
|
61
|
-
readonly subjectId?: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Build the `searchAttributes` map for a `workflow.start` / schedule action.
|
|
66
|
-
* Only set attributes are emitted — a cluster-wide sweep with no tenant
|
|
67
|
-
* carries `WorkflowDomain` alone.
|
|
68
|
-
*/
|
|
69
|
-
export function buildPlatformSearchAttributes(
|
|
70
|
-
input: PlatformSearchAttributeInput,
|
|
71
|
-
): Record<string, string[]> {
|
|
72
|
-
const attrs: Record<string, string[]> = {
|
|
73
|
-
[SEARCH_ATTR_WORKFLOW_DOMAIN]: [input.workflowDomain],
|
|
74
|
-
};
|
|
75
|
-
if (input.orgId) {
|
|
76
|
-
attrs[SEARCH_ATTR_ORG_ID] = [input.orgId];
|
|
77
|
-
}
|
|
78
|
-
if (input.projectId) {
|
|
79
|
-
attrs[SEARCH_ATTR_PROJECT_ID] = [input.projectId];
|
|
80
|
-
}
|
|
81
|
-
if (input.subjectId) {
|
|
82
|
-
attrs[SEARCH_ATTR_SUBJECT_ID] = [input.subjectId];
|
|
83
|
-
}
|
|
84
|
-
return attrs;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Outcome of a registration call — the attribute names newly added. */
|
|
88
|
-
export interface RegisterSearchAttributesResult {
|
|
89
|
-
readonly registered: readonly string[];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Idempotently register {@link PLATFORM_SEARCH_ATTRIBUTES} on a Temporal
|
|
94
|
-
* namespace. Lists the namespace's existing custom attributes first and adds
|
|
95
|
-
* only what is missing, so a multi-replica / multi-service boot is race-safe
|
|
96
|
-
* — every starter calls this before it can `workflow.start` an attribute that
|
|
97
|
-
* the cluster would otherwise reject.
|
|
98
|
-
*
|
|
99
|
-
* Fail-fast: any operator-service error propagates so the caller aborts boot
|
|
100
|
-
* loudly rather than starting workflows whose Visibility tags will be refused.
|
|
101
|
-
*
|
|
102
|
-
* @param connection A Temporal **client** `Connection` (the worker's
|
|
103
|
-
* `NativeConnection` has no `operatorService`).
|
|
104
|
-
* @param namespace The namespace to register on — `xema` for platform work.
|
|
105
|
-
*/
|
|
106
|
-
export async function registerPlatformSearchAttributes(
|
|
107
|
-
connection: Connection,
|
|
108
|
-
namespace: string,
|
|
109
|
-
): Promise<RegisterSearchAttributesResult> {
|
|
110
|
-
const listResponse = await connection.operatorService.listSearchAttributes({
|
|
111
|
-
namespace,
|
|
112
|
-
});
|
|
113
|
-
const existing = new Set(Object.keys(listResponse.customAttributes ?? {}));
|
|
114
|
-
const missing = PLATFORM_SEARCH_ATTRIBUTES.filter(
|
|
115
|
-
(attr) => !existing.has(attr.name),
|
|
116
|
-
);
|
|
117
|
-
if (missing.length === 0) {
|
|
118
|
-
return { registered: [] };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const searchAttributes: Record<string, number> = {};
|
|
122
|
-
for (const attr of missing) {
|
|
123
|
-
searchAttributes[attr.name] = attr.type;
|
|
124
|
-
}
|
|
125
|
-
await connection.operatorService.addSearchAttributes({
|
|
126
|
-
namespace,
|
|
127
|
-
searchAttributes,
|
|
128
|
-
});
|
|
129
|
-
return { registered: missing.map((attr) => attr.name) };
|
|
130
|
-
}
|
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
4
|
-
import { IdentityBootstrapService } from '@xemahq/identity-client';
|
|
5
|
-
|
|
6
|
-
import { getActivityAuthContext } from './activity-auth-context';
|
|
7
|
-
import { ServiceAuthKind, type ServiceAuthSpec } from './auth-kind';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Request input for {@link ServiceHttpClient.request}. Deliberately narrow:
|
|
11
|
-
* we want activities to construct the full URL (including query string) at
|
|
12
|
-
* their own layer, so the client focuses on transport semantics + auth/
|
|
13
|
-
* correlation propagation.
|
|
14
|
-
*/
|
|
15
|
-
export interface ServiceRequest {
|
|
16
|
-
readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
17
|
-
readonly url: string;
|
|
18
|
-
readonly headers?: Readonly<Record<string, string>>;
|
|
19
|
-
readonly body?: string | Uint8Array | null;
|
|
20
|
-
/** Per-request timeout override. Defaults to the client's configured timeout. */
|
|
21
|
-
readonly timeoutMs?: number;
|
|
22
|
-
/**
|
|
23
|
-
* Auth resolution intent. The client resolves the bearer token from
|
|
24
|
-
* here — callers never pass a raw token (except `ExternalBearer`).
|
|
25
|
-
*/
|
|
26
|
-
readonly auth: ServiceAuthSpec;
|
|
27
|
-
/** X-Xema-Org-Id header value. Must be set for any org-scoped call. */
|
|
28
|
-
readonly orgId?: string;
|
|
29
|
-
/** X-Project-Id header value. */
|
|
30
|
-
readonly projectId?: string;
|
|
31
|
-
/** X-Correlation-Id header value. Falls back to a generated correlation id. */
|
|
32
|
-
readonly correlationId?: string;
|
|
33
|
-
/**
|
|
34
|
-
* Optional override for the `X-Xema-Actor-Subject` audit-attribution
|
|
35
|
-
* header. When omitted, the client reads the activity's ALS-stored
|
|
36
|
-
* `actorSubject`. Activities should leave this unset; only specialised
|
|
37
|
-
* flows that attribute to a different subject would override it.
|
|
38
|
-
*/
|
|
39
|
-
readonly actorSubject?: string | null;
|
|
40
|
-
/**
|
|
41
|
-
* `X-Pipeline-Run-Id` header value. Forwarded to downstream services
|
|
42
|
-
* so their `RequestContextMiddleware` can populate `pipelineRunId` on
|
|
43
|
-
* the request context (and, in turn, on any rows the call writes —
|
|
44
|
-
* pages, artifacts, audit log, etc.). Activities should pass
|
|
45
|
-
* `context.workflowRunId` here so workflow runs and pipeline runs
|
|
46
|
-
* share one provenance column downstream.
|
|
47
|
-
*/
|
|
48
|
-
readonly pipelineRunId?: string;
|
|
49
|
-
/**
|
|
50
|
-
* `X-Pipeline-Phase-Key` header value. Forwarded alongside
|
|
51
|
-
* `pipelineRunId`. Activities should pass `context.jobKey` so the
|
|
52
|
-
* workflow lane the call originated from is preserved in downstream
|
|
53
|
-
* provenance records.
|
|
54
|
-
*/
|
|
55
|
-
readonly pipelinePhaseKey?: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface ServiceResponse<TBody = unknown> {
|
|
59
|
-
readonly status: number;
|
|
60
|
-
readonly headers: Readonly<Record<string, string>>;
|
|
61
|
-
readonly body: TBody;
|
|
62
|
-
/** Raw body bytes (for callers that need non-JSON). */
|
|
63
|
-
readonly rawBody: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface ServiceHttpClientOptions {
|
|
67
|
-
/** Default request timeout in ms. Activities can override per-call. */
|
|
68
|
-
readonly defaultTimeoutMs?: number;
|
|
69
|
-
/** Correlation id generator. Defaults to a uuid-style random id. */
|
|
70
|
-
readonly correlationIdFactory?: () => string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Shared HTTP client every Temporal activity uses when calling a downstream
|
|
77
|
-
* service. Single-responsibility wrapper around `fetch`:
|
|
78
|
-
* - Token resolution via {@link IdentityBootstrapService} based on
|
|
79
|
-
* {@link ServiceAuthSpec}. ALS-driven attribution: when the activity
|
|
80
|
-
* runs for a user-launched workflow the {@link OnBehalfOfActivityInterceptor}
|
|
81
|
-
* has populated `actorSubject`, and an `OnBehalfOfActor` request mints a
|
|
82
|
-
* fresh on-behalf-of token; otherwise it falls back to the worker's
|
|
83
|
-
* service-account token.
|
|
84
|
-
* - `X-Xema-Org-Id` / `X-Project-Id` / `X-Correlation-Id` /
|
|
85
|
-
* `X-Xema-Actor-Subject` header propagation.
|
|
86
|
-
* - Consistent timeout handling (AbortController).
|
|
87
|
-
* - Response parsing: JSON unless the server replies with a non-JSON
|
|
88
|
-
* Content-Type (in which case the rawBody is returned and `body` is set
|
|
89
|
-
* to the same string).
|
|
90
|
-
*
|
|
91
|
-
* Tokens are minted *inside* the activity, immediately before the call
|
|
92
|
-
* — no credential ever rides a durable workflow. Activities
|
|
93
|
-
* should NOT construct their own fetch requests: this wrapper is the single
|
|
94
|
-
* chokepoint that stamps every downstream call with the originating subject
|
|
95
|
-
* and a correlation id.
|
|
96
|
-
*/
|
|
97
|
-
@Injectable()
|
|
98
|
-
export class ServiceHttpClient {
|
|
99
|
-
private readonly logger = new Logger(ServiceHttpClient.name);
|
|
100
|
-
private readonly defaultTimeoutMs: number;
|
|
101
|
-
private readonly correlationIdFactory: () => string;
|
|
102
|
-
|
|
103
|
-
constructor(
|
|
104
|
-
private readonly identity: IdentityBootstrapService,
|
|
105
|
-
@Optional() options?: ServiceHttpClientOptions,
|
|
106
|
-
) {
|
|
107
|
-
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
108
|
-
this.correlationIdFactory = options?.correlationIdFactory ?? defaultCorrelationIdFactory;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async request<TBody = unknown>(req: ServiceRequest): Promise<ServiceResponse<TBody>> {
|
|
112
|
-
const controller = new AbortController();
|
|
113
|
-
const timeoutMs = req.timeoutMs ?? this.defaultTimeoutMs;
|
|
114
|
-
// Only bounds remote hangs — justified per the engineering rule on
|
|
115
|
-
// timeouts: external network call with an explicit deadline, typed
|
|
116
|
-
// error, observable in logs.
|
|
117
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
118
|
-
|
|
119
|
-
const authToken = await this.resolveToken(req.auth);
|
|
120
|
-
const headers = this.buildHeaders(req, authToken);
|
|
121
|
-
const init: RequestInit = {
|
|
122
|
-
method: req.method,
|
|
123
|
-
headers,
|
|
124
|
-
signal: controller.signal,
|
|
125
|
-
...(req.body !== undefined && req.body !== null && {
|
|
126
|
-
body:
|
|
127
|
-
typeof req.body === 'string'
|
|
128
|
-
? req.body
|
|
129
|
-
: Buffer.from(req.body.buffer, req.body.byteOffset, req.body.byteLength),
|
|
130
|
-
}),
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
const response = await fetch(req.url, init);
|
|
135
|
-
const rawBody = await response.text();
|
|
136
|
-
const body = this.parseBody<TBody>(rawBody, response);
|
|
137
|
-
const outHeaders: Record<string, string> = {};
|
|
138
|
-
response.headers.forEach((value, key) => {
|
|
139
|
-
outHeaders[key] = value;
|
|
140
|
-
});
|
|
141
|
-
this.logger.debug(
|
|
142
|
-
`${req.method} ${req.url} → ${response.status} (${rawBody.length} bytes)`,
|
|
143
|
-
);
|
|
144
|
-
return {
|
|
145
|
-
status: response.status,
|
|
146
|
-
headers: outHeaders,
|
|
147
|
-
body,
|
|
148
|
-
rawBody,
|
|
149
|
-
};
|
|
150
|
-
} finally {
|
|
151
|
-
clearTimeout(timer);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Convenience JSON helper. Throws on non-2xx. */
|
|
156
|
-
async requestJsonOrThrow<TBody = unknown>(req: ServiceRequest): Promise<TBody> {
|
|
157
|
-
const response = await this.request<TBody>(req);
|
|
158
|
-
if (response.status < 200 || response.status >= 300) {
|
|
159
|
-
throw new ServiceHttpError(
|
|
160
|
-
`${req.method} ${req.url} failed with status ${response.status}`,
|
|
161
|
-
{
|
|
162
|
-
status: response.status,
|
|
163
|
-
rawBody: response.rawBody,
|
|
164
|
-
method: req.method,
|
|
165
|
-
url: req.url,
|
|
166
|
-
},
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
return response.body;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async resolveToken(spec: ServiceAuthSpec): Promise<string | null> {
|
|
173
|
-
switch (spec.kind) {
|
|
174
|
-
case ServiceAuthKind.OnBehalfOfActor: {
|
|
175
|
-
const actor = getActivityAuthContext();
|
|
176
|
-
if (actor?.actorSubject) {
|
|
177
|
-
if (!actor.orgId) {
|
|
178
|
-
throw new Error(
|
|
179
|
-
'Activity auth context missing orgId for on-behalf-of token mint',
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
// The acting party is THIS worker process — the party that will
|
|
183
|
-
// exercise the user's authority downstream. It must be the
|
|
184
|
-
// worker's own registered identity: identity-api binds the actor
|
|
185
|
-
// a caller may name to the credential it proved.
|
|
186
|
-
const params = spec.audience !== undefined
|
|
187
|
-
? {
|
|
188
|
-
userId: actor.actorSubject,
|
|
189
|
-
actorSubject: this.identity.serviceName,
|
|
190
|
-
orgId: actor.orgId,
|
|
191
|
-
audience: spec.audience,
|
|
192
|
-
}
|
|
193
|
-
: {
|
|
194
|
-
userId: actor.actorSubject,
|
|
195
|
-
actorSubject: this.identity.serviceName,
|
|
196
|
-
orgId: actor.orgId,
|
|
197
|
-
};
|
|
198
|
-
return this.identity.getUserAccessToken(params);
|
|
199
|
-
}
|
|
200
|
-
return this.identity.getAccessToken();
|
|
201
|
-
}
|
|
202
|
-
case ServiceAuthKind.Service:
|
|
203
|
-
return this.identity.getAccessToken();
|
|
204
|
-
case ServiceAuthKind.ExternalBearer:
|
|
205
|
-
return spec.token;
|
|
206
|
-
case ServiceAuthKind.None:
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private buildHeaders(req: ServiceRequest, authToken: string | null): Headers {
|
|
212
|
-
const headers = new Headers(req.headers ?? {});
|
|
213
|
-
if (authToken !== null) {
|
|
214
|
-
headers.set('Authorization', `Bearer ${authToken}`);
|
|
215
|
-
}
|
|
216
|
-
if (req.orgId) {
|
|
217
|
-
headers.set('X-Xema-Org-Id', req.orgId);
|
|
218
|
-
}
|
|
219
|
-
if (req.projectId) {
|
|
220
|
-
headers.set('X-Project-Id', req.projectId);
|
|
221
|
-
}
|
|
222
|
-
if (req.pipelineRunId) {
|
|
223
|
-
headers.set('X-Pipeline-Run-Id', req.pipelineRunId);
|
|
224
|
-
}
|
|
225
|
-
if (req.pipelinePhaseKey) {
|
|
226
|
-
headers.set('X-Pipeline-Phase-Key', req.pipelinePhaseKey);
|
|
227
|
-
}
|
|
228
|
-
headers.set('X-Correlation-Id', req.correlationId ?? this.correlationIdFactory());
|
|
229
|
-
const actorSubject =
|
|
230
|
-
req.actorSubject !== undefined
|
|
231
|
-
? req.actorSubject
|
|
232
|
-
: (getActivityAuthContext()?.actorSubject ?? null);
|
|
233
|
-
if (actorSubject) {
|
|
234
|
-
// Documented in API_STANDARDS: downstream audit logs attribute
|
|
235
|
-
// actions to the originating user, NOT the worker identity.
|
|
236
|
-
headers.set('X-Xema-Actor-Subject', actorSubject);
|
|
237
|
-
}
|
|
238
|
-
if (!headers.has('Content-Type') && req.body !== undefined && req.body !== null) {
|
|
239
|
-
headers.set('Content-Type', typeof req.body === 'string' ? 'application/json' : 'application/octet-stream');
|
|
240
|
-
}
|
|
241
|
-
return headers;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private parseBody<TBody>(raw: string, response: Response): TBody {
|
|
245
|
-
if (raw.length === 0) {
|
|
246
|
-
return null as unknown as TBody;
|
|
247
|
-
}
|
|
248
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
249
|
-
if (contentType.includes('application/json')) {
|
|
250
|
-
try {
|
|
251
|
-
return JSON.parse(raw) as TBody;
|
|
252
|
-
} catch (err) {
|
|
253
|
-
throw new ServiceHttpError(
|
|
254
|
-
`Failed to parse JSON response: ${(err as Error).message}`,
|
|
255
|
-
{ status: response.status, rawBody: raw },
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return raw as unknown as TBody;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Typed error surface for HTTP failures; activities rethrow or branch on `.status`. */
|
|
264
|
-
export class ServiceHttpError extends Error {
|
|
265
|
-
readonly status: number;
|
|
266
|
-
readonly rawBody: string;
|
|
267
|
-
readonly method: string;
|
|
268
|
-
readonly url: string;
|
|
269
|
-
|
|
270
|
-
constructor(
|
|
271
|
-
message: string,
|
|
272
|
-
details: { status: number; rawBody: string; method?: string; url?: string },
|
|
273
|
-
) {
|
|
274
|
-
super(message);
|
|
275
|
-
this.name = 'ServiceHttpError';
|
|
276
|
-
this.status = details.status;
|
|
277
|
-
this.rawBody = details.rawBody;
|
|
278
|
-
this.method = details.method ?? '';
|
|
279
|
-
this.url = details.url ?? '';
|
|
280
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function defaultCorrelationIdFactory(): string {
|
|
285
|
-
return `urn:xema:${randomUUID()}`;
|
|
286
|
-
}
|
package/src/lib/temporal-sdk.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
-
// Temporal SDK re-exports — the single import surface for the raw `@temporalio`
|
|
3
|
-
// client primitives that platform services need.
|
|
4
|
-
//
|
|
5
|
-
// `@xemahq/temporal-runtime` is the one battle-tested Temporal setup; every
|
|
6
|
-
// service builds on it instead of wiring the SDK itself. A consumer that ALSO
|
|
7
|
-
// imports `@temporalio/client` directly creates a SECOND copy of the
|
|
8
|
-
// `@temporalio/*` dependency tree in its own `node_modules`. Under
|
|
9
|
-
// `--preserve-symlinks` (required by pnpm + Prisma's generated client) Node
|
|
10
|
-
// then loads `@temporalio/proto` twice from two symlink paths, and
|
|
11
|
-
// `@temporalio/proto/protos/json-module.js` re-registers its protobuf schema
|
|
12
|
-
// into the process-wide protobufjs `Root` → `duplicate name 'ActivityHeartbeat'`.
|
|
13
|
-
//
|
|
14
|
-
// Routing every client-side SDK import through this barrel keeps exactly one
|
|
15
|
-
// `@temporalio/*` tree (the one owned by this package) in the graph, so the
|
|
16
|
-
// proto module loads exactly once. Consumers MUST NOT depend on
|
|
17
|
-
// `@temporalio/client` / `@temporalio/common` directly for client-side use.
|
|
18
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
-
|
|
20
|
-
export {
|
|
21
|
-
Client,
|
|
22
|
-
Connection,
|
|
23
|
-
ScheduleAlreadyRunning,
|
|
24
|
-
WorkflowExecutionAlreadyStartedError,
|
|
25
|
-
} from '@temporalio/client';
|
|
26
|
-
export type {
|
|
27
|
-
ClientOptions,
|
|
28
|
-
ConnectionOptions,
|
|
29
|
-
WorkflowExecutionStatusName,
|
|
30
|
-
} from '@temporalio/client';
|
|
31
|
-
export type { Duration, Workflow } from '@temporalio/common';
|
package/src/lib/worker.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
bundleWorkflowCode,
|
|
5
|
-
Worker,
|
|
6
|
-
type ActivityInterceptorsFactory,
|
|
7
|
-
type NativeConnection,
|
|
8
|
-
type WorkerOptions,
|
|
9
|
-
type WorkflowBundle,
|
|
10
|
-
} from '@temporalio/worker';
|
|
11
|
-
|
|
12
|
-
import { OnBehalfOfActivityInterceptor } from './on-behalf-of-interceptor';
|
|
13
|
-
|
|
14
|
-
import type { PayloadCodec } from '@temporalio/common';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Worker sinks (e.g. the OpenTelemetry workflow-span sink). Aliased to
|
|
18
|
-
* Temporal's own `WorkerOptions['sinks']` so callers pass the native shape.
|
|
19
|
-
*/
|
|
20
|
-
export type PlatformWorkerSinks = NonNullable<WorkerOptions['sinks']>;
|
|
21
|
-
|
|
22
|
-
export interface BundlePlatformWorkflowsOptions {
|
|
23
|
-
/**
|
|
24
|
-
* Extra workflow-side interceptor modules to compile INTO the bundle (e.g.
|
|
25
|
-
* the OpenTelemetry workflow interceptor from `otelWorkflowInterceptorModule`)
|
|
26
|
-
* so trace context propagates through the workflow to its activities.
|
|
27
|
-
*/
|
|
28
|
-
readonly workflowInterceptorModules?: readonly string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Compile a service's workflow bundle once. A service that runs several
|
|
33
|
-
* internal-workflow workers (one per task queue) bundles once and passes the
|
|
34
|
-
* same `WorkflowBundle` to every `createPlatformWorker` call.
|
|
35
|
-
*/
|
|
36
|
-
export async function bundlePlatformWorkflows(
|
|
37
|
-
workflowsPath: string,
|
|
38
|
-
options?: BundlePlatformWorkflowsOptions,
|
|
39
|
-
): Promise<WorkflowBundle> {
|
|
40
|
-
return bundleWorkflowCode({
|
|
41
|
-
workflowsPath,
|
|
42
|
-
...(options?.workflowInterceptorModules !== undefined
|
|
43
|
-
? { workflowInterceptorModules: [...options.workflowInterceptorModules] }
|
|
44
|
-
: {}),
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface PlatformWorkerOptions {
|
|
49
|
-
/** Cluster connection from `connectTemporalWorker`. */
|
|
50
|
-
readonly connection: NativeConnection;
|
|
51
|
-
/** Always the `xema` platform namespace for internal workflows. */
|
|
52
|
-
readonly namespace: string;
|
|
53
|
-
/** `platformTaskQueue(domain)` from `@xemahq/kernel-contracts/workflow`. */
|
|
54
|
-
readonly taskQueue: string;
|
|
55
|
-
/** Pre-built via `bundlePlatformWorkflows` (bundle once, create many). */
|
|
56
|
-
readonly workflowBundle: WorkflowBundle;
|
|
57
|
-
/** Activity implementations registered on this worker. */
|
|
58
|
-
readonly activities: object;
|
|
59
|
-
/**
|
|
60
|
-
* Worker-Versioning build id — pins an in-flight workflow to a compatible
|
|
61
|
-
* worker so a non-deterministic workflow edit cannot break replay.
|
|
62
|
-
*/
|
|
63
|
-
readonly buildId: string;
|
|
64
|
-
readonly useVersioning?: boolean;
|
|
65
|
-
/**
|
|
66
|
-
* Spill codec for large payloads (`@xemahq/dsl/payload-codec`). Omit
|
|
67
|
-
* for small-payload workers / dev.
|
|
68
|
-
*/
|
|
69
|
-
readonly payloadCodec?: PayloadCodec;
|
|
70
|
-
readonly maxConcurrentActivityTaskExecutions?: number;
|
|
71
|
-
readonly maxConcurrentWorkflowTaskExecutions?: number;
|
|
72
|
-
/**
|
|
73
|
-
* Extra activity interceptor factories, appended AFTER the platform's own
|
|
74
|
-
* on-behalf-of interceptor (e.g. an OpenTelemetry activity interceptor).
|
|
75
|
-
*/
|
|
76
|
-
readonly activityInterceptors?: readonly ActivityInterceptorsFactory[];
|
|
77
|
-
/** Worker sinks (e.g. the OTel workflow-span sink). */
|
|
78
|
-
readonly sinks?: PlatformWorkerSinks;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Create a Temporal Worker for an internal-workflow task queue. Wraps
|
|
83
|
-
* `Worker.create` with the platform defaults: the
|
|
84
|
-
* {@link OnBehalfOfActivityInterceptor} (every activity runs in a fresh
|
|
85
|
-
* on-behalf-of identity context), an optional spill payload
|
|
86
|
-
* codec, and Worker Versioning.
|
|
87
|
-
*
|
|
88
|
-
* `worker.run()` is the caller's responsibility — a service starts it off
|
|
89
|
-
* `OnApplicationBootstrap` and awaits the returned promise on shutdown.
|
|
90
|
-
*/
|
|
91
|
-
export async function createPlatformWorker(
|
|
92
|
-
options: PlatformWorkerOptions,
|
|
93
|
-
): Promise<Worker> {
|
|
94
|
-
return Worker.create({
|
|
95
|
-
connection: options.connection,
|
|
96
|
-
namespace: options.namespace,
|
|
97
|
-
taskQueue: options.taskQueue,
|
|
98
|
-
workflowBundle: options.workflowBundle,
|
|
99
|
-
activities: options.activities,
|
|
100
|
-
identity: `xema-platform-worker-${options.namespace}-${options.taskQueue}-${randomUUID().slice(0, 8)}`,
|
|
101
|
-
interceptors: {
|
|
102
|
-
activity: [
|
|
103
|
-
() => ({ inbound: new OnBehalfOfActivityInterceptor() }),
|
|
104
|
-
...(options.activityInterceptors ?? []),
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
...(options.sinks !== undefined ? { sinks: options.sinks } : {}),
|
|
108
|
-
buildId: options.buildId,
|
|
109
|
-
useVersioning: options.useVersioning ?? false,
|
|
110
|
-
...(options.payloadCodec
|
|
111
|
-
? { dataConverter: { payloadCodecs: [options.payloadCodec] } }
|
|
112
|
-
: {}),
|
|
113
|
-
...(options.maxConcurrentActivityTaskExecutions !== undefined
|
|
114
|
-
? {
|
|
115
|
-
maxConcurrentActivityTaskExecutions:
|
|
116
|
-
options.maxConcurrentActivityTaskExecutions,
|
|
117
|
-
}
|
|
118
|
-
: {}),
|
|
119
|
-
...(options.maxConcurrentWorkflowTaskExecutions !== undefined
|
|
120
|
-
? {
|
|
121
|
-
maxConcurrentWorkflowTaskExecutions:
|
|
122
|
-
options.maxConcurrentWorkflowTaskExecutions,
|
|
123
|
-
}
|
|
124
|
-
: {}),
|
|
125
|
-
});
|
|
126
|
-
}
|