@intentius/chant-lexicon-temporal 0.1.14 → 0.1.15

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.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Temporal runtime harness (#161) — runs the serializer's ACTUAL generated
3
+ * workflow under a real Temporal worker + time-skipping test server. This is
4
+ * the durable counterpart to the string-level assertions in op-serializer.test:
5
+ * it proves the emitted control flow behaves correctly at runtime —
6
+ *
7
+ * - happy path: phases run in declared order
8
+ * - gate: the workflow waits for the approval signal (proven via the
9
+ * skipped-clock delta), not just for the timeout
10
+ * - compensation: a failing activity runs onFailure phases in reverse, then
11
+ * the original failure is re-thrown (the #168 fix)
12
+ * - destructive-apply approval: ApplyOp's `nativeApply` does not run until the
13
+ * approval signal is received (#125)
14
+ *
15
+ * Each scenario serializes a real Op, writes the emitted workflow.ts next to
16
+ * this file (so `@intentius/chant-lexicon-temporal/config` resolves), and runs
17
+ * it. Generated workflow files live in ./__generated__ (gitignored).
18
+ */
19
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
20
+ import { TestWorkflowEnvironment } from "@temporalio/testing";
21
+ import { Worker } from "@temporalio/worker";
22
+ import { ApplicationFailure } from "@temporalio/common";
23
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import type { ActivityFn } from "./activity-registry";
27
+ import { serializeOps } from "./serializer";
28
+ import { ApplyOp } from "../composites/apply-op";
29
+ import type { Declarable } from "@intentius/chant/declarable";
30
+
31
+ const GEN_DIR = fileURLToPath(new URL("./__generated__", import.meta.url));
32
+
33
+ // Search attributes the generated workflows upsert (OpName/Phase always; the
34
+ // rest from ApplyOp's searchAttributes + the Drift outcome attribute).
35
+ const SEARCH_ATTRS = { OpName: 2, Phase: 2, Drift: 2, Apply: 2, Env: 2 } as const;
36
+
37
+ function camel(name: string): string {
38
+ return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
39
+ }
40
+ function workflowFn(name: string): string {
41
+ return camel(name) + "Workflow";
42
+ }
43
+ function opMap(config: Record<string, unknown>): Map<string, Declarable> {
44
+ return new Map([[config.name as string, { props: config } as unknown as Declarable]]);
45
+ }
46
+
47
+ let env: TestWorkflowEnvironment;
48
+ let wfCounter = 0;
49
+
50
+ beforeAll(async () => {
51
+ mkdirSync(GEN_DIR, { recursive: true });
52
+ env = await TestWorkflowEnvironment.createTimeSkipping();
53
+ await (env.connection as { operatorService: { addSearchAttributes: (r: unknown) => Promise<unknown> } })
54
+ .operatorService.addSearchAttributes({
55
+ namespace: env.namespace,
56
+ searchAttributes: SEARCH_ATTRS,
57
+ });
58
+ }, 120_000);
59
+
60
+ afterAll(async () => {
61
+ await env?.teardown();
62
+ rmSync(GEN_DIR, { recursive: true, force: true });
63
+ });
64
+
65
+ /**
66
+ * Serialize `op` to a workflow file, run it on a fresh worker, optionally
67
+ * signalling mid-flight. Returns the workflow result and the test-clock time
68
+ * (ms) skipped while it ran.
69
+ */
70
+ async function runOp(
71
+ op: Map<string, Declarable>,
72
+ activities: Record<string, ActivityFn>,
73
+ opts: { signal?: string } = {},
74
+ ): Promise<{ durationMs: number; failed: boolean }> {
75
+ const files = serializeOps(op);
76
+ const wfKey = Object.keys(files).find((k) => k.endsWith("/workflow.ts"))!;
77
+ const opName = wfKey.split("/")[1];
78
+ const wfPath = join(GEN_DIR, `${opName}.workflow.ts`);
79
+ writeFileSync(wfPath, files[wfKey]);
80
+
81
+ const worker = await Worker.create({
82
+ connection: env.nativeConnection,
83
+ namespace: env.namespace,
84
+ taskQueue: opName,
85
+ workflowsPath: wfPath,
86
+ activities,
87
+ });
88
+
89
+ const handle = await env.client.workflow.start(workflowFn(opName), {
90
+ taskQueue: opName,
91
+ workflowId: `${opName}-${wfCounter++}`,
92
+ });
93
+ if (opts.signal) await handle.signal(opts.signal);
94
+
95
+ let failed = false;
96
+ await worker.runUntil(handle.result()).catch(() => { failed = true; });
97
+
98
+ // Duration from the workflow's own server timestamps — under time-skipping
99
+ // this reflects the wall time the workflow *believed* elapsed (so a gate that
100
+ // waited out its 48h timeout shows ~48h; one cleared by a signal shows ~0).
101
+ const desc = await handle.describe();
102
+ const durationMs =
103
+ desc.closeTime && desc.startTime ? desc.closeTime.getTime() - desc.startTime.getTime() : 0;
104
+ return { durationMs, failed };
105
+ }
106
+
107
+ describe("temporal runtime harness (#161)", () => {
108
+ test("happy path — phases run in declared order", async () => {
109
+ const order: string[] = [];
110
+ const rec = (tag: string): ActivityFn => async () => { order.push(tag); };
111
+ const { failed } = await runOp(
112
+ opMap({
113
+ name: "happy-op", overview: "o",
114
+ phases: [
115
+ { name: "P1", steps: [{ kind: "activity", fn: "a" }] },
116
+ { name: "P2", steps: [{ kind: "activity", fn: "b" }] },
117
+ ],
118
+ }),
119
+ { a: rec("a"), b: rec("b") },
120
+ );
121
+ expect(failed).toBe(false);
122
+ expect(order).toEqual(["a", "b"]);
123
+ }, 120_000);
124
+
125
+ test("gate — the workflow waits for the approval signal, not the timeout", async () => {
126
+ const ran: string[] = [];
127
+ const rec = (tag: string): ActivityFn => async () => { ran.push(tag); };
128
+ const gateOp = () => opMap({
129
+ name: "gate-op", overview: "o",
130
+ phases: [
131
+ { name: "Before", steps: [{ kind: "activity", fn: "before" }] },
132
+ { name: "Approve", steps: [{ kind: "gate", signalName: "gate-approve", timeout: "48h" }] },
133
+ { name: "After", steps: [{ kind: "activity", fn: "after" }] },
134
+ ],
135
+ });
136
+
137
+ // Signalled: the gate clears immediately, so the 48h timer never elapses.
138
+ ran.length = 0;
139
+ const signalled = await runOp(gateOp(), { before: rec("before"), after: rec("after") }, {
140
+ signal: "gate-approve",
141
+ });
142
+ expect(signalled.failed).toBe(false);
143
+ expect(ran).toEqual(["before", "after"]);
144
+ // The workflow barely took any (skipped) time — the signal short-circuited the 48h wait.
145
+ expect(signalled.durationMs).toBeLessThan(60 * 60 * 1000); // < 1h
146
+
147
+ // Unsignalled: the gate blocks on its timer — the workflow's elapsed time is ~48h.
148
+ ran.length = 0;
149
+ const unsignalled = await runOp(gateOp(), { before: rec("before"), after: rec("after") });
150
+ expect(unsignalled.durationMs).toBeGreaterThan(47 * 60 * 60 * 1000); // ~48h
151
+ }, 120_000);
152
+
153
+ test("compensation — failing activity runs onFailure in reverse, then re-throws (#168)", async () => {
154
+ const order: string[] = [];
155
+ const activities: Record<string, ActivityFn> = {
156
+ boom: async () => { throw ApplicationFailure.nonRetryable("boom", "Boom"); },
157
+ comp1: async () => { order.push("comp1"); },
158
+ comp2: async () => { order.push("comp2"); },
159
+ };
160
+ const { failed } = await runOp(
161
+ opMap({
162
+ name: "comp-op", overview: "o",
163
+ phases: [{ name: "Main", steps: [{ kind: "activity", fn: "boom" }] }],
164
+ onFailure: [
165
+ { name: "C1", steps: [{ kind: "activity", fn: "comp1" }] },
166
+ { name: "C2", steps: [{ kind: "activity", fn: "comp2" }] },
167
+ ],
168
+ }),
169
+ activities,
170
+ );
171
+ expect(failed).toBe(true); // original failure is re-thrown
172
+ expect(order).toEqual(["comp2", "comp1"]); // reverse phase order
173
+ }, 120_000);
174
+
175
+ test("destructive-apply approval — nativeApply waits for the gate (#125)", async () => {
176
+ const ran: string[] = [];
177
+ const activities: Record<string, ActivityFn> = {
178
+ chantBuild: async () => { ran.push("build"); },
179
+ lifecycleDiff: async () => { ran.push("diff"); return { drifted: false }; },
180
+ nativeApply: async () => { ran.push("apply"); },
181
+ compensateApply: async () => { ran.push("compensate"); },
182
+ };
183
+ const { op } = ApplyOp({ name: "apply-op", env: "prod", target: "cloudformation", delete: "gated" });
184
+
185
+ // Without the signal, the apply must not have run before the gate clears;
186
+ // with the signal it proceeds through nativeApply.
187
+ const res = await runOp(
188
+ new Map([["apply-op", op as unknown as Declarable]]),
189
+ activities,
190
+ { signal: "approve-apply-op" },
191
+ );
192
+ expect(res.failed).toBe(false);
193
+ expect(ran).toEqual(["build", "diff", "apply"]);
194
+ expect(res.durationMs).toBeLessThan(60 * 60 * 1000); // gate cleared by signal, not timeout
195
+ }, 120_000);
196
+ });
@@ -76,7 +76,10 @@ function generateWorkflow(config: OpConfig): string {
76
76
  "// Generated by chant — do not edit directly.",
77
77
  `// Source: ${config.name}.op.ts`,
78
78
  "import { proxyActivities, condition, defineSignal, setHandler, upsertSearchAttributes } from '@temporalio/workflow';",
79
- "import { TEMPORAL_ACTIVITY_PROFILES } from '@intentius/chant-lexicon-temporal';",
79
+ // Import profiles from the config leaf, not the package root: the root pulls
80
+ // in the plugin/serializer (node:fs/path), which Temporal's workflow sandbox
81
+ // forbids and the worker's bundler rejects. config.ts is import-free.
82
+ "import { TEMPORAL_ACTIVITY_PROFILES } from '@intentius/chant-lexicon-temporal/config';",
80
83
  "import type * as activities from './activities';",
81
84
  "",
82
85
  ];
@@ -213,11 +216,23 @@ function generateWorkflow(config: OpConfig): string {
213
216
  }
214
217
  };
215
218
 
216
- renderPhases(config.phases);
217
-
218
219
  if (config.onFailure && config.onFailure.length > 0) {
219
- lines.push(" // onFailure compensation (executed on terminal failure only)");
220
- renderPhases(config.onFailure);
220
+ // Compensation must run ONLY on terminal failure, in reverse phase order,
221
+ // and must never mask the original error — matching the local executor
222
+ // (packages/core/src/op/local-executor.ts). Wrap the main phases in
223
+ // try/catch; run onFailure phases reversed in the catch (best-effort), then
224
+ // re-throw the original failure.
225
+ lines.push(" try {");
226
+ renderPhases(config.phases);
227
+ lines.push(" } catch (__opErr) {");
228
+ lines.push(" // onFailure compensation (reverse phase order, terminal failure only)");
229
+ lines.push(" try {");
230
+ renderPhases([...config.onFailure].reverse());
231
+ lines.push(" } catch { /* best-effort compensation — never mask the original error */ }");
232
+ lines.push(" throw __opErr;");
233
+ lines.push(" }");
234
+ } else {
235
+ renderPhases(config.phases);
221
236
  }
222
237
 
223
238
  lines.push("}");
package/src/resources.ts CHANGED
@@ -91,7 +91,8 @@ export interface TemporalScheduleProps {
91
91
  searchAttributes?: Record<string, unknown>;
92
92
  /**
93
93
  * Retry policy for the triggered workflow execution.
94
- * When set, the generated schedule script includes `workflowStartOptions.retry`.
94
+ * When set, the generated schedule script emits it as the schedule
95
+ * action's `retry` (Temporal `ScheduleOptions` action.retry).
95
96
  */
96
97
  workflowRetryPolicy?: {
97
98
  /** Initial retry interval (e.g. "10s"). Default: Temporal server default (~1s). */
@@ -251,6 +251,59 @@ describe("temporal serializer", () => {
251
251
  expect(result.files["schedules/with-policy.ts"]).toContain("BufferOne");
252
252
  });
253
253
 
254
+ it("emits action.retry from workflowRetryPolicy, including nonRetryableErrorTypes", () => {
255
+ const entities = new Map([makeSchedule("with-retry", {
256
+ action: {
257
+ workflowType: "w", taskQueue: "q",
258
+ workflowRetryPolicy: {
259
+ initialInterval: "10s",
260
+ backoffCoefficient: 2,
261
+ maximumAttempts: 5,
262
+ maximumInterval: "5m",
263
+ nonRetryableErrorTypes: ["ValidationError"],
264
+ },
265
+ },
266
+ })]);
267
+ const ts = (temporalSerializer.serialize(entities) as { files: Record<string, string> }).files["schedules/with-retry.ts"];
268
+ expect(ts).toContain("retry:");
269
+ expect(ts).toContain('"initialInterval": "10s"');
270
+ expect(ts).toContain('"maximumAttempts": 5');
271
+ expect(ts).toContain('"nonRetryableErrorTypes"');
272
+ expect(ts).toContain('"ValidationError"');
273
+ });
274
+
275
+ it("omits action.retry when no workflowRetryPolicy is set", () => {
276
+ const ts = (temporalSerializer.serialize(new Map([makeSchedule("no-retry")])) as { files: Record<string, string> }).files["schedules/no-retry.ts"];
277
+ expect(ts).not.toContain("retry:");
278
+ });
279
+
280
+ it("emits action timeouts, memo, and searchAttributes when set", () => {
281
+ const entities = new Map([makeSchedule("with-action-opts", {
282
+ action: {
283
+ workflowType: "w", taskQueue: "q",
284
+ workflowExecutionTimeout: "1h",
285
+ workflowRunTimeout: "30m",
286
+ memo: { owner: "platform" },
287
+ searchAttributes: { Env: "prod" },
288
+ },
289
+ })]);
290
+ const ts = (temporalSerializer.serialize(entities) as { files: Record<string, string> }).files["schedules/with-action-opts.ts"];
291
+ expect(ts).toContain('workflowExecutionTimeout: "1h"');
292
+ expect(ts).toContain('workflowRunTimeout: "30m"');
293
+ expect(ts).toContain("memo:");
294
+ expect(ts).toContain('"owner": "platform"');
295
+ expect(ts).toContain("searchAttributes:");
296
+ expect(ts).toContain('"Env": "prod"');
297
+ });
298
+
299
+ it("omits action timeouts, memo, and searchAttributes when unset", () => {
300
+ const ts = (temporalSerializer.serialize(new Map([makeSchedule("bare-action")])) as { files: Record<string, string> }).files["schedules/bare-action.ts"];
301
+ expect(ts).not.toContain("workflowExecutionTimeout:");
302
+ expect(ts).not.toContain("workflowRunTimeout:");
303
+ expect(ts).not.toContain("memo:");
304
+ expect(ts).not.toContain("searchAttributes:");
305
+ });
306
+
254
307
  // ── Mixed entities ─────────────────────────────────────────────
255
308
 
256
309
  it("returns SerializerResult with all keys for mixed entities", () => {
package/src/serializer.ts CHANGED
@@ -222,9 +222,33 @@ function serializeSchedule(scheduleId: string, props: TemporalScheduleProps): st
222
222
  ` workflowType: ${JSON.stringify(props.action.workflowType)},`,
223
223
  ` taskQueue: ${JSON.stringify(props.action.taskQueue)},`,
224
224
  ` args: ${JSON.stringify(props.action.args ?? [])},`,
225
- ` },`,
226
225
  ];
227
226
 
227
+ // Optional workflow timeouts → ScheduleOptions action.workflow*Timeout.
228
+ if (props.action.workflowExecutionTimeout) {
229
+ lines.push(` workflowExecutionTimeout: ${JSON.stringify(props.action.workflowExecutionTimeout)},`);
230
+ }
231
+ if (props.action.workflowRunTimeout) {
232
+ lines.push(` workflowRunTimeout: ${JSON.stringify(props.action.workflowRunTimeout)},`);
233
+ }
234
+
235
+ // Retry policy for the triggered workflow → ScheduleOptions action.retry.
236
+ if (props.action.workflowRetryPolicy) {
237
+ lines.push(
238
+ ` retry: ${JSON.stringify(props.action.workflowRetryPolicy, null, 6).replace(/^/gm, " ").trimStart()},`,
239
+ );
240
+ }
241
+
242
+ // Memo + search attributes attached to the triggered workflow.
243
+ if (props.action.memo) {
244
+ lines.push(` memo: ${JSON.stringify(props.action.memo, null, 6).replace(/^/gm, " ").trimStart()},`);
245
+ }
246
+ if (props.action.searchAttributes) {
247
+ lines.push(` searchAttributes: ${JSON.stringify(props.action.searchAttributes, null, 6).replace(/^/gm, " ").trimStart()},`);
248
+ }
249
+
250
+ lines.push(` },`);
251
+
228
252
  if (props.policies) {
229
253
  lines.push(` policies: ${JSON.stringify(props.policies, null, 6).replace(/^/gm, " ").trimStart()},`);
230
254
  }