@yieldstar/core 0.4.6 → 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.
Files changed (79) hide show
  1. package/dist/base/durable-value.d.ts +71 -0
  2. package/dist/base/durable-value.d.ts.map +1 -0
  3. package/dist/base/durable-value.test.d.ts +2 -0
  4. package/dist/base/durable-value.test.d.ts.map +1 -0
  5. package/dist/base/effect.d.ts +136 -0
  6. package/dist/base/effect.d.ts.map +1 -0
  7. package/dist/base/errors.d.ts +23 -0
  8. package/dist/base/errors.d.ts.map +1 -0
  9. package/dist/base/errors.test.d.ts +2 -0
  10. package/dist/base/errors.test.d.ts.map +1 -0
  11. package/dist/base/event.d.ts +6 -1
  12. package/dist/base/event.d.ts.map +1 -1
  13. package/dist/base/execution.d.ts +244 -0
  14. package/dist/base/execution.d.ts.map +1 -0
  15. package/dist/base/heap.d.ts +15 -3
  16. package/dist/base/heap.d.ts.map +1 -1
  17. package/dist/base/invoker.d.ts +47 -2
  18. package/dist/base/invoker.d.ts.map +1 -1
  19. package/dist/base/runtime.d.ts +27 -4
  20. package/dist/base/runtime.d.ts.map +1 -1
  21. package/dist/base/step.d.ts +26 -5
  22. package/dist/base/step.d.ts.map +1 -1
  23. package/dist/base/store.d.ts +431 -0
  24. package/dist/base/store.d.ts.map +1 -0
  25. package/dist/base/store.test.d.ts +2 -0
  26. package/dist/base/store.test.d.ts.map +1 -0
  27. package/dist/base/workflow.d.ts +17 -0
  28. package/dist/base/workflow.d.ts.map +1 -1
  29. package/dist/index.d.ts +24 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1986 -111
  32. package/dist/lib/effect-state.d.ts +78 -0
  33. package/dist/lib/effect-state.d.ts.map +1 -0
  34. package/dist/lib/execution-dispatcher.d.ts +102 -0
  35. package/dist/lib/execution-dispatcher.d.ts.map +1 -0
  36. package/dist/lib/execution-state.d.ts +248 -0
  37. package/dist/lib/execution-state.d.ts.map +1 -0
  38. package/dist/lib/execution-state.test.d.ts +2 -0
  39. package/dist/lib/execution-state.test.d.ts.map +1 -0
  40. package/dist/lib/wait-for-outcome.d.ts +15 -0
  41. package/dist/lib/wait-for-outcome.d.ts.map +1 -0
  42. package/dist/lib/workflow-runner.d.ts +14 -7
  43. package/dist/lib/workflow-runner.d.ts.map +1 -1
  44. package/dist/utils/map.d.ts +1 -1
  45. package/dist/utils/map.d.ts.map +1 -1
  46. package/package.json +13 -3
  47. package/src/base/durable-value.test.ts +285 -0
  48. package/src/base/durable-value.ts +442 -0
  49. package/src/base/effect.ts +165 -0
  50. package/src/base/errors.test.ts +210 -0
  51. package/src/base/errors.ts +72 -0
  52. package/src/base/event.ts +6 -1
  53. package/src/base/execution.ts +303 -0
  54. package/src/base/heap.ts +17 -3
  55. package/src/base/invoker.ts +60 -2
  56. package/src/base/runtime.ts +28 -7
  57. package/src/base/step.ts +37 -7
  58. package/src/base/store.test.ts +176 -0
  59. package/src/base/store.ts +1123 -0
  60. package/src/base/workflow.ts +17 -0
  61. package/src/index.ts +174 -3
  62. package/src/lib/effect-state.ts +254 -0
  63. package/src/lib/execution-dispatcher.ts +393 -0
  64. package/src/lib/execution-state.test.ts +249 -0
  65. package/src/lib/execution-state.ts +650 -0
  66. package/src/lib/wait-for-outcome.ts +40 -0
  67. package/src/lib/workflow-runner.ts +78 -34
  68. package/src/utils/map.test.ts +1 -1
  69. package/src/utils/map.ts +8 -2
  70. package/dist/base/map.d.ts +0 -21
  71. package/dist/base/map.d.ts.map +0 -1
  72. package/dist/base/map.test.d.ts +0 -2
  73. package/dist/base/map.test.d.ts.map +0 -1
  74. package/dist/base/schedule.d.ts +0 -71
  75. package/dist/base/schedule.d.ts.map +0 -1
  76. package/dist/base/types.d.ts +0 -24
  77. package/dist/base/types.d.ts.map +0 -1
  78. package/src/base/scheduler.ts +0 -8
  79. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,393 @@
1
+ import type { Logger } from "pino";
2
+ import type { WorkflowInvoker } from "../base/invoker";
3
+ import { WorkerExitedError } from "../base/invoker";
4
+ import type { Runtime } from "../base/runtime";
5
+ import type {
6
+ ExecutionAcknowledgeResult,
7
+ ExecutionLease,
8
+ WorkflowRunOutcome,
9
+ } from "../base/execution";
10
+ import { LeaseLostError, isTerminalExecutionStatus } from "../base/execution";
11
+ import { DEFAULT_LEASE_DURATION } from "./execution-state";
12
+
13
+ export const DEFAULT_DISPATCHER_CONCURRENCY = 10;
14
+
15
+ export const DEFAULT_ABORT_GRACE_PERIOD = 1_000;
16
+
17
+ const ABANDONED = Symbol("abandoned");
18
+
19
+ export interface DispatcherOptions {
20
+ invoker: WorkflowInvoker;
21
+ logger: Logger;
22
+ /** The maximum number of deliveries this dispatcher runs at once. @default 10 */
23
+ concurrency?: number;
24
+ /** Milliseconds between claims when nothing is due. @default 10 */
25
+ pollInterval?: number;
26
+ /** Whether the dispatcher's timers keep the process alive. @default true */
27
+ keepAlive?: boolean;
28
+ /** How long each lease lasts unless renewed, in milliseconds. @default 30_000 */
29
+ leaseDuration?: number;
30
+ /**
31
+ * Milliseconds between renewals of a delivery's lease, measured on the
32
+ * process's timers. Must be shorter than `leaseDuration`.
33
+ * @default leaseDuration / 3
34
+ */
35
+ renewInterval?: number;
36
+ /**
37
+ * Milliseconds to keep waiting for a delivery after its lease is lost and
38
+ * its signal aborts. After that the dispatcher abandons the delivery and
39
+ * frees its slot, even if the invoker cannot stop a step function that is
40
+ * still running.
41
+ * @default 1_000
42
+ */
43
+ abortGracePeriod?: number;
44
+ }
45
+
46
+ /**
47
+ * The only component that delivers executions. It leases due executions, reads
48
+ * each event from its record, hands it to the invoker, and acknowledges the
49
+ * outcome under the delivery's lease.
50
+ *
51
+ * - Up to `concurrency` deliveries run at once. The dispatcher claims new
52
+ * executions as slots free up, without waiting for earlier deliveries.
53
+ * - It renews each lease every `renewInterval` while the delivery runs. If a
54
+ * renewal returns `lost`, another claim has taken over: the dispatcher stops
55
+ * renewing, aborts the delivery's signal so it runs no more workflow code,
56
+ * and neither acknowledges nor releases. If the delivery has not settled
57
+ * `abortGracePeriod` after the abort, the dispatcher abandons it and frees
58
+ * its slot. An abandoned delivery may keep running a step function, but its
59
+ * signal and lease token stop every write it attempts.
60
+ * - A delivery that rejects is not acknowledged. If the worker exited before
61
+ * replying, the lease is left to expire; any other rejection is a retry
62
+ * reply, and the lease is released. Either way the wakes stay pending and the
63
+ * execution is delivered again after the redelivery delay.
64
+ * - If reading the record, acknowledging or releasing throws, the lease is
65
+ * left to expire rather than released.
66
+ */
67
+ export class ExecutionDispatcher {
68
+ private runtime: Runtime;
69
+ private invoker: WorkflowInvoker;
70
+ private logger: Logger;
71
+ private pollInterval: number;
72
+ private keepAlive: boolean;
73
+ private concurrency: number;
74
+ private leaseDuration: number;
75
+ private renewInterval: number;
76
+ private abortGracePeriod: number;
77
+ private running = false;
78
+ private looping = false;
79
+ private timer: ReturnType<typeof setTimeout> | undefined;
80
+ private inFlight = new Set<Promise<void>>();
81
+ /** Slots reserved by claims that have not returned yet. */
82
+ private claiming = 0;
83
+
84
+ constructor(runtime: Runtime, options: DispatcherOptions) {
85
+ this.runtime = runtime;
86
+ this.invoker = options.invoker;
87
+ this.logger = options.logger;
88
+ this.pollInterval = options.pollInterval ?? 10;
89
+ this.keepAlive = options.keepAlive ?? true;
90
+ this.concurrency = options.concurrency ?? DEFAULT_DISPATCHER_CONCURRENCY;
91
+ this.leaseDuration = options.leaseDuration ?? DEFAULT_LEASE_DURATION;
92
+ this.renewInterval = options.renewInterval ?? this.leaseDuration / 3;
93
+ this.abortGracePeriod = options.abortGracePeriod ?? DEFAULT_ABORT_GRACE_PERIOD;
94
+ if (!Number.isInteger(this.concurrency) || this.concurrency < 1) {
95
+ throw new RangeError("concurrency must be a positive integer");
96
+ }
97
+ if (!(this.leaseDuration > 0)) {
98
+ throw new RangeError("leaseDuration must be positive");
99
+ }
100
+ if (!(this.renewInterval > 0 && this.renewInterval < this.leaseDuration)) {
101
+ throw new RangeError("renewInterval must be positive and shorter than leaseDuration");
102
+ }
103
+ if (!(this.abortGracePeriod >= 0)) {
104
+ throw new RangeError("abortGracePeriod must not be negative");
105
+ }
106
+ }
107
+
108
+ start() {
109
+ if (this.running) return;
110
+ this.running = true;
111
+ void this.loop();
112
+ }
113
+
114
+ /** Stops claiming and waits for in-flight deliveries to settle. */
115
+ async stop() {
116
+ this.running = false;
117
+ if (this.timer) clearTimeout(this.timer);
118
+ this.timer = undefined;
119
+ await this.drain();
120
+ }
121
+
122
+ get activeDeliveries() {
123
+ return this.inFlight.size;
124
+ }
125
+
126
+ /**
127
+ * Leases due executions, up to the free delivery slots, and starts
128
+ * delivering them. Resolves with the number of deliveries claimed; it does
129
+ * not wait for them to finish.
130
+ */
131
+ async tick(): Promise<number> {
132
+ const slots = this.concurrency - this.inFlight.size - this.claiming;
133
+ if (slots <= 0) return 0;
134
+ this.claiming += slots;
135
+ let leases: ExecutionLease[];
136
+ try {
137
+ leases = await this.runtime.executions.claimDue({
138
+ limit: slots,
139
+ duration: this.leaseDuration,
140
+ });
141
+ } finally {
142
+ this.claiming -= slots;
143
+ }
144
+ for (const lease of leases) {
145
+ const task: Promise<void> = this.dispatch(lease).finally(() => {
146
+ this.inFlight.delete(task);
147
+ // A slot is free: claim again now rather than at the next poll.
148
+ if (this.running && !this.looping) this.schedule(0);
149
+ });
150
+ this.inFlight.add(task);
151
+ }
152
+ return leases.length;
153
+ }
154
+
155
+ /** Waits for every delivery started so far to settle. */
156
+ async drain() {
157
+ while (this.inFlight.size > 0) {
158
+ await Promise.allSettled([...this.inFlight]);
159
+ }
160
+ }
161
+
162
+ private schedule(delay: number) {
163
+ if (this.timer) clearTimeout(this.timer);
164
+ this.timer = setTimeout(() => {
165
+ this.timer = undefined;
166
+ void this.loop();
167
+ }, delay);
168
+ if (!this.keepAlive) this.timer.unref?.();
169
+ }
170
+
171
+ private async loop() {
172
+ if (!this.running || this.looping) return;
173
+ this.looping = true;
174
+ let claimed = 0;
175
+ try {
176
+ claimed = await this.tick();
177
+ } catch (err) {
178
+ this.logger.error({ err }, "Failed to claim due executions");
179
+ } finally {
180
+ this.looping = false;
181
+ }
182
+ if (!this.running) return;
183
+ // When every slot is busy, the next delivery to finish schedules a claim.
184
+ if (this.inFlight.size + this.claiming >= this.concurrency) return;
185
+ this.schedule(claimed > 0 ? 0 : this.pollInterval);
186
+ }
187
+
188
+ private async dispatch(lease: ExecutionLease) {
189
+ const { executionId } = lease;
190
+ const { executions } = this.runtime;
191
+ const renewal = this.renew(lease);
192
+
193
+ try {
194
+ const record = await executions.get(executionId);
195
+ if (!record) {
196
+ this.logger.error({ executionId }, "Claimed execution has no record");
197
+ return;
198
+ }
199
+
200
+ // A finished execution never runs again: return its recorded outcome.
201
+ // Reaching a terminal status ends the lease, so no lease is current
202
+ // here and there is nothing to acknowledge.
203
+ if (isTerminalExecutionStatus(record.status)) {
204
+ this.emit(executionId, record.outcome!);
205
+ return;
206
+ }
207
+
208
+ let outcome: WorkflowRunOutcome | typeof ABANDONED;
209
+ try {
210
+ outcome = await this.abandonAfterAbort(
211
+ this.invoker.deliver(
212
+ {
213
+ workflowId: record.workflowId,
214
+ executionId,
215
+ params: record.params,
216
+ context: record.context,
217
+ },
218
+ { lease, signal: renewal.signal }
219
+ ),
220
+ lease,
221
+ renewal.signal
222
+ );
223
+ } catch (err) {
224
+ const current = await renewal.stop();
225
+ if (!current) return;
226
+ if (WorkerExitedError.is(err)) {
227
+ // The worker may have died mid-write; let the lease expire rather
228
+ // than invite a new holder while its last writes may still land.
229
+ this.logger.error(
230
+ { executionId, err },
231
+ "Worker exited without replying; the lease will expire and the execution will be redelivered"
232
+ );
233
+ return;
234
+ }
235
+ this.logger.error(
236
+ { executionId, err },
237
+ "Delivery failed without an outcome; releasing its lease for redelivery"
238
+ );
239
+ const released = await executions.release({ lease: current });
240
+ if (released.status === "lost") this.warnLost(lease);
241
+ return;
242
+ }
243
+
244
+ // An abandoned delivery's lease was lost: there is nothing to end.
245
+ if (outcome === ABANDONED) return;
246
+ const current = await renewal.stop();
247
+ if (!current) return;
248
+ const result = await executions.acknowledge({ lease: current, outcome });
249
+ if (result.status === "lost") {
250
+ this.warnLost(lease);
251
+ return;
252
+ }
253
+ this.emit(executionId, result);
254
+ } catch (err) {
255
+ // Reading the record, acknowledging or releasing threw. Leave the lease
256
+ // to expire rather than trying to release it: expiry is safe, it counts
257
+ // as an unacknowledged delivery so the execution is delivered again
258
+ // after the redelivery delay, and the backend that just threw may be
259
+ // unreachable anyway.
260
+ this.logger.error(
261
+ { executionId, err },
262
+ "Failed to dispatch execution; the lease will expire and the execution will be redelivered"
263
+ );
264
+ } finally {
265
+ await renewal.stop();
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Settles with `delivery`, or with `ABANDONED` once `abortGracePeriod` has
271
+ * passed since `signal` aborted. An abandoned delivery's late outcome or
272
+ * rejection is logged and otherwise ignored, so it is never acknowledged or
273
+ * released and never surfaces as an unhandled rejection.
274
+ */
275
+ private abandonAfterAbort<T>(
276
+ delivery: Promise<T>,
277
+ lease: ExecutionLease,
278
+ signal: AbortSignal
279
+ ): Promise<T | typeof ABANDONED> {
280
+ return new Promise((resolve, reject) => {
281
+ let abandoned = false;
282
+ let timer: ReturnType<typeof setTimeout> | undefined;
283
+ const onAbort = () => {
284
+ timer = setTimeout(() => {
285
+ abandoned = true;
286
+ this.logger.warn(
287
+ { executionId: lease.executionId, token: lease.token },
288
+ "Delivery did not stop within the abort grace period after its lease was lost; abandoning it"
289
+ );
290
+ resolve(ABANDONED);
291
+ }, this.abortGracePeriod);
292
+ if (!this.keepAlive) timer.unref?.();
293
+ };
294
+ const settle = () => {
295
+ clearTimeout(timer);
296
+ signal.removeEventListener("abort", onAbort);
297
+ };
298
+ if (signal.aborted) onAbort();
299
+ else signal.addEventListener("abort", onAbort, { once: true });
300
+
301
+ delivery.then(
302
+ (value) => {
303
+ settle();
304
+ if (abandoned) {
305
+ this.logger.warn(
306
+ { executionId: lease.executionId, token: lease.token },
307
+ "Abandoned delivery settled late; its outcome is ignored"
308
+ );
309
+ } else resolve(value);
310
+ },
311
+ (err) => {
312
+ settle();
313
+ if (abandoned) {
314
+ this.logger.warn(
315
+ { executionId: lease.executionId, token: lease.token, err },
316
+ "Abandoned delivery rejected late; ignored"
317
+ );
318
+ } else reject(err);
319
+ }
320
+ );
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Renews `lease` every `renewInterval` until stopped. `signal` aborts, with
326
+ * a `LeaseLostError`, once a renewal returns `lost`. `stop` waits for any
327
+ * renewal in flight and resolves with the latest lease, or `undefined` once
328
+ * a renewal has returned `lost`.
329
+ */
330
+ private renew(lease: ExecutionLease) {
331
+ const controller = new AbortController();
332
+ let current: ExecutionLease | undefined = lease;
333
+ let pending: Promise<void> | undefined;
334
+ let stopped = false;
335
+ const { executions } = this.runtime;
336
+
337
+ const timer = setInterval(() => {
338
+ if (stopped || pending || !current) return;
339
+ const renewing = current;
340
+ pending = executions
341
+ .renew({ lease: renewing, duration: this.leaseDuration })
342
+ .then(
343
+ (result) => {
344
+ if (result.status === "lost") {
345
+ current = undefined;
346
+ clearInterval(timer);
347
+ this.warnLost(lease);
348
+ controller.abort(new LeaseLostError(lease.executionId));
349
+ } else if (current) {
350
+ current = result.lease;
351
+ }
352
+ },
353
+ (err) => {
354
+ // Keep trying: the lease stays current until another claim
355
+ // issues a higher token, even after it expires.
356
+ this.logger.error(
357
+ { executionId: lease.executionId, err },
358
+ "Failed to renew lease"
359
+ );
360
+ }
361
+ )
362
+ .finally(() => {
363
+ pending = undefined;
364
+ });
365
+ }, this.renewInterval);
366
+ if (!this.keepAlive) timer.unref?.();
367
+
368
+ return {
369
+ signal: controller.signal,
370
+ async stop() {
371
+ stopped = true;
372
+ clearInterval(timer);
373
+ await pending;
374
+ return current;
375
+ },
376
+ };
377
+ }
378
+
379
+ private warnLost(lease: ExecutionLease) {
380
+ this.logger.warn(
381
+ { executionId: lease.executionId, token: lease.token },
382
+ "Lease was lost to another claim; this delivery stops without an outcome"
383
+ );
384
+ }
385
+
386
+ private emit(executionId: string, result: ExecutionAcknowledgeResult) {
387
+ if (result.status === "completed") {
388
+ this.invoker.workflowEndEmitter.emit(executionId, result.result);
389
+ } else if (result.status === "failed") {
390
+ this.invoker.workflowEndEmitter.emit(executionId, result.error);
391
+ }
392
+ }
393
+ }
@@ -0,0 +1,249 @@
1
+ import { expect, test } from "vitest";
2
+ import { WorkflowValueError } from "../base/errors";
3
+ import { WorkflowStartError } from "../base/execution";
4
+ import { ReadOnlyMap } from "../utils/map";
5
+ import {
6
+ acknowledgeExecution,
7
+ claimExecution,
8
+ createExecutionState,
9
+ createStartedExecution,
10
+ decodeExecutionEvent,
11
+ decodeExecutionOutcome,
12
+ encodeExecutionEvent,
13
+ encodeExecutionOutcome,
14
+ matchStartRequest,
15
+ prepareExecutionAdmission,
16
+ prepareExecutionStart,
17
+ toExecutionRecord,
18
+ } from "./execution-state";
19
+
20
+ const requestId = { executionId: "caller", stepKey: "start:job" };
21
+
22
+ function prepare(request: { workflowId: string; params?: unknown }) {
23
+ const prepared = prepareExecutionStart({ requestId, ...request } as never);
24
+ if (!prepared.ok) throw new Error(prepared.result.message);
25
+ return prepared.start;
26
+ }
27
+
28
+ test.each([
29
+ ["an empty workflow ID", { workflowId: "" }],
30
+ ["array params", { workflowId: "w", params: [] }],
31
+ ["null params", { workflowId: "w", params: null }],
32
+ ["string params", { workflowId: "w", params: "x" }],
33
+ ["class instance params", { workflowId: "w", params: new Date(0) }],
34
+ ])("prepareExecutionStart rejects %s", (_label, request) => {
35
+ const prepared = prepareExecutionStart({ requestId, ...request } as never);
36
+ expect(prepared.ok).toBe(false);
37
+ if (!prepared.ok) {
38
+ expect(prepared.result.code).toBe("INVALID_START_PARAMETERS");
39
+ }
40
+ });
41
+
42
+ test("prepareExecutionStart distinguishes missing params from an empty object", () => {
43
+ expect(prepare({ workflowId: "w" }).params).toBeNull();
44
+ expect(prepare({ workflowId: "w", params: {} }).params).toBe("{}");
45
+ });
46
+
47
+ test("matchStartRequest compares workflow ID and canonical params", () => {
48
+ const caller = createExecutionState({
49
+ executionId: "caller",
50
+ workflowId: "parent",
51
+ encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
52
+ now: 0,
53
+ });
54
+ const { startRequest } = createStartedExecution({
55
+ start: prepare({ workflowId: "w", params: { a: 1, b: 2 } }),
56
+ caller,
57
+ now: 0,
58
+ });
59
+
60
+ expect(
61
+ matchStartRequest(startRequest, prepare({ workflowId: "w", params: { b: 2, a: 1 } }))
62
+ ).toEqual({ status: "accepted", executionId: startRequest.executionId });
63
+
64
+ for (const request of [
65
+ { workflowId: "other", params: { a: 1, b: 2 } },
66
+ { workflowId: "w", params: { a: 1 } },
67
+ { workflowId: "w", params: { a: 1, b: 2, c: undefined } },
68
+ { workflowId: "w" },
69
+ ]) {
70
+ expect(matchStartRequest(startRequest, prepare(request))).toMatchObject({
71
+ status: "rejected",
72
+ code: "START_REQUEST_MISMATCH",
73
+ });
74
+ }
75
+ });
76
+
77
+ test("createStartedExecution admits the target with the caller's context and a parent link", () => {
78
+ const caller = createExecutionState({
79
+ executionId: "caller",
80
+ workflowId: "parent",
81
+ encoded: encodeExecutionEvent({
82
+ params: undefined,
83
+ context: new ReadOnlyMap(new Map([["tenant", "t-1"]])),
84
+ }),
85
+ now: 0,
86
+ });
87
+ const { target, startRequest } = createStartedExecution({
88
+ start: prepare({ workflowId: "w", params: { n: -0, list: [1] } }),
89
+ caller,
90
+ now: 5,
91
+ });
92
+
93
+ expect(startRequest.executionId).toBe(target.executionId);
94
+ expect(target.executionId).not.toBe("caller");
95
+ expect(target.dueAt).toBe(5);
96
+
97
+ const record = toExecutionRecord(target, 5);
98
+ expect(record).toMatchObject({
99
+ workflowId: "w",
100
+ params: { list: [1], n: 0 },
101
+ status: "admitted",
102
+ parent: { executionId: "caller", stepKey: "start:job" },
103
+ });
104
+ expect(record.context.get("tenant")).toBe("t-1");
105
+
106
+ const other = createStartedExecution({
107
+ start: prepare({ workflowId: "w" }),
108
+ caller,
109
+ now: 5,
110
+ });
111
+ expect(other.target.executionId).not.toBe(target.executionId);
112
+ expect(toExecutionRecord(other.target, 5).params).toBeUndefined();
113
+ });
114
+
115
+ test("records admitted without start have no parent", () => {
116
+ const state = createExecutionState({
117
+ executionId: "e",
118
+ workflowId: "w",
119
+ encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
120
+ now: 0,
121
+ });
122
+ expect(toExecutionRecord(state, 0)).not.toHaveProperty("parent");
123
+ });
124
+
125
+ test("start params keep undefined properties", () => {
126
+ const caller = createExecutionState({
127
+ executionId: "caller",
128
+ workflowId: "parent",
129
+ encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
130
+ now: 0,
131
+ });
132
+ const { target } = createStartedExecution({
133
+ start: prepare({ workflowId: "w", params: { a: undefined } }),
134
+ caller,
135
+ now: 0,
136
+ });
137
+ const params = toExecutionRecord(target, 0).params;
138
+ expect(params).toEqual({ a: undefined });
139
+ expect(Object.keys(params!)).toEqual(["a"]);
140
+ });
141
+
142
+ test("execution events round trip through the codec", () => {
143
+ const params = {
144
+ missing: undefined,
145
+ list: [1, undefined],
146
+ tagged: { $type: "undefined" },
147
+ report: { name: "launch", message: "Ship it", stack: "web" },
148
+ };
149
+ const context = new ReadOnlyMap(
150
+ new Map<string, unknown>([
151
+ ["tenant", "acme"],
152
+ ["nothing", undefined],
153
+ ])
154
+ );
155
+ const decoded = decodeExecutionEvent(encodeExecutionEvent({ params, context }));
156
+ expect(decoded.params).toEqual(params);
157
+ expect(Object.keys(decoded.params!)).toContain("missing");
158
+ expect(decoded.params!.report).not.toBeInstanceOf(Error);
159
+ expect([...decoded.context.entries()]).toEqual([
160
+ ["tenant", "acme"],
161
+ ["nothing", undefined],
162
+ ]);
163
+
164
+ const bare = decodeExecutionEvent(
165
+ encodeExecutionEvent({ params: undefined, context: undefined })
166
+ );
167
+ expect(bare.params).toBeUndefined();
168
+ expect(bare.context.size).toBe(0);
169
+ });
170
+
171
+ test.each([
172
+ ["a Date in params", { params: { at: new Date(0) }, context: new Map() }],
173
+ ["a Date in context", { params: {}, context: new Map([["at", new Date(0)]]) }],
174
+ ["a number context key", { params: {}, context: new Map([[7, 1]]) }],
175
+ ])("prepareExecutionAdmission rejects %s with INVALID_EXECUTION_EVENT", (_label, event) => {
176
+ expect(prepareExecutionAdmission({ workflowId: "w", ...event })).toMatchObject({
177
+ ok: false,
178
+ code: "INVALID_EXECUTION_EVENT",
179
+ });
180
+ });
181
+
182
+ class PaymentError extends Error {
183
+ code = "DECLINED";
184
+ constructor(message: string, options?: ErrorOptions) {
185
+ super(message, options);
186
+ this.name = "PaymentError";
187
+ }
188
+ }
189
+
190
+ test("failed outcomes keep the error's registered class, code and cause", () => {
191
+ const error = new WorkflowStartError(
192
+ "START_REQUEST_MISMATCH",
193
+ "mismatch"
194
+ );
195
+ error.cause = new PaymentError("declined");
196
+ const decoded = decodeExecutionOutcome(
197
+ encodeExecutionOutcome({ status: "failed", error })
198
+ );
199
+ if (decoded.status !== "failed") throw new Error("expected failed");
200
+ expect(decoded.error).toBeInstanceOf(WorkflowStartError);
201
+ expect(decoded.error).toMatchObject({ code: "START_REQUEST_MISMATCH", message: "mismatch" });
202
+ expect(decoded.error.stack).toBe(error.stack);
203
+ const cause = decoded.error.cause as Error & { code?: string };
204
+ expect([cause.name, cause.message, cause.code]).toEqual(["PaymentError", "declined", "DECLINED"]);
205
+ });
206
+
207
+ test("failed outcomes record a thrown non-error as Error(String(value))", () => {
208
+ const decoded = decodeExecutionOutcome(
209
+ encodeExecutionOutcome({ status: "failed", error: "boom" as unknown as Error })
210
+ );
211
+ if (decoded.status !== "failed") throw new Error("expected failed");
212
+ expect(Object.getPrototypeOf(decoded.error)).toBe(Error.prototype);
213
+ expect(decoded.error.message).toBe("boom");
214
+ });
215
+
216
+ test("completed outcomes keep errors and undefined in the result", () => {
217
+ const result = { ok: false, error: new RangeError("r"), extra: undefined };
218
+ const decoded = decodeExecutionOutcome(
219
+ encodeExecutionOutcome({ status: "completed", result })
220
+ );
221
+ if (decoded.status !== "completed") throw new Error("expected completed");
222
+ expect(decoded.result).toEqual(result);
223
+ expect((decoded.result as typeof result).error).toBeInstanceOf(RangeError);
224
+ });
225
+
226
+ test("acknowledging a non-durable result records a failed outcome with INVALID_STEP_VALUE", () => {
227
+ const admitted = createExecutionState({
228
+ executionId: "e",
229
+ workflowId: "w",
230
+ encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
231
+ now: 0,
232
+ });
233
+ const { state, lease } = claimExecution(admitted, { now: 0, duration: 1_000 });
234
+ const acknowledged = acknowledgeExecution(state, {
235
+ lease,
236
+ outcome: { status: "completed", result: { at: new Date(0) } },
237
+ now: 1,
238
+ });
239
+ expect(acknowledged.state.status).toBe("failed");
240
+ const outcome = toExecutionRecord(acknowledged.state, 1).outcome;
241
+ for (const recorded of [acknowledged.result, outcome]) {
242
+ if (recorded?.status !== "failed") throw new Error("expected failed");
243
+ expect(recorded.error).toBeInstanceOf(WorkflowValueError);
244
+ expect(recorded.error).toMatchObject({
245
+ code: "INVALID_STEP_VALUE",
246
+ stepKey: "$$workflow-result$$",
247
+ });
248
+ }
249
+ });