@iworkflow/temporal 0.4.0 → 0.6.0

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/README.md CHANGED
@@ -1,11 +1,19 @@
1
1
  # @iworkflow/temporal
2
2
 
3
- Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,worker,workflow,activity}` ^1.24.
3
+ Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,common,worker,workflow,activity}` ^1.24.
4
+
5
+ Модуль объявляет в своих зависимостях **все пять** peer, даже если сам импортирует не все: сборка
6
+ сервера с `external: ["@temporalio/*"]` их не вкладывает, и без `@temporalio/common` под падает
7
+ на старте с `Cannot find module`.
4
8
 
5
9
  - `temporalRuntime(workflowsPath, opts?).start(def)` — воркер на task queue `def.app`; `opts.workflowBundlePath` — готовый бандл прод-сборки.
6
10
  - `temporalClient(opts?)` — старт, сигнал, query по id.
11
+ - `temporalActivities(activities)` — шаги порта для своего `Worker.create` (слив старой очереди, тестовый
12
+ воркер): `Failure` → `ApplicationFailure`, работают `heartbeat` и `stepAttempt`. Шаги без неё
13
+ в своём воркере `heartbeat`/`stepAttempt` не видят — с 0.6.0 рантайм шага ставится на время шага.
7
14
  - `@iworkflow/temporal/workflow` — только для входа песочницы модуля:
8
15
  `export const outreach = temporalFlow(flows.outreach, ["send-twice"])`.
9
16
 
10
- `opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`.
17
+ `opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`;
18
+ `plugins` — плагины SDK воркерам и клиенту; `temporalClient({ lazy: true })` — соединение при первом вызове.
11
19
  Без них — переменные `TEMPORAL_URL` (по умолчанию `127.0.0.1:17233`), `TEMPORAL_NAMESPACE`.
@@ -1,4 +1,5 @@
1
- import type { TLSConfig } from "@temporalio/client";
1
+ import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
2
+ import type { WorkerPlugin } from "@temporalio/worker";
2
3
  /** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
3
4
  export interface TemporalOptions {
4
5
  address?: string;
@@ -8,6 +9,8 @@ export interface TemporalOptions {
8
9
  /** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
9
10
  apiKey?: () => Promise<string>;
10
11
  apiKeyRenewMs?: number;
12
+ /** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
13
+ plugins?: (ConnectionPlugin & WorkerPlugin)[];
11
14
  }
12
15
  export declare const address: (o: TemporalOptions) => string;
13
16
  export declare const namespace: (o: TemporalOptions) => string;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,11 @@
1
- import { type EngineClient, type EngineRuntime } from "@iworkflow/core";
1
+ import { type Activity, type EngineClient, type EngineRuntime } from "@iworkflow/core";
2
2
  import { type TemporalOptions } from "./connection";
3
3
  export type { TemporalOptions };
4
+ /**
5
+ * Шаги порта для своего Worker.create (слив старой очереди, тестовый воркер): Failure доезжает
6
+ * как ApplicationFailure, heartbeat и stepAttempt работают. temporalRuntime делает это сам.
7
+ */
8
+ export declare function temporalActivities<T extends Record<string, Activity>>(activities: T): T;
4
9
  /**
5
10
  * workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
6
11
  * workflowBundlePath — готовый бандл того же входа (прод-сборка); с ним воркер не собирает код сам.
@@ -8,4 +13,7 @@ export type { TemporalOptions };
8
13
  export declare function temporalRuntime(workflowsPath: string, opts?: TemporalOptions & {
9
14
  workflowBundlePath?: string;
10
15
  }): EngineRuntime;
11
- export declare function temporalClient(opts?: TemporalOptions): Promise<EngineClient>;
16
+ /** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
17
+ export declare function temporalClient(opts?: TemporalOptions & {
18
+ lazy?: boolean;
19
+ }): Promise<EngineClient>;
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from "
3
3
  import { Context } from "@temporalio/activity";
4
4
  import { NativeConnection, Worker } from "@temporalio/worker";
5
5
  import {
6
- bindStepRuntime,
6
+ stepScope,
7
+ checkPriority,
8
+ DEFAULT_PRIORITY,
7
9
  exitSignal,
8
10
  FlowError as FlowError2
9
11
  } from "@iworkflow/core";
@@ -39,6 +41,7 @@ var connectOptions = async (o) => ({
39
41
  import {
40
42
  ActivityFailure,
41
43
  ApplicationFailure,
44
+ ApplicationFailureCategory,
42
45
  ChildWorkflowFailure,
43
46
  TerminatedFailure,
44
47
  TimeoutFailure,
@@ -74,13 +77,22 @@ function toTemporal(e, nonRetryable) {
74
77
  message: e.message,
75
78
  type: e.type,
76
79
  details: [e.details],
77
- nonRetryable: nonRetryable || e.nonRetryable
80
+ nonRetryable: nonRetryable || e.nonRetryable,
81
+ ...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {},
82
+ ...e.benign && !nonRetryable ? { category: ApplicationFailureCategory.BENIGN } : {}
78
83
  });
79
84
  if (isFlowError(e))
80
85
  return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
81
86
  return e;
82
87
  }
83
88
 
89
+ // src/scope.ts
90
+ import { AsyncLocalStorage } from "node:async_hooks";
91
+ var alsScope = () => {
92
+ const als = new AsyncLocalStorage;
93
+ return { run: (rt, fn) => als.run(rt, fn), current: () => als.getStore() };
94
+ };
95
+
84
96
  // src/index.ts
85
97
  var SIGNAL = "iwf.signal";
86
98
  var QUERY = "iwf.query";
@@ -99,29 +111,50 @@ var notFound = (id) => (e) => {
99
111
  throw new FlowError2(`процесса ${id} нет`, { reason: "not-found" });
100
112
  throw e;
101
113
  };
102
- var wrap = (h) => ({
114
+ var wrap = (h, ready = async () => {}) => ({
103
115
  id: h.workflowId,
104
- result: () => h.result().catch((e) => {
105
- throw e instanceof WorkflowFailedError && cause(e.cause) || e;
106
- }),
107
- signal: (topic, value) => h.signal(SIGNAL, { topic, value }),
108
- query: async (key) => await h.query(QUERY, key) ?? undefined,
116
+ result: async () => {
117
+ await ready();
118
+ return h.result().catch((e) => {
119
+ throw e instanceof WorkflowFailedError && cause(e.cause) || e;
120
+ });
121
+ },
122
+ signal: async (topic, value) => {
123
+ await ready();
124
+ await h.signal(SIGNAL, { topic, value });
125
+ },
126
+ query: async (key) => {
127
+ await ready();
128
+ return await h.query(QUERY, key) ?? undefined;
129
+ },
109
130
  async describe() {
131
+ await ready();
110
132
  const d = await h.describe().catch(notFound(h.workflowId));
111
133
  return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
112
134
  },
113
- terminate: async (reason) => void await h.terminate(reason).catch(notFound(h.workflowId))
135
+ async terminate(reason) {
136
+ await ready();
137
+ await h.terminate(reason).catch(notFound(h.workflowId));
138
+ }
114
139
  });
115
- var withFailures = (activities) => Object.fromEntries(Object.entries(activities).map(([name, fn]) => [
116
- name,
117
- async (...args) => {
118
- try {
119
- return await fn(...args);
120
- } catch (e) {
121
- throw toTemporal(e, false);
140
+ var TEMPORAL_STEP = {
141
+ heartbeat: async (details) => Context.current().heartbeat(details),
142
+ lastHeartbeat: async () => Context.current().info.heartbeatDetails,
143
+ attempt: () => Context.current().info.attempt
144
+ };
145
+ function temporalActivities(activities) {
146
+ const scope = stepScope(alsScope);
147
+ return Object.fromEntries(Object.entries(activities).map(([name, fn]) => [
148
+ name,
149
+ async (...args) => {
150
+ try {
151
+ return await scope.run(TEMPORAL_STEP, () => fn(...args));
152
+ } catch (e) {
153
+ throw toTemporal(e, false);
154
+ }
122
155
  }
123
- }
124
- ]));
156
+ ]));
157
+ }
125
158
  function temporalRuntime(workflowsPath, opts = {}) {
126
159
  let workers = [];
127
160
  let running = [];
@@ -137,10 +170,6 @@ function temporalRuntime(workflowsPath, opts = {}) {
137
170
  starting = true;
138
171
  exit.arm();
139
172
  stopping = false;
140
- bindStepRuntime({
141
- heartbeat: async (details) => Context.current().heartbeat(details),
142
- lastHeartbeat: async () => Context.current().info.heartbeatDetails
143
- });
144
173
  let c;
145
174
  try {
146
175
  c = conn = await NativeConnection.connect(await connectOptions(opts));
@@ -151,8 +180,8 @@ function temporalRuntime(workflowsPath, opts = {}) {
151
180
  }
152
181
  stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
153
182
  try {
154
- const activities = withFailures(def.activities);
155
- const base = { connection: c, namespace: namespace(opts) };
183
+ const activities = temporalActivities(def.activities);
184
+ const base = { connection: c, namespace: namespace(opts), ...opts.plugins ? { plugins: opts.plugins } : {} };
156
185
  const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
157
186
  workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
158
187
  for (const [pool, limit] of Object.entries(def.pools ?? {}))
@@ -205,25 +234,57 @@ function temporalRuntime(workflowsPath, opts = {}) {
205
234
  };
206
235
  }
207
236
  async function temporalClient(opts = {}) {
208
- const connection = await Connection.connect(await connectOptions(opts));
209
- const stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
237
+ let connection;
238
+ let stopRenew;
239
+ let ready = async () => {
240
+ return;
241
+ };
242
+ const plugins = opts.plugins ? { plugins: opts.plugins } : {};
243
+ if (opts.lazy) {
244
+ let token = "";
245
+ const apiKey = opts.apiKey;
246
+ let pending;
247
+ if (apiKey)
248
+ ready = async () => token || (token = await (pending ??= apiKey().finally(() => pending = undefined)));
249
+ connection = Connection.lazy({
250
+ ...await connectOptions({ ...opts, apiKey: undefined }),
251
+ ...apiKey ? { apiKey: () => token } : {},
252
+ ...plugins
253
+ });
254
+ stopRenew = renewApiKey(opts, (t) => token = t);
255
+ } else {
256
+ connection = await Connection.connect({ ...await connectOptions(opts), ...plugins });
257
+ stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
258
+ }
210
259
  const client = new Client({ connection, namespace: namespace(opts) });
260
+ const startOptions = (target, args, o) => ({
261
+ taskQueue: target.app,
262
+ workflowId: o.id,
263
+ args,
264
+ ...o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {},
265
+ priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY }
266
+ });
211
267
  return {
212
- async start(target, args, opts) {
213
- const h = await client.workflow.start(target.flow, {
214
- taskQueue: target.app,
215
- workflowId: opts.id,
216
- args,
217
- workflowIdConflictPolicy: "USE_EXISTING",
218
- ...opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}
268
+ async start(target, args, o) {
269
+ await ready();
270
+ const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
271
+ return wrap(h, ready);
272
+ },
273
+ async signalWithStart(target, args, o, topic, value) {
274
+ await ready();
275
+ const h = await client.workflow.signalWithStart(target.flow, {
276
+ ...startOptions(target, args, o),
277
+ signal: SIGNAL,
278
+ signalArgs: [{ topic, value }]
219
279
  });
220
- return wrap(h);
280
+ return wrap(h, ready);
221
281
  },
222
- handle: (id) => wrap(client.workflow.getHandle(id)),
282
+ handle: (id) => wrap(client.workflow.getHandle(id), ready),
223
283
  close: () => (stopRenew(), connection.close())
224
284
  };
225
285
  }
226
286
  export {
287
+ temporalActivities,
227
288
  temporalClient,
228
289
  temporalRuntime
229
290
  };
@@ -0,0 +1,2 @@
1
+ import type { StepScope } from "@iworkflow/core";
2
+ export declare const alsScope: () => StepScope;
package/dist/workflow.js CHANGED
@@ -15,12 +15,13 @@ import {
15
15
  TemporalFailure,
16
16
  workflowInfo
17
17
  } from "@temporalio/workflow";
18
- import { FlowError as FlowError2 } from "@iworkflow/core";
18
+ import { checkPriority, DEFAULT_PRIORITY, FlowError as FlowError2 } from "@iworkflow/core";
19
19
 
20
20
  // src/failure.ts
21
21
  import {
22
22
  ActivityFailure,
23
23
  ApplicationFailure,
24
+ ApplicationFailureCategory,
24
25
  ChildWorkflowFailure,
25
26
  TerminatedFailure,
26
27
  TimeoutFailure,
@@ -56,7 +57,9 @@ function toTemporal(e, nonRetryable) {
56
57
  message: e.message,
57
58
  type: e.type,
58
59
  details: [e.details],
59
- nonRetryable: nonRetryable || e.nonRetryable
60
+ nonRetryable: nonRetryable || e.nonRetryable,
61
+ ...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {},
62
+ ...e.benign && !nonRetryable ? { category: ApplicationFailureCategory.BENIGN } : {}
60
63
  });
61
64
  if (isFlowError(e))
62
65
  return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
@@ -71,7 +74,8 @@ var queryDef = defineQuery(QUERY);
71
74
  var poolQueue = (app, pool) => `${app}@${pool}`;
72
75
  var proxies = new Map;
73
76
  function activitiesWith(opts = {}) {
74
- const key = JSON.stringify(opts);
77
+ const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
78
+ const key = JSON.stringify({ ...opts, priority });
75
79
  let p = proxies.get(key);
76
80
  if (!p) {
77
81
  p = proxyActivities({
@@ -79,9 +83,10 @@ function activitiesWith(opts = {}) {
79
83
  startToCloseTimeout: opts.timeoutMs ?? 60000,
80
84
  ...opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {},
81
85
  ...opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {},
86
+ ...priority ? { priority: { priorityKey: priority } } : {},
82
87
  retry: {
83
88
  maximumAttempts: opts.attempts ?? 3,
84
- initialInterval: opts.retryIntervalMs ?? 200,
89
+ initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
85
90
  backoffCoefficient: opts.backoff ?? 1,
86
91
  ...opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {},
87
92
  ...opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}
@@ -116,6 +121,8 @@ function temporalFlow(flow, patches = []) {
116
121
  workflowId: info.workflowId,
117
122
  parentId: info.parent?.workflowId,
118
123
  startedAt: info.startTime,
124
+ priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
125
+ now: async () => Date.now(),
119
126
  step: (name, stepArgs, opts) => activitiesWith(opts)[name](...stepArgs).catch(rethrow),
120
127
  child: (target, childArgs, opts) => executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow),
121
128
  async startChild(target, childArgs, opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/temporal",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Temporal adapter for @iworkflow/core",
5
5
  "keywords": [
6
6
  "workflow",
@@ -41,7 +41,7 @@
41
41
  "prepack": "bun run build"
42
42
  },
43
43
  "dependencies": {
44
- "@iworkflow/core": "^0.4.0"
44
+ "@iworkflow/core": "^0.6.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@temporalio/activity": "^1.24.0",
package/src/connection.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Подключение к Temporal: адрес, TLS, токен с заменой на месте — общее для воркера и клиента.
2
- import type { TLSConfig } from "@temporalio/client";
2
+ import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
3
+ import type { WorkerPlugin } from "@temporalio/worker";
3
4
 
4
5
  /** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
5
6
  export interface TemporalOptions {
@@ -10,6 +11,8 @@ export interface TemporalOptions {
10
11
  /** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
11
12
  apiKey?: () => Promise<string>;
12
13
  apiKeyRenewMs?: number;
14
+ /** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
15
+ plugins?: (ConnectionPlugin & WorkerPlugin)[];
13
16
  }
14
17
 
15
18
  export const address = (o: TemporalOptions) => o.address ?? process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
package/src/failure.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  ActivityFailure,
4
4
  ApplicationFailure,
5
+ ApplicationFailureCategory,
5
6
  ChildWorkflowFailure,
6
7
  TerminatedFailure,
7
8
  TimeoutFailure,
@@ -41,6 +42,10 @@ export function toTemporal(e: unknown, nonRetryable: boolean): unknown {
41
42
  type: e.type,
42
43
  details: [e.details],
43
44
  nonRetryable: nonRetryable || e.nonRetryable,
45
+ // 0 сервер считает «не задано» и берёт паузу политики; 1 мс — «сразу», как у DBOS.
46
+ ...(e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}),
47
+ // BENIGN — только отказу шага: отказ процесса целиком штатным не бывает.
48
+ ...(e.benign && !nonRetryable ? { category: ApplicationFailureCategory.BENIGN } : {}),
44
49
  });
45
50
  if (isFlowError(e))
46
51
  return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
package/src/index.ts CHANGED
@@ -3,7 +3,10 @@ import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError, type Wo
3
3
  import { Context } from "@temporalio/activity";
4
4
  import { NativeConnection, Worker } from "@temporalio/worker";
5
5
  import {
6
- bindStepRuntime,
6
+ stepScope,
7
+ type StepRuntime,
8
+ checkPriority,
9
+ DEFAULT_PRIORITY,
7
10
  exitSignal,
8
11
  FlowError,
9
12
  type Activity,
@@ -11,10 +14,13 @@ import {
11
14
  type EngineClient,
12
15
  type EngineRuntime,
13
16
  type FlowHandle,
17
+ type FlowRef,
14
18
  type FlowStatus,
19
+ type StartOptions,
15
20
  } from "@iworkflow/core";
16
21
  import { address, connectOptions, namespace, renewApiKey, type TemporalOptions } from "./connection";
17
22
  import { cause, toTemporal } from "./failure";
23
+ import { alsScope } from "./scope";
18
24
 
19
25
  export type { TemporalOptions };
20
26
 
@@ -38,35 +44,60 @@ const notFound = (id: string) => (e: unknown): never => {
38
44
  throw e;
39
45
  };
40
46
 
41
- const wrap = <T>(h: WorkflowHandle): FlowHandle<T> => ({
47
+ /** ready — токен ленивого клиента: без него SDK уйдёт с пустым ключом. */
48
+ const wrap = <T>(h: WorkflowHandle, ready: () => Promise<unknown> = async () => {}): FlowHandle<T> => ({
42
49
  id: h.workflowId,
43
- result: () =>
44
- h.result().catch((e) => {
50
+ result: async () => {
51
+ await ready();
52
+ return h.result().catch((e) => {
45
53
  throw (e instanceof WorkflowFailedError && cause(e.cause)) || e;
46
- }),
47
- signal: (topic, value) => h.signal(SIGNAL, { topic, value }),
48
- query: async <V>(key: string) => ((await h.query(QUERY, key)) ?? undefined) as V | undefined,
54
+ });
55
+ },
56
+ signal: async (topic, value) => {
57
+ await ready();
58
+ await h.signal(SIGNAL, { topic, value });
59
+ },
60
+ query: async <V>(key: string) => {
61
+ await ready();
62
+ return ((await h.query(QUERY, key)) ?? undefined) as V | undefined;
63
+ },
49
64
  async describe() {
65
+ await ready();
50
66
  const d = await h.describe().catch(notFound(h.workflowId));
51
67
  return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
52
68
  },
53
- terminate: async (reason) => void (await h.terminate(reason).catch(notFound(h.workflowId))),
69
+ async terminate(reason) {
70
+ await ready();
71
+ await h.terminate(reason).catch(notFound(h.workflowId));
72
+ },
54
73
  });
55
74
 
56
- /** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
57
- const withFailures = (activities: Record<string, Activity>) =>
58
- Object.fromEntries(
75
+ /** Рантайм шага Temporal: всё из контекста активности. */
76
+ const TEMPORAL_STEP: StepRuntime = {
77
+ heartbeat: async (details) => Context.current().heartbeat(details),
78
+ lastHeartbeat: async () => Context.current().info.heartbeatDetails,
79
+ attempt: () => Context.current().info.attempt,
80
+ };
81
+
82
+ /**
83
+ * Шаги порта для своего Worker.create (слив старой очереди, тестовый воркер): Failure доезжает
84
+ * как ApplicationFailure, heartbeat и stepAttempt работают. temporalRuntime делает это сам.
85
+ */
86
+ export function temporalActivities<T extends Record<string, Activity>>(activities: T): T {
87
+ const scope = stepScope(alsScope);
88
+ return Object.fromEntries(
59
89
  Object.entries(activities).map(([name, fn]) => [
60
90
  name,
61
91
  async (...args: unknown[]) => {
62
92
  try {
63
- return await fn(...args);
93
+ return await scope.run(TEMPORAL_STEP, () => fn(...args));
64
94
  } catch (e) {
65
95
  throw toTemporal(e, false);
66
96
  }
67
97
  },
68
98
  ]),
69
- );
99
+ ) as T;
100
+ }
70
101
 
71
102
  /**
72
103
  * workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
@@ -89,10 +120,6 @@ export function temporalRuntime(
89
120
  starting = true;
90
121
  exit.arm();
91
122
  stopping = false;
92
- bindStepRuntime({
93
- heartbeat: async (details) => Context.current().heartbeat(details),
94
- lastHeartbeat: async () => Context.current().info.heartbeatDetails,
95
- });
96
123
  let c: NativeConnection;
97
124
  try {
98
125
  c = conn = await NativeConnection.connect(await connectOptions(opts));
@@ -104,8 +131,8 @@ export function temporalRuntime(
104
131
  stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
105
132
  // Воркер не создался (нет бандла, namespace) — соединение и таймер токена закрываются.
106
133
  try {
107
- const activities = withFailures(def.activities);
108
- const base = { connection: c, namespace: namespace(opts) };
134
+ const activities = temporalActivities(def.activities);
135
+ const base = { connection: c, namespace: namespace(opts), ...(opts.plugins ? { plugins: opts.plugins } : {}) };
109
136
  const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
110
137
  workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
111
138
  // Пул — своя очередь с пределом одновременных шагов в этом процессе.
@@ -162,22 +189,59 @@ export function temporalRuntime(
162
189
  };
163
190
  }
164
191
 
165
- export async function temporalClient(opts: TemporalOptions = {}): Promise<EngineClient> {
166
- const connection = await Connection.connect(await connectOptions(opts));
167
- const stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
192
+ /** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
193
+ export async function temporalClient(opts: TemporalOptions & { lazy?: boolean } = {}): Promise<EngineClient> {
194
+ let connection: Connection;
195
+ let stopRenew: () => void;
196
+ let ready = async (): Promise<unknown> => undefined;
197
+ const plugins = opts.plugins ? { plugins: opts.plugins } : {};
198
+ if (opts.lazy) {
199
+ // Ни Temporal, ни источник токена не нужны для старта: ключ — функцией, берётся при первом вызове.
200
+ let token = "";
201
+ const apiKey = opts.apiKey;
202
+ // Неудача не залипает: следующий вызов спросит токен снова.
203
+ // Один запрос на всех ждущих; неудача не залипает: следующий вызов спросит снова.
204
+ let pending: Promise<string> | undefined;
205
+ if (apiKey)
206
+ ready = async () =>
207
+ token ||
208
+ (token = await (pending ??= apiKey().finally(() => (pending = undefined))));
209
+ connection = Connection.lazy({
210
+ ...(await connectOptions({ ...opts, apiKey: undefined })),
211
+ ...(apiKey ? { apiKey: () => token } : {}),
212
+ ...plugins,
213
+ });
214
+ stopRenew = renewApiKey(opts, (t) => (token = t));
215
+ } else {
216
+ connection = await Connection.connect({ ...(await connectOptions(opts)), ...plugins });
217
+ stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
218
+ }
219
+ // Плагины — у соединения: оно само передаёт их клиенту.
168
220
  const client = new Client({ connection, namespace: namespace(opts) });
221
+ const startOptions = (target: FlowRef, args: unknown[], o: StartOptions) => ({
222
+ taskQueue: target.app,
223
+ workflowId: o.id,
224
+ args,
225
+ ...(o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {}),
226
+ // Явно и по умолчанию: ctx.priority и очередь видят одно и то же, как у DBOS.
227
+ priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY },
228
+ });
169
229
  return {
170
- async start(target, args, opts) {
171
- const h = await client.workflow.start(target.flow, {
172
- taskQueue: target.app,
173
- workflowId: opts.id,
174
- args,
175
- workflowIdConflictPolicy: "USE_EXISTING",
176
- ...(opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}),
230
+ async start(target, args, o) {
231
+ await ready();
232
+ const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
233
+ return wrap(h, ready);
234
+ },
235
+ async signalWithStart(target, args, o, topic, value) {
236
+ await ready();
237
+ const h = await client.workflow.signalWithStart(target.flow, {
238
+ ...startOptions(target, args, o),
239
+ signal: SIGNAL,
240
+ signalArgs: [{ topic, value }],
177
241
  });
178
- return wrap(h);
242
+ return wrap(h, ready);
179
243
  },
180
- handle: (id) => wrap(client.workflow.getHandle(id)),
244
+ handle: (id) => wrap(client.workflow.getHandle(id), ready),
181
245
  close: () => (stopRenew(), connection.close()),
182
246
  };
183
247
  }
package/src/scope.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Область шага на AsyncLocalStorage: stepScope ядра берёт её у первого адаптера процесса.
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import type { StepRuntime, StepScope } from "@iworkflow/core";
4
+
5
+ export const alsScope = (): StepScope => {
6
+ const als = new AsyncLocalStorage<StepRuntime>();
7
+ return { run: (rt, fn) => als.run(rt, fn), current: () => als.getStore() };
8
+ };
package/src/workflow.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  workflowInfo,
19
19
  } from "@temporalio/workflow";
20
20
 
21
- import { FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
21
+ import { checkPriority, DEFAULT_PRIORITY, FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
22
22
  import { fromTemporal, toTemporal } from "./failure";
23
23
 
24
24
  export const SIGNAL = "iwf.signal";
@@ -32,7 +32,9 @@ export const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
32
32
  const proxies = new Map<string, Record<string, (...a: unknown[]) => Promise<unknown>>>();
33
33
 
34
34
  function activitiesWith(opts: StepOptions = {}) {
35
- const key = JSON.stringify(opts);
35
+ // Шаг без своего приоритета — с приоритетом процесса, как у Temporal по умолчанию.
36
+ const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
37
+ const key = JSON.stringify({ ...opts, priority });
36
38
  let p = proxies.get(key);
37
39
  if (!p) {
38
40
  p = proxyActivities({
@@ -40,9 +42,11 @@ function activitiesWith(opts: StepOptions = {}) {
40
42
  startToCloseTimeout: opts.timeoutMs ?? 60_000,
41
43
  ...(opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {}),
42
44
  ...(opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {}),
45
+ ...(priority ? { priority: { priorityKey: priority } } : {}),
43
46
  retry: {
44
47
  maximumAttempts: opts.attempts ?? 3,
45
- initialInterval: opts.retryIntervalMs ?? 200,
48
+ // 0 Temporal не принимает (ValueError — вечный повтор задачи процесса): 1 мс — «сразу», как у DBOS.
49
+ initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
46
50
  backoffCoefficient: opts.backoff ?? 1,
47
51
  ...(opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {}),
48
52
  ...(opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}),
@@ -83,6 +87,9 @@ export function temporalFlow(flow: Flow, patches: string[] = []) {
83
87
  workflowId: info.workflowId,
84
88
  parentId: info.parent?.workflowId,
85
89
  startedAt: info.startTime,
90
+ priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
91
+ // Date.now() в песочнице Temporal детерминирован: время задачи процесса.
92
+ now: async () => Date.now(),
86
93
  step: (name, stepArgs, opts) => activitiesWith(opts)[name]!(...stepArgs).catch(rethrow) as Promise<never>,
87
94
  child: (target, childArgs, opts) =>
88
95
  executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow) as Promise<never>,