@xemahq/temporal-runtime 0.2.4 → 0.3.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/temporal-runtime",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
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,7 +18,8 @@
18
18
  "main": "dist/index.js",
19
19
  "types": "dist/index.d.ts",
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "src"
22
23
  ],
23
24
  "devDependencies": {
24
25
  "@nestjs/common": "11.1.13",
@@ -26,19 +27,20 @@
26
27
  "prettier": "3.6.2",
27
28
  "reflect-metadata": "0.2.2",
28
29
  "typescript": "5.9.3",
29
- "@xemahq/kernel-contracts": "^0.58.0"
30
+ "@xemahq/identity-client": "^0.9.1",
31
+ "@xemahq/kernel-contracts": "^8.0.0"
30
32
  },
31
33
  "peerDependencies": {
32
34
  "@nestjs/common": "^10 || ^11",
33
35
  "reflect-metadata": "^0.2",
34
- "@xemahq/kernel-contracts": ">=0.13.0"
36
+ "@xemahq/identity-client": ">=0.4.0",
37
+ "@xemahq/kernel-contracts": ">=1.0.0"
35
38
  },
36
39
  "dependencies": {
37
40
  "@temporalio/client": "^1.16.2",
38
41
  "@temporalio/common": "^1.16.2",
39
42
  "@temporalio/proto": "^1.16.2",
40
- "@temporalio/worker": "^1.16.2",
41
- "@xemahq/identity-client": "^0.4.0"
43
+ "@temporalio/worker": "^1.16.2"
42
44
  },
43
45
  "exports": {
44
46
  ".": {
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
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';
@@ -0,0 +1,59 @@
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
+ }
@@ -0,0 +1,50 @@
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
+ };
@@ -0,0 +1,153 @@
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
+ }
@@ -0,0 +1,121 @@
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
+ }
@@ -0,0 +1,58 @@
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
+ }
@@ -0,0 +1,115 @@
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
+ }
@@ -0,0 +1,130 @@
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
+ }
@@ -0,0 +1,286 @@
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
+ }
@@ -0,0 +1,31 @@
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';
@@ -0,0 +1,126 @@
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
+ }