@iworkflow/temporal 0.0.0-stage → 0.2.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,3 +1,10 @@
1
- # Temporary Holding Version
1
+ # @iworkflow/temporal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,worker,workflow,activity}` ^1.24.
4
+
5
+ - `temporalRuntime(workflowsPath).start(def)` — воркер на task queue `def.app`.
6
+ - `temporalClient()` — старт, сигнал, query по id.
7
+ - `@iworkflow/temporal/workflow` — только для входа песочницы модуля:
8
+ `export const outreach = temporalFlow(flows.outreach, ["send-twice"])`.
9
+
10
+ Переменные: `TEMPORAL_URL` (по умолчанию `127.0.0.1:17233`), `TEMPORAL_NAMESPACE`.
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@iworkflow/temporal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Temporal adapter for @iworkflow/core",
5
+ "keywords": [
6
+ "workflow",
7
+ "temporal"
8
+ ],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/iconicompany/iworkflow.git",
13
+ "directory": "packages/temporal"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./workflow": "./src/workflow.ts"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "type-check": "tsc --noEmit -p tsconfig.json",
29
+ "test": "bun test"
30
+ },
31
+ "dependencies": {
32
+ "@iworkflow/core": "^0.2.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@temporalio/activity": "^1.24.0",
36
+ "@temporalio/client": "^1.24.0",
37
+ "@temporalio/common": "^1.24.0",
38
+ "@temporalio/worker": "^1.24.0",
39
+ "@temporalio/workflow": "^1.24.0"
40
+ },
41
+ "devDependencies": {
42
+ "@temporalio/activity": "^1.24.0",
43
+ "@temporalio/client": "^1.24.0",
44
+ "@temporalio/common": "^1.24.0",
45
+ "@temporalio/worker": "^1.24.0",
46
+ "@temporalio/workflow": "^1.24.0"
47
+ }
48
+ }
package/src/failure.ts ADDED
@@ -0,0 +1,48 @@
1
+ // Отказы Temporal ↔ ошибки порта. Общее для песочницы и клиента: только @temporalio/common.
2
+ import {
3
+ ActivityFailure,
4
+ ApplicationFailure,
5
+ ChildWorkflowFailure,
6
+ TerminatedFailure,
7
+ TimeoutFailure,
8
+ WorkflowExecutionAlreadyStartedError,
9
+ } from "@temporalio/common";
10
+ import { Failure, FlowError, isFlowError } from "@iworkflow/core";
11
+
12
+ /** Отказ шага, дочернего процесса или процесса целиком — FlowError; прочее (отмена) — как есть. */
13
+ export function fromTemporal(e: unknown): unknown {
14
+ if (e instanceof WorkflowExecutionAlreadyStartedError)
15
+ return new FlowError(`процесс ${e.workflowId} уже есть`, { reason: "already-started" });
16
+ if (e instanceof ActivityFailure) {
17
+ const exhausted = e.retryState === "MAXIMUM_ATTEMPTS_REACHED";
18
+ const f = cause(e.cause, exhausted);
19
+ // Срок шага целиком (scheduleToClose) Temporal отдаёт последней ошибкой попытки, а не TimeoutFailure.
20
+ if (f && e.retryState === "TIMEOUT") return new FlowError(f.message, { reason: "timeout", type: f.type, details: f.details });
21
+ return f ?? e;
22
+ }
23
+ if (e instanceof ChildWorkflowFailure) return cause(e.cause, false) ?? e;
24
+ return e;
25
+ }
26
+
27
+ /** Причина отказа процесса или шага — FlowError; undefined — причина не наша (отмена и т. п.). */
28
+ export function cause(c: Error | undefined, exhausted = false): FlowError | undefined {
29
+ if (c instanceof TimeoutFailure) return new FlowError(c.message || "срок вышел", { reason: "timeout", exhausted });
30
+ if (c instanceof TerminatedFailure) return new FlowError(c.message || "процесс прерван", { reason: "terminated" });
31
+ if (c instanceof ApplicationFailure)
32
+ return new FlowError(c.message, { reason: "failed", type: c.type ?? undefined, details: c.details?.[0], exhausted });
33
+ return undefined;
34
+ }
35
+
36
+ /** Отказ из кода порта — ApplicationFailure: обычная ошибка в песочнице не роняет процесс, а повторяет задачу. */
37
+ export function toTemporal(e: unknown, nonRetryable: boolean): unknown {
38
+ if (e instanceof Failure)
39
+ return ApplicationFailure.create({
40
+ message: e.message,
41
+ type: e.type,
42
+ details: [e.details],
43
+ nonRetryable: nonRetryable || e.nonRetryable,
44
+ });
45
+ if (isFlowError(e))
46
+ return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
47
+ return e;
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ // Temporal: воркер на очереди модуля (task queue = имя модуля), клиент — по имени процесса.
2
+ import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError, type WorkflowHandle } from "@temporalio/client";
3
+ import { Context } from "@temporalio/activity";
4
+ import { NativeConnection, Worker } from "@temporalio/worker";
5
+ import {
6
+ bindStepRuntime,
7
+ FlowError,
8
+ type Activity,
9
+ type AppDefinition,
10
+ type EngineClient,
11
+ type EngineRuntime,
12
+ type FlowHandle,
13
+ type FlowStatus,
14
+ } from "@iworkflow/core";
15
+ import { cause, toTemporal } from "./failure";
16
+
17
+ // Те же имена, что в workflow.ts: его нельзя импортировать вне песочницы.
18
+ const SIGNAL = "iwf.signal";
19
+ const QUERY = "iwf.query";
20
+ const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
21
+
22
+ const STATUS: Record<string, FlowStatus> = {
23
+ RUNNING: "running",
24
+ COMPLETED: "completed",
25
+ FAILED: "failed",
26
+ CANCELLED: "terminated",
27
+ TERMINATED: "terminated",
28
+ TIMED_OUT: "timed-out",
29
+ CONTINUED_AS_NEW: "running",
30
+ };
31
+
32
+ const notFound = (id: string) => (e: unknown): never => {
33
+ if (e instanceof WorkflowNotFoundError) throw new FlowError(`процесса ${id} нет`, { reason: "not-found" });
34
+ throw e;
35
+ };
36
+
37
+ const wrap = <T>(h: WorkflowHandle): FlowHandle<T> => ({
38
+ id: h.workflowId,
39
+ result: () =>
40
+ h.result().catch((e) => {
41
+ throw (e instanceof WorkflowFailedError && cause(e.cause)) || e;
42
+ }),
43
+ signal: (topic, value) => h.signal(SIGNAL, { topic, value }),
44
+ query: async <V>(key: string) => ((await h.query(QUERY, key)) ?? undefined) as V | undefined,
45
+ async describe() {
46
+ const d = await h.describe().catch(notFound(h.workflowId));
47
+ return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
48
+ },
49
+ terminate: async (reason) => void (await h.terminate(reason).catch(notFound(h.workflowId))),
50
+ });
51
+
52
+ const address = () => process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
53
+ const namespace = () => process.env.TEMPORAL_NAMESPACE ?? "default";
54
+
55
+ /** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
56
+ const withFailures = (activities: Record<string, Activity>) =>
57
+ Object.fromEntries(
58
+ Object.entries(activities).map(([name, fn]) => [
59
+ name,
60
+ async (...args: unknown[]) => {
61
+ try {
62
+ return await fn(...args);
63
+ } catch (e) {
64
+ throw toTemporal(e, false);
65
+ }
66
+ },
67
+ ]),
68
+ );
69
+
70
+ /** workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса. */
71
+ export function temporalRuntime(workflowsPath: string): EngineRuntime {
72
+ let workers: Worker[] = [];
73
+ let running: Promise<void>[] = [];
74
+ let conn: NativeConnection | undefined;
75
+ return {
76
+ async start(def: AppDefinition) {
77
+ bindStepRuntime({
78
+ heartbeat: async (details) => Context.current().heartbeat(details),
79
+ lastHeartbeat: async () => Context.current().info.heartbeatDetails,
80
+ });
81
+ conn = await NativeConnection.connect({ address: address() });
82
+ const activities = withFailures(def.activities);
83
+ const base = { connection: conn, namespace: namespace() };
84
+ workers = [await Worker.create({ ...base, taskQueue: def.app, workflowsPath, activities })];
85
+ // Пул — своя очередь с пределом одновременных шагов в этом процессе.
86
+ for (const [pool, limit] of Object.entries(def.pools ?? {}))
87
+ workers.push(
88
+ await Worker.create({
89
+ ...base,
90
+ taskQueue: poolQueue(def.app, pool),
91
+ activities,
92
+ maxConcurrentActivityTaskExecutions: limit,
93
+ }),
94
+ );
95
+ running = workers.map((w) => w.run());
96
+ },
97
+ async stop() {
98
+ for (const w of workers) w.shutdown();
99
+ await Promise.all(running);
100
+ await conn?.close();
101
+ },
102
+ };
103
+ }
104
+
105
+ export async function temporalClient(): Promise<EngineClient> {
106
+ const connection = await Connection.connect({ address: address() });
107
+ const client = new Client({ connection, namespace: namespace() });
108
+ return {
109
+ async start(target, args, opts) {
110
+ const h = await client.workflow.start(target.flow, {
111
+ taskQueue: target.app,
112
+ workflowId: opts.id,
113
+ args,
114
+ workflowIdConflictPolicy: "USE_EXISTING",
115
+ ...(opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}),
116
+ });
117
+ return wrap(h);
118
+ },
119
+ handle: (id) => wrap(client.workflow.getHandle(id)),
120
+ close: () => connection.close(),
121
+ };
122
+ }
@@ -0,0 +1,124 @@
1
+ // Обёртка процесса для песочницы Temporal: FlowContext поверх proxyActivities и executeChild.
2
+ // Сигналы и query — по одному обработчику на всё: тема и ключ едут в данных.
3
+ // Импортируется только из входа песочницы модуля: `@iworkflow/temporal/workflow`.
4
+ import {
5
+ condition,
6
+ defineQuery,
7
+ defineSignal,
8
+ deprecatePatch,
9
+ executeChild,
10
+ getExternalWorkflowHandle,
11
+ ParentClosePolicy,
12
+ patched,
13
+ proxyActivities,
14
+ setHandler,
15
+ sleep,
16
+ startChild,
17
+ TemporalFailure,
18
+ workflowInfo,
19
+ } from "@temporalio/workflow";
20
+
21
+ import { FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
22
+ import { fromTemporal, toTemporal } from "./failure";
23
+
24
+ export const SIGNAL = "iwf.signal";
25
+ export const QUERY = "iwf.query";
26
+ const signalDef = defineSignal<[{ topic: string; value: unknown }]>(SIGNAL);
27
+ const queryDef = defineQuery<unknown, [string]>(QUERY);
28
+
29
+ /** Очередь пула шагов модуля; воркер пула поднимает temporalRuntime. */
30
+ export const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
31
+
32
+ const proxies = new Map<string, Record<string, (...a: unknown[]) => Promise<unknown>>>();
33
+
34
+ function activitiesWith(opts: StepOptions = {}) {
35
+ const key = JSON.stringify(opts);
36
+ let p = proxies.get(key);
37
+ if (!p) {
38
+ p = proxyActivities({
39
+ ...(opts.pool ? { taskQueue: poolQueue(workflowInfo().taskQueue, opts.pool) } : {}),
40
+ startToCloseTimeout: opts.timeoutMs ?? 60_000,
41
+ ...(opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {}),
42
+ ...(opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {}),
43
+ retry: {
44
+ maximumAttempts: opts.attempts ?? 3,
45
+ initialInterval: opts.retryIntervalMs ?? 200,
46
+ backoffCoefficient: opts.backoff ?? 1,
47
+ ...(opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {}),
48
+ ...(opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}),
49
+ },
50
+ });
51
+ proxies.set(key, p);
52
+ }
53
+ return p;
54
+ }
55
+
56
+ const childOptions = (app: string, args: unknown[], opts: ChildOptions) => ({
57
+ taskQueue: app,
58
+ workflowId: opts.id,
59
+ args,
60
+ ...(opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}),
61
+ ...(opts.onExisting === "reject" ? { workflowIdReusePolicy: "REJECT_DUPLICATE" as const } : {}),
62
+ });
63
+
64
+ const rethrow = (e: unknown): never => {
65
+ throw fromTemporal(e);
66
+ };
67
+
68
+ /** patches — как AppDefinition.patches: необъявленный id — ошибка, как у DBOS. */
69
+ export function temporalFlow(flow: Flow, patches: string[] = []) {
70
+ return async (...args: unknown[]) => {
71
+ // Буфер по темам: сигнал до recv ждёт в очереди, как в DBOS.
72
+ const inbox = new Map<string, unknown[]>();
73
+ const state = new Map<string, unknown>();
74
+ setHandler(signalDef, ({ topic, value }) => {
75
+ const q = inbox.get(topic) ?? [];
76
+ q.push(value);
77
+ inbox.set(topic, q);
78
+ });
79
+ setHandler(queryDef, (key) => state.get(key));
80
+
81
+ const info = workflowInfo();
82
+ const ctx: FlowContext = {
83
+ workflowId: info.workflowId,
84
+ parentId: info.parent?.workflowId,
85
+ startedAt: info.startTime,
86
+ step: (name, stepArgs, opts) => activitiesWith(opts)[name]!(...stepArgs).catch(rethrow) as Promise<never>,
87
+ child: (target, childArgs, opts) =>
88
+ executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow) as Promise<never>,
89
+ async startChild(target, childArgs, opts) {
90
+ // REJECT_DUPLICATE: завершённый id тоже занят — как у DBOS, где id не переиспользуется.
91
+ const h = await startChild(target.flow, {
92
+ ...childOptions(target.app, childArgs, { ...opts, onExisting: "reject" }),
93
+ parentClosePolicy: ParentClosePolicy.ABANDON,
94
+ }).catch(rethrow);
95
+ return { id: opts.id, result: () => h.result().catch(rethrow) as Promise<never> };
96
+ },
97
+ sleep: (ms) => sleep(ms),
98
+ async recv<T>(topic: string, timeoutMs: number) {
99
+ const ready = await condition(() => (inbox.get(topic)?.length ?? 0) > 0, timeoutMs);
100
+ return ready ? (inbox.get(topic)!.shift() as T) : null;
101
+ },
102
+ signal: (workflowId, topic, value) =>
103
+ getExternalWorkflowHandle(workflowId)
104
+ .signal(signalDef, { topic, value })
105
+ .catch((e) => {
106
+ if (!(e instanceof TemporalFailure)) throw e;
107
+ throw new FlowError(`процесса ${workflowId} нет или он завершён`, { reason: "not-found" });
108
+ }),
109
+ async setState(key, value) {
110
+ state.set(key, value);
111
+ },
112
+ patched: async (id) => {
113
+ if (!patches.includes(id)) throw new Error(`патч "${id}" не объявлен в patches процесса`);
114
+ return patched(id);
115
+ },
116
+ deprecatePatch: async (id) => deprecatePatch(id),
117
+ };
118
+ try {
119
+ return await flow(ctx, ...args);
120
+ } catch (e) {
121
+ throw toTemporal(e, true);
122
+ }
123
+ };
124
+ }