@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,650 @@
1
+ import type { EventContext, EventParams } from "../base/event";
2
+ import type {
3
+ ExecutionAcknowledgeResult,
4
+ ExecutionLease,
5
+ ExecutionOutcome,
6
+ ExecutionParent,
7
+ ExecutionRecord,
8
+ ExecutionReleaseResult,
9
+ ExecutionRenewResult,
10
+ ExecutionStartRequest,
11
+ ExecutionStartResult,
12
+ ExecutionStatus,
13
+ LeaseLost,
14
+ WorkflowRunOutcome,
15
+ } from "../base/execution";
16
+ import { isTerminalExecutionStatus } from "../base/execution";
17
+ import type { DurableValue } from "../base/durable-value";
18
+ import {
19
+ decodeParsedValue,
20
+ durableValueCodec,
21
+ encodeThrownValue,
22
+ isPlainObject,
23
+ } from "../base/durable-value";
24
+ import { WORKFLOW_RESULT_STEP_KEY, WorkflowValueError } from "../base/errors";
25
+ import { ReadOnlyMap } from "../utils/map";
26
+
27
+ /**
28
+ * Storage-neutral state of one execution record, with the event and outcome
29
+ * already encoded. Runtime adapters persist these fields however they like and
30
+ * apply the transitions below inside one atomic write, so every adapter keeps
31
+ * the same lease, wake and acknowledgement semantics.
32
+ *
33
+ * Lease transitions (claim, renew, release) do not change `updatedAt`.
34
+ */
35
+ export type ExecutionState = {
36
+ executionId: string;
37
+ workflowId: string;
38
+ params: string;
39
+ context: string;
40
+ /**
41
+ * The status the execution rests in when it has no live lease. The record's
42
+ * `running` status is derived from the lease and the clock by
43
+ * `executionStatusAt`, so an expired lease reads as ended without a write.
44
+ */
45
+ status: Exclude<ExecutionStatus, "running">;
46
+ outcome: string | null;
47
+ admittedAt: number;
48
+ updatedAt: number;
49
+ /** The earliest of the wakes not covered by a lease. */
50
+ dueAt: number | null;
51
+ /**
52
+ * The wakes the current lease covers. They stay pending until that lease is
53
+ * acknowledged, so a lease that is released or expires leaves them due.
54
+ */
55
+ claimedDueAt: number | null;
56
+ /** The highest token issued for this execution (0 before the first claim). */
57
+ leaseToken: number;
58
+ /**
59
+ * Non-null while the lease with `leaseToken` is held, that is, claimed and
60
+ * not yet released or acknowledged. A held lease stays current after it
61
+ * expires, until a later claim issues a higher token.
62
+ */
63
+ leaseExpiresAt: number | null;
64
+ /**
65
+ * Consecutive leases that ended by release or by expiry and a later claim.
66
+ * It does not include a held lease that has expired;
67
+ * `unacknowledgedDeliveryCountAt` adds that one.
68
+ */
69
+ unacknowledgedDeliveryCount: number;
70
+ /**
71
+ * When a released execution may be claimed again. A held lease's delay is
72
+ * measured from `leaseExpiresAt` instead.
73
+ */
74
+ redeliverAt: number | null;
75
+ /** Set at admission by `start`, and never changed. */
76
+ parent: ExecutionParent | null;
77
+ };
78
+
79
+ export const DEFAULT_LEASE_DURATION = 30_000;
80
+
81
+ /** The redelivery delay after one unacknowledged delivery. */
82
+ export const REDELIVERY_DELAY_BASE = 1_000;
83
+
84
+ export const REDELIVERY_DELAY_MAX = 300_000;
85
+
86
+ /**
87
+ * How long an execution waits after a delivery ends unacknowledged before it
88
+ * can be claimed again. Starts at `REDELIVERY_DELAY_BASE` and doubles with
89
+ * each consecutive unacknowledged delivery, up to `REDELIVERY_DELAY_MAX`.
90
+ */
91
+ export function redeliveryDelay(unacknowledgedDeliveryCount: number): number {
92
+ if (unacknowledgedDeliveryCount <= 0) return 0;
93
+ const exponent = Math.min(unacknowledgedDeliveryCount - 1, 30);
94
+ return Math.min(REDELIVERY_DELAY_BASE * 2 ** exponent, REDELIVERY_DELAY_MAX);
95
+ }
96
+
97
+ export class ExecutionEventEncodingError extends Error {
98
+ constructor(message: string) {
99
+ super(message);
100
+ this.name = "ExecutionEventEncodingError";
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Encodes `params` with the durable value codec and `context` as its entries,
106
+ * so that decoding returns exactly what was admitted. Context keys must be
107
+ * strings. Throws `ExecutionEventEncodingError` for any value the codec
108
+ * rejects.
109
+ */
110
+ export function encodeExecutionEvent(event: {
111
+ params: EventParams;
112
+ context: EventContext | undefined;
113
+ }): { params: string; context: string } {
114
+ const params = durableValueCodec.encode(event.params);
115
+ if (params.status === "invalid") {
116
+ throw new ExecutionEventEncodingError(`params${params.message.slice(1)}`);
117
+ }
118
+ const entries: unknown[] = [];
119
+ for (const [key, value] of event.context?.entries() ?? []) {
120
+ if (typeof key !== "string") {
121
+ throw new ExecutionEventEncodingError(
122
+ `context: keys must be strings, got ${typeof key}`
123
+ );
124
+ }
125
+ entries.push([key, value]);
126
+ }
127
+ const context = durableValueCodec.encode(entries);
128
+ if (context.status === "invalid") {
129
+ throw new ExecutionEventEncodingError(`context${context.message.slice(1)}`);
130
+ }
131
+ return { params: params.canonical, context: context.canonical };
132
+ }
133
+
134
+ export function decodeExecutionEvent(encoded: {
135
+ params: string;
136
+ context: string;
137
+ }): { params: EventParams; context: EventContext } {
138
+ const entries = decodeOrThrow(encoded.context, "context");
139
+ if (
140
+ !Array.isArray(entries) ||
141
+ !entries.every(
142
+ (entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string"
143
+ )
144
+ ) {
145
+ throw new Error("Invalid execution context encoding: expected [key, value] entries");
146
+ }
147
+ return {
148
+ params: decodeOrThrow(encoded.params, "params") as EventParams,
149
+ context: new ReadOnlyMap(new Map(entries as [string, DurableValue][])),
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Encodes a terminal outcome. A completed result the codec rejects is recorded
155
+ * as a failed outcome with `WorkflowValueError` (`INVALID_STEP_VALUE`), and a
156
+ * failure that is not an error is recorded as `Error(String(value))`.
157
+ */
158
+ export function encodeExecutionOutcome(outcome: ExecutionOutcome): string {
159
+ if (outcome.status === "completed") {
160
+ const result = durableValueCodec.encode(outcome.result);
161
+ if (result.status === "encoded") {
162
+ return `{"result":${result.canonical},"status":"completed"}`;
163
+ }
164
+ return encodeFailedOutcome(
165
+ new WorkflowValueError(
166
+ "INVALID_STEP_VALUE",
167
+ WORKFLOW_RESULT_STEP_KEY,
168
+ `Workflow result is not a durable value: ${result.message}`
169
+ )
170
+ );
171
+ }
172
+ return encodeFailedOutcome(outcome.error);
173
+ }
174
+
175
+ function encodeFailedOutcome(error: unknown) {
176
+ // An outcome's error is always an Error, so a thrown value that is not one
177
+ // is recorded as Error(String(value)) even when it is durable.
178
+ const encoded = encodeThrownValue(
179
+ error instanceof Error ? error : new Error(safeString(error))
180
+ );
181
+ return `{"error":${encoded},"status":"failed"}`;
182
+ }
183
+
184
+ export function decodeExecutionOutcome(encoded: string): ExecutionOutcome {
185
+ const parsed = JSON.parse(encoded);
186
+ if (parsed.status === "completed") {
187
+ return { status: "completed", result: decodeParsedValue(parsed.result, "result") };
188
+ }
189
+ const error = decodeParsedValue(parsed.error, "error");
190
+ if (!(error instanceof Error)) {
191
+ throw new Error("Invalid failed outcome encoding: expected an error");
192
+ }
193
+ return { status: "failed", error };
194
+ }
195
+
196
+ function decodeOrThrow(canonical: string, label: string) {
197
+ const decoding = durableValueCodec.decode(canonical);
198
+ if (decoding.status === "invalid") {
199
+ throw new Error(`Invalid ${label} encoding: ${decoding.message}`);
200
+ }
201
+ return decoding.value;
202
+ }
203
+
204
+ function safeString(value: unknown) {
205
+ try {
206
+ return String(value);
207
+ } catch {
208
+ return "Thrown value is not a durable value";
209
+ }
210
+ }
211
+
212
+ export function createExecutionState(params: {
213
+ executionId: string;
214
+ workflowId: string;
215
+ encoded: { params: string; context: string };
216
+ now: number;
217
+ parent?: ExecutionParent;
218
+ }): ExecutionState {
219
+ return {
220
+ executionId: params.executionId,
221
+ workflowId: params.workflowId,
222
+ params: params.encoded.params,
223
+ context: params.encoded.context,
224
+ status: "admitted",
225
+ outcome: null,
226
+ admittedAt: params.now,
227
+ updatedAt: params.now,
228
+ dueAt: params.now,
229
+ claimedDueAt: null,
230
+ leaseToken: 0,
231
+ leaseExpiresAt: null,
232
+ unacknowledgedDeliveryCount: 0,
233
+ redeliverAt: null,
234
+ parent: params.parent ?? null,
235
+ };
236
+ }
237
+
238
+ /** Whether a lease is held and has expired. Such a lease is still current. */
239
+ function hasExpiredLease(state: ExecutionState, now: number) {
240
+ return state.leaseExpiresAt !== null && state.leaseExpiresAt <= now;
241
+ }
242
+
243
+ /** The record's status at `now`: `running` while a lease is live. */
244
+ export function executionStatusAt(
245
+ state: ExecutionState,
246
+ now: number
247
+ ): ExecutionStatus {
248
+ if (state.leaseExpiresAt !== null && state.leaseExpiresAt > now) {
249
+ return "running";
250
+ }
251
+ return state.status;
252
+ }
253
+
254
+ /**
255
+ * Consecutive unacknowledged deliveries at `now`, counting a held lease that
256
+ * has expired as ended.
257
+ */
258
+ export function unacknowledgedDeliveryCountAt(state: ExecutionState, now: number) {
259
+ return state.unacknowledgedDeliveryCount + (hasExpiredLease(state, now) ? 1 : 0);
260
+ }
261
+
262
+ export function toExecutionRecord(
263
+ state: ExecutionState,
264
+ now: number
265
+ ): ExecutionRecord {
266
+ const event = decodeExecutionEvent(state);
267
+ return {
268
+ executionId: state.executionId,
269
+ workflowId: state.workflowId,
270
+ params: event.params,
271
+ context: event.context,
272
+ status: executionStatusAt(state, now),
273
+ ...(state.outcome !== null
274
+ ? { outcome: decodeExecutionOutcome(state.outcome) }
275
+ : {}),
276
+ admittedAt: state.admittedAt,
277
+ updatedAt: state.updatedAt,
278
+ unacknowledgedDeliveryCount: unacknowledgedDeliveryCountAt(state, now),
279
+ ...(state.parent !== null ? { parent: { ...state.parent } } : {}),
280
+ };
281
+ }
282
+
283
+ /**
284
+ * The earliest time `claimDue` may return this execution, or null when it has
285
+ * nothing pending. It depends only on the state, not on the clock, so
286
+ * adapters can persist and index it to find due executions.
287
+ */
288
+ export function nextClaimAt(state: ExecutionState): number | null {
289
+ if (isTerminalExecutionStatus(state.status)) return null;
290
+ if (state.leaseExpiresAt !== null) {
291
+ // A held lease covers the wakes that were due when it was claimed. Once it
292
+ // expires, it counts as unacknowledged, and the redelivery delay runs
293
+ // from its expiry.
294
+ const due = earliest(state.dueAt, state.claimedDueAt);
295
+ if (due === null) return null;
296
+ return Math.max(
297
+ due,
298
+ state.leaseExpiresAt + redeliveryDelay(state.unacknowledgedDeliveryCount + 1)
299
+ );
300
+ }
301
+ if (state.dueAt === null) return null;
302
+ return state.redeliverAt === null
303
+ ? state.dueAt
304
+ : Math.max(state.dueAt, state.redeliverAt);
305
+ }
306
+
307
+ export function isExecutionClaimable(state: ExecutionState, now: number) {
308
+ const at = nextClaimAt(state);
309
+ return at !== null && at <= now;
310
+ }
311
+
312
+ /**
313
+ * Whether `lease` is the execution's current lease: its token is the highest
314
+ * issued and the lease has not been released or acknowledged. Expiry alone
315
+ * does not end it. Adapters check this in the same atomic write as every
316
+ * fenced write.
317
+ */
318
+ export function isLeaseCurrent(state: ExecutionState, lease: ExecutionLease) {
319
+ return (
320
+ state.executionId === lease.executionId &&
321
+ state.leaseExpiresAt !== null &&
322
+ state.leaseToken === lease.token
323
+ );
324
+ }
325
+
326
+ const LOST: LeaseLost = { status: "lost" };
327
+
328
+ /** Returns null when the execution is terminal and the wake is ignored. */
329
+ export function requestExecutionWake(
330
+ state: ExecutionState,
331
+ wakeAt: number,
332
+ now: number
333
+ ): ExecutionState | null {
334
+ if (isTerminalExecutionStatus(state.status)) return null;
335
+ return { ...state, dueAt: earliest(state.dueAt, wakeAt), updatedAt: now };
336
+ }
337
+
338
+ /**
339
+ * Leases a claimable execution with the next token. Adapters check
340
+ * `isExecutionClaimable` first, in the same atomic write.
341
+ */
342
+ export function claimExecution(
343
+ state: ExecutionState,
344
+ params: { now: number; duration: number }
345
+ ): { state: ExecutionState; lease: ExecutionLease } {
346
+ const lease: ExecutionLease = {
347
+ executionId: state.executionId,
348
+ token: state.leaseToken + 1,
349
+ expiresAt: params.now + params.duration,
350
+ };
351
+ return {
352
+ state: {
353
+ ...state,
354
+ // Claiming over an expired lease ends it unacknowledged.
355
+ unacknowledgedDeliveryCount:
356
+ state.unacknowledgedDeliveryCount + (state.leaseExpiresAt !== null ? 1 : 0),
357
+ // The lease covers the wakes due now, plus any an expired lease covered.
358
+ claimedDueAt: earliest(state.dueAt, state.claimedDueAt),
359
+ dueAt: null,
360
+ leaseToken: lease.token,
361
+ leaseExpiresAt: lease.expiresAt,
362
+ redeliverAt: null,
363
+ },
364
+ lease,
365
+ };
366
+ }
367
+
368
+ export function renewExecutionLease(
369
+ state: ExecutionState,
370
+ params: { lease: ExecutionLease; duration: number; now: number }
371
+ ): { state: ExecutionState; result: ExecutionRenewResult } {
372
+ if (!isLeaseCurrent(state, params.lease)) return { state, result: LOST };
373
+ const lease: ExecutionLease = {
374
+ ...params.lease,
375
+ expiresAt: params.now + params.duration,
376
+ };
377
+ return {
378
+ state: { ...state, leaseExpiresAt: lease.expiresAt },
379
+ result: { status: "renewed", lease },
380
+ };
381
+ }
382
+
383
+ export function releaseExecutionLease(
384
+ state: ExecutionState,
385
+ params: { lease: ExecutionLease; now: number }
386
+ ): { state: ExecutionState; result: ExecutionReleaseResult } {
387
+ if (!isLeaseCurrent(state, params.lease)) return { state, result: LOST };
388
+ const unacknowledgedDeliveryCount = state.unacknowledgedDeliveryCount + 1;
389
+ return {
390
+ state: {
391
+ ...state,
392
+ // The wakes the lease covered stay pending.
393
+ dueAt: earliest(state.dueAt, state.claimedDueAt),
394
+ claimedDueAt: null,
395
+ leaseExpiresAt: null,
396
+ unacknowledgedDeliveryCount,
397
+ redeliverAt: params.now + redeliveryDelay(unacknowledgedDeliveryCount),
398
+ },
399
+ result: { status: "released" },
400
+ };
401
+ }
402
+
403
+ export function acknowledgeExecution(
404
+ state: ExecutionState,
405
+ params: {
406
+ lease: ExecutionLease;
407
+ outcome: WorkflowRunOutcome;
408
+ now: number;
409
+ }
410
+ ): { state: ExecutionState; result: ExecutionAcknowledgeResult } {
411
+ if (!isLeaseCurrent(state, params.lease)) return { state, result: LOST };
412
+
413
+ // A held lease is never on a terminal record, but keep the first outcome
414
+ // regardless: terminal outcomes are immutable.
415
+ if (isTerminalExecutionStatus(state.status)) {
416
+ return { state, result: decodeExecutionOutcome(state.outcome!) };
417
+ }
418
+
419
+ const { outcome, now } = params;
420
+ const ended = {
421
+ claimedDueAt: null,
422
+ leaseExpiresAt: null,
423
+ unacknowledgedDeliveryCount: 0,
424
+ redeliverAt: null,
425
+ updatedAt: now,
426
+ };
427
+
428
+ if (outcome.status !== "suspended") {
429
+ const encoded = encodeExecutionOutcome(
430
+ outcome.status === "completed"
431
+ ? { status: "completed", result: outcome.result }
432
+ : { status: "failed", error: outcome.error }
433
+ );
434
+ // An invalid result is recorded as failed, so the status comes from the
435
+ // encoding rather than the reported outcome.
436
+ const terminal = decodeExecutionOutcome(encoded);
437
+ return {
438
+ // Reaching a terminal status clears every pending wake.
439
+ state: {
440
+ ...state,
441
+ ...ended,
442
+ status: terminal.status,
443
+ outcome: encoded,
444
+ dueAt: null,
445
+ },
446
+ result: terminal,
447
+ };
448
+ }
449
+
450
+ return {
451
+ // The wakes the lease covered are cleared; wakes requested since the
452
+ // claim stay pending, merged with the outcome's `wakeAt`.
453
+ state: {
454
+ ...state,
455
+ ...ended,
456
+ status: "suspended",
457
+ dueAt: earliest(state.dueAt, outcome.wakeAt ?? null),
458
+ },
459
+ result: { status: "suspended" },
460
+ };
461
+ }
462
+
463
+ function earliest(a: number | null, b: number | null): number | null {
464
+ if (a === null) return b;
465
+ if (b === null) return a;
466
+ return Math.min(a, b);
467
+ }
468
+
469
+ /**
470
+ * Validates an admission request and encodes its event. Adapters call this
471
+ * before their atomic insert, then reject with `EXECUTION_ID_CONFLICT` if the
472
+ * ID is already in use.
473
+ */
474
+ export function prepareExecutionAdmission(request: {
475
+ executionId?: string;
476
+ workflowId: string;
477
+ params: EventParams;
478
+ context: EventContext;
479
+ }):
480
+ | { ok: true; executionId: string; encoded: { params: string; context: string } }
481
+ | { ok: false; code: "INVALID_EXECUTION_EVENT"; message: string } {
482
+ if (typeof request.workflowId !== "string" || request.workflowId === "") {
483
+ return {
484
+ ok: false,
485
+ code: "INVALID_EXECUTION_EVENT",
486
+ message: "workflowId must be a non-empty string",
487
+ };
488
+ }
489
+ if (
490
+ request.executionId !== undefined &&
491
+ (typeof request.executionId !== "string" || request.executionId === "")
492
+ ) {
493
+ return {
494
+ ok: false,
495
+ code: "INVALID_EXECUTION_EVENT",
496
+ message: "executionId must be a non-empty string",
497
+ };
498
+ }
499
+ try {
500
+ return {
501
+ ok: true,
502
+ executionId: request.executionId ?? crypto.randomUUID(),
503
+ encoded: encodeExecutionEvent(request),
504
+ };
505
+ } catch (err) {
506
+ if (!(err instanceof ExecutionEventEncodingError)) throw err;
507
+ return { ok: false, code: "INVALID_EXECUTION_EVENT", message: err.message };
508
+ }
509
+ }
510
+
511
+ export function executionIdConflict(executionId: string) {
512
+ return {
513
+ status: "rejected" as const,
514
+ code: "EXECUTION_ID_CONFLICT" as const,
515
+ message: `Execution "${executionId}" already exists`,
516
+ };
517
+ }
518
+
519
+ export function executionNotFound(executionId: string) {
520
+ return {
521
+ status: "rejected" as const,
522
+ code: "EXECUTION_NOT_FOUND" as const,
523
+ message: `Execution "${executionId}" does not exist`,
524
+ };
525
+ }
526
+
527
+ /**
528
+ * A recorded start request, keyed by `callerExecutionId` and `stepKey`.
529
+ * `executionId` is the target it admitted. Adapters keep it until the caller
530
+ * is pruned, including after the target is terminal.
531
+ */
532
+ export type StartRequestState = {
533
+ callerExecutionId: string;
534
+ stepKey: string;
535
+ workflowId: string;
536
+ /** The canonical params, or null when the request had none. */
537
+ params: string | null;
538
+ executionId: string;
539
+ };
540
+
541
+ export type PreparedExecutionStart = {
542
+ callerExecutionId: string;
543
+ stepKey: string;
544
+ workflowId: string;
545
+ params: string | null;
546
+ };
547
+
548
+ /**
549
+ * Validates a start request and encodes its params canonically. Adapters call
550
+ * this before touching storage and return the rejection as is.
551
+ */
552
+ export function prepareExecutionStart(
553
+ request: ExecutionStartRequest
554
+ ):
555
+ | { ok: true; start: PreparedExecutionStart }
556
+ | {
557
+ ok: false;
558
+ result: Extract<ExecutionStartResult, { status: "rejected" }>;
559
+ } {
560
+ const invalid = (message: string) => ({
561
+ ok: false as const,
562
+ result: {
563
+ status: "rejected" as const,
564
+ code: "INVALID_START_PARAMETERS" as const,
565
+ message,
566
+ },
567
+ });
568
+ if (typeof request.workflowId !== "string" || request.workflowId === "") {
569
+ return invalid("workflowId must be a non-empty string");
570
+ }
571
+ let params: string | null = null;
572
+ if (request.params !== undefined) {
573
+ if (!isPlainObject(request.params)) {
574
+ return invalid("params must be a plain object");
575
+ }
576
+ const encoding = durableValueCodec.encode(request.params);
577
+ if (encoding.status === "invalid") {
578
+ return invalid(`params: ${encoding.message}`);
579
+ }
580
+ params = encoding.canonical;
581
+ }
582
+ return {
583
+ ok: true,
584
+ start: {
585
+ callerExecutionId: request.requestId.executionId,
586
+ stepKey: request.requestId.stepKey,
587
+ workflowId: request.workflowId,
588
+ params,
589
+ },
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Answers a repeated start request from its record: the first target's ID when
595
+ * the workflow ID and canonical params match, and `START_REQUEST_MISMATCH`
596
+ * otherwise. The target's status does not matter.
597
+ */
598
+ export function matchStartRequest(
599
+ recorded: StartRequestState,
600
+ start: PreparedExecutionStart
601
+ ): ExecutionStartResult {
602
+ if (
603
+ recorded.workflowId === start.workflowId &&
604
+ recorded.params === start.params
605
+ ) {
606
+ return { status: "accepted", executionId: recorded.executionId };
607
+ }
608
+ return {
609
+ status: "rejected",
610
+ code: "START_REQUEST_MISMATCH",
611
+ message: `Step "${start.stepKey}" of execution "${start.callerExecutionId}" already started workflow "${recorded.workflowId}" with different parameters or workflow ID`,
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Builds a new start's target state and start-request record. The target
617
+ * inherits the caller's encoded context and links to the caller as its parent.
618
+ * Adapters insert both in one atomic write, after finding no recorded request
619
+ * for the same ID. `executionId` defaults to a random UUID; if it is already in
620
+ * use, adapters must not reuse it.
621
+ */
622
+ export function createStartedExecution(params: {
623
+ start: PreparedExecutionStart;
624
+ caller: ExecutionState;
625
+ now: number;
626
+ executionId?: string;
627
+ }): { target: ExecutionState; startRequest: StartRequestState } {
628
+ const { start, caller, now } = params;
629
+ const executionId = params.executionId ?? crypto.randomUUID();
630
+ const target = createExecutionState({
631
+ executionId,
632
+ workflowId: start.workflowId,
633
+ encoded: {
634
+ params: start.params ?? encodeExecutionEvent({ params: undefined, context: undefined }).params,
635
+ context: caller.context,
636
+ },
637
+ now,
638
+ parent: { executionId: start.callerExecutionId, stepKey: start.stepKey },
639
+ });
640
+ return {
641
+ target,
642
+ startRequest: {
643
+ callerExecutionId: start.callerExecutionId,
644
+ stepKey: start.stepKey,
645
+ workflowId: start.workflowId,
646
+ params: start.params,
647
+ executionId,
648
+ },
649
+ };
650
+ }
@@ -0,0 +1,40 @@
1
+ import type { EventEmitter } from "node:events";
2
+ import type { ExecutionClient, ExecutionOutcome } from "../base/execution";
3
+ import { isTerminalExecutionStatus } from "../base/execution";
4
+
5
+ /**
6
+ * Resolves with an execution's outcome once its record is terminal, or with
7
+ * undefined if no record exists. The record is the source of truth, so this
8
+ * settles even if the process that ran the execution restarted. An optional
9
+ * `emitter` (the invoker's deprecated `workflowEndEmitter`) only nudges an
10
+ * early re-read.
11
+ */
12
+ export async function waitForExecutionOutcome(
13
+ executions: Pick<ExecutionClient, "get">,
14
+ executionId: string,
15
+ options?: { emitter?: EventEmitter; pollInterval?: number; signal?: AbortSignal }
16
+ ): Promise<ExecutionOutcome | undefined> {
17
+ const pollInterval = options?.pollInterval ?? 25;
18
+ const emitter = options?.emitter;
19
+ let wake: (() => void) | undefined;
20
+ const nudge = () => wake?.();
21
+ emitter?.on(executionId, nudge);
22
+ try {
23
+ while (true) {
24
+ options?.signal?.throwIfAborted();
25
+ const record = await executions.get(executionId);
26
+ if (!record) return undefined;
27
+ if (isTerminalExecutionStatus(record.status)) return record.outcome;
28
+ await new Promise<void>((resolve) => {
29
+ const timer = setTimeout(resolve, pollInterval);
30
+ wake = () => {
31
+ clearTimeout(timer);
32
+ resolve();
33
+ };
34
+ });
35
+ wake = undefined;
36
+ }
37
+ } finally {
38
+ emitter?.off(executionId, nudge);
39
+ }
40
+ }