@lostgradient/weft 0.9.0 → 0.11.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 (106) hide show
  1. package/README.md +12 -4
  2. package/dist/alerting/alert-manager.d.ts +3 -1
  3. package/dist/alerting/alert-manager.js +5 -2
  4. package/dist/cli-main.js +63 -63
  5. package/dist/core/checkpoint/serialization.js +2 -2
  6. package/dist/core/context/index.d.ts +2 -1
  7. package/dist/core/context/index.js +8 -0
  8. package/dist/core/context/operation-request.d.ts +2 -0
  9. package/dist/core/context/parallel-cache-entry.d.ts +1 -1
  10. package/dist/core/context/parallel-cache-entry.js +12 -8
  11. package/dist/core/context/parallel-operations.d.ts +1 -1
  12. package/dist/core/context/parallel-operations.js +50 -8
  13. package/dist/core/engine/async-activity-completion.d.ts +24 -54
  14. package/dist/core/engine/async-activity-completion.js +25 -90
  15. package/dist/core/engine/async-activity-records.d.ts +106 -0
  16. package/dist/core/engine/async-activity-records.js +124 -0
  17. package/dist/core/engine/bulk-operations-purge.js +2 -1
  18. package/dist/core/engine/callback-creators-core.js +1 -0
  19. package/dist/core/engine/construction.d.ts +1 -3
  20. package/dist/core/engine/construction.js +2 -5
  21. package/dist/core/engine/deferred-consume-envelope.d.ts +10 -1
  22. package/dist/core/engine/deferred-consume-envelope.js +9 -1
  23. package/dist/core/engine/disposal.js +4 -0
  24. package/dist/core/engine/engine-internal-types.d.ts +1 -0
  25. package/dist/core/engine/engine-leak-warnings.d.ts +2 -0
  26. package/dist/core/engine/engine-leak-warnings.js +7 -1
  27. package/dist/core/engine/engine-runtime-helpers.d.ts +8 -0
  28. package/dist/core/engine/engine-runtime-helpers.js +9 -0
  29. package/dist/core/engine/errors.js +1 -1
  30. package/dist/core/engine/index.d.ts +18 -5
  31. package/dist/core/engine/index.js +41 -10
  32. package/dist/core/engine/inline-parking.js +14 -1
  33. package/dist/core/engine/internals.d.ts +23 -2
  34. package/dist/core/engine/lifecycle/recovered-services.d.ts +4 -2
  35. package/dist/core/engine/lifecycle/recovered-services.js +37 -7
  36. package/dist/core/engine/lifecycle/resume.d.ts +2 -2
  37. package/dist/core/engine/lifecycle/resume.js +39 -7
  38. package/dist/core/engine/lifecycle/shared.d.ts +34 -0
  39. package/dist/core/engine/lifecycle/transition.d.ts +1 -1
  40. package/dist/core/engine/lifecycle/transition.js +3 -3
  41. package/dist/core/engine/lifecycle.d.ts +1 -1
  42. package/dist/core/engine/memo-durable-activity.js +7 -9
  43. package/dist/core/engine/operations-activity.js +1 -1
  44. package/dist/core/engine/operations-coordination.d.ts +15 -1
  45. package/dist/core/engine/operations-coordination.js +43 -10
  46. package/dist/core/engine/operations-time.js +8 -28
  47. package/dist/core/engine/ownership-options.d.ts +1 -0
  48. package/dist/core/engine/ownership-options.js +14 -0
  49. package/dist/core/engine/retention.js +4 -0
  50. package/dist/core/engine/schedule-run-metadata.d.ts +3 -0
  51. package/dist/core/engine/schedule-run-metadata.js +29 -0
  52. package/dist/core/engine/schedule-run.js +19 -4
  53. package/dist/core/engine/schedules.js +4 -3
  54. package/dist/core/engine/sleep-timer-acknowledgements.d.ts +15 -0
  55. package/dist/core/engine/sleep-timer-acknowledgements.js +144 -0
  56. package/dist/core/engine/sub-operation.js +6 -13
  57. package/dist/core/engine/termination/cleanup.js +6 -1
  58. package/dist/core/types/options.d.ts +12 -0
  59. package/dist/core/types/services-resolution.d.ts +8 -3
  60. package/dist/core/types/workflow-builder.d.ts +2 -2
  61. package/dist/core/types/workflow-context.d.ts +27 -0
  62. package/dist/index.d.ts +2 -2
  63. package/dist/mcp/cli.js +17 -17
  64. package/dist/server/authorization.d.ts +3 -3
  65. package/dist/server/fault-to-json-rpc.d.ts +2 -1
  66. package/dist/server/handler.js +22 -22
  67. package/dist/server/index.js +17 -17
  68. package/dist/server/json-rpc-dispatch.d.ts +2 -2
  69. package/dist/server/json-rpc-parse.d.ts +1 -1
  70. package/dist/server/json-rpc-protocol.d.ts +2 -2
  71. package/dist/server/json-rpc-websocket.d.ts +1 -1
  72. package/dist/server/operation-fault.d.ts +3 -1
  73. package/dist/server/operations/async-activity.js +2 -2
  74. package/dist/server/principal.d.ts +2 -2
  75. package/dist/server/stdio-session.d.ts +4 -4
  76. package/dist/service-worker/index.js +13 -13
  77. package/dist/service-worker/scheduler.js +1 -0
  78. package/dist/storage/compressed-storage.js +1 -1
  79. package/dist/storage/http.js +2 -2
  80. package/dist/storage/index.d.ts +1 -0
  81. package/dist/storage/indexeddb.js +1 -1
  82. package/dist/storage/interface.d.ts +1 -0
  83. package/dist/storage/interface.js +1 -1
  84. package/dist/storage/lazy-postgres-pool.d.ts +38 -0
  85. package/dist/storage/lazy-postgres-pool.js +38 -0
  86. package/dist/storage/lmdb.js +1 -1
  87. package/dist/storage/memory.js +1 -1
  88. package/dist/storage/neon-batch.d.ts +8 -8
  89. package/dist/storage/neon.d.ts +26 -79
  90. package/dist/storage/neon.js +3 -3
  91. package/dist/storage/postgres-key-value-queries.d.ts +1 -1
  92. package/dist/storage/postgres-key-value-queries.js +1 -1
  93. package/dist/storage/postgres-key-value-storage.d.ts +110 -0
  94. package/dist/storage/postgres-key-value-storage.js +204 -0
  95. package/dist/storage/postgres.d.ts +59 -0
  96. package/dist/storage/postgres.js +15 -0
  97. package/dist/storage/resolve.js +1 -1
  98. package/dist/storage/scoped-storage.js +1 -1
  99. package/dist/storage/testing.js +1 -1
  100. package/dist/storage/turso.js +1 -1
  101. package/dist/storage/typed-storage.js +1 -1
  102. package/dist/storage/web-extension.js +1 -1
  103. package/dist/testing/index.js +17 -17
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/package.json +17 -4
@@ -29,7 +29,7 @@ export function validateCheckpointShape(value) {
29
29
  validateAccumulatedResultReplayWatermark(record);
30
30
  validateWorkerReplaySignatures(record);
31
31
  validateWorkerReplayFailures(record);
32
- dropLegacyPendingSignals(record);
32
+ dropRetiredPendingSignals(record);
33
33
  assertRecordField(record, "searchAttributes");
34
34
  assertStringField(record, "version");
35
35
  assertNumberField(record, "createdAt");
@@ -148,7 +148,7 @@ function validateAccumulatedResultReplayWatermark(record) {
148
148
  if (typeof watermark !== "number" || !Number.isSafeInteger(watermark) || watermark < 0)
149
149
  throw Error('Invalid checkpoint: invalid "accumulatedResultReplayWatermark" (expected non-negative safe integer)');
150
150
  }
151
- function dropLegacyPendingSignals(record) {
151
+ function dropRetiredPendingSignals(record) {
152
152
  delete record.pendingSignals;
153
153
  }
154
154
  function assertCurrentSchemaVersion(record) {
@@ -1,6 +1,6 @@
1
1
  import type { ComposedWorkflowInterceptor } from '../interceptor.ts';
2
2
  import type { HumanReviewOptions, HumanReviewResult } from '../review/index.ts';
3
- import type { ActivityCallable, ActivityCallOptions, AwaitChildWorkflowOptions, ChildWorkflowHandle, ChildWorkflowTarget, DetachedChildWorkflowOptions, Duration, QueryDefinition, RunAllResult, SearchAttributeHandle, SearchAttributeValue, SignalDefinition, UpdateDefinition, WorkflowContext, WorkflowLogger, WorkflowMapOptions, WorkflowOperation, WorkflowOperationTupleResult, WorkflowPipeStageDefinition, WorkflowReduceInput, WorkflowReduceOptions, WorkflowRunAllBranch, WorkflowStateNamespace } from '../types.ts';
3
+ import type { ActivityCallable, ActivityCallOptions, AwaitChildWorkflowOptions, ChildWorkflowHandle, ChildWorkflowTarget, DetachedChildWorkflowOptions, Duration, QueryDefinition, RunAllResult, SearchAttributeHandle, SearchAttributeValue, SignalDefinition, UpdateDefinition, WorkflowContext, WorkflowKeyedRaceResult, WorkflowLogger, WorkflowMapOptions, WorkflowOperation, WorkflowOperationTupleResult, WorkflowPipeStageDefinition, WorkflowReduceInput, WorkflowReduceOptions, WorkflowRunAllBranch, WorkflowStateNamespace } from '../types.ts';
4
4
  import type { ContextOperationRequest } from './operation-request.ts';
5
5
  import type { ContextOptions, ErasedSagaStep, OffloadReference, StreamReference, StreamSink } from './types.ts';
6
6
  import * as contextUpdates from './updates.ts';
@@ -82,6 +82,7 @@ export declare class Context implements WorkflowContext {
82
82
  review(options: HumanReviewOptions): Generator<ContextOperationRequest, HumanReviewResult, unknown>;
83
83
  all<const TOperations extends readonly WorkflowOperation<unknown>[]>(operations: TOperations): Generator<ContextOperationRequest, WorkflowOperationTupleResult<TOperations>, unknown>;
84
84
  race<const TOperations extends readonly WorkflowOperation<unknown>[]>(operations: TOperations): Generator<ContextOperationRequest, WorkflowOperationTupleResult<TOperations>[number], unknown>;
85
+ raceKeyed<const TOperations extends Readonly<Record<string, WorkflowOperation<unknown>>>>(operations: TOperations): Generator<ContextOperationRequest, WorkflowKeyedRaceResult<TOperations>, unknown>;
85
86
  memo<T>(key: string, fn: () => T | Promise<T>): Generator<ContextOperationRequest, T, unknown>;
86
87
  offload<T>(key: string, fn: () => Promise<T>): Generator<ContextOperationRequest, OffloadReference, unknown>;
87
88
  stream(key: string, fn: (sink: StreamSink) => AsyncGenerator<unknown, void, unknown>): Generator<ContextOperationRequest, StreamReference, unknown>;
@@ -156,6 +156,14 @@ export class Context {
156
156
  *race(operations) {
157
157
  return yield* parallelOperations.race(this, getInternals(this), [...operations]);
158
158
  }
159
+ *raceKeyed(operations) {
160
+ if (Object.getOwnPropertySymbols(operations).length > 0)
161
+ throw TypeError("ctx.raceKeyed branch names must be strings or numbers, not symbols");
162
+ const entries = Object.entries(operations);
163
+ if (entries.length === 0)
164
+ throw TypeError("ctx.raceKeyed requires at least one branch");
165
+ return yield* parallelOperations.race(this, getInternals(this), entries.map(([, operation]) => operation), entries.map(([key]) => key));
166
+ }
159
167
  *memo(key, fn) {
160
168
  return yield* parallelOperations.memo(this, getInternals(this), key, fn);
161
169
  }
@@ -127,6 +127,8 @@ export type ContextOperationRequest = {
127
127
  type: 'race';
128
128
  operationId: string;
129
129
  operations: ContextOperationRequest[];
130
+ /** Ordered branch names for `ctx.raceKeyed`; absent for positional `ctx.race`. */
131
+ branchNames?: string[];
130
132
  callerStack?: string;
131
133
  } | {
132
134
  type: 'memo';
@@ -57,7 +57,7 @@ export type ParallelOperationCacheEntry = {
57
57
  formatVersion: 2;
58
58
  variant: 'all' | 'race' | 'run-all';
59
59
  branches: ParallelBranchSlot[];
60
- /** Ordered key list for `run-all`; absent for `all` and `race`. */
60
+ /** Ordered key list for `run-all` or `raceKeyed`; absent for `all` and positional `race`. */
61
61
  branchNames?: string[];
62
62
  subOperationCount: number;
63
63
  };
@@ -41,20 +41,24 @@ function hasParallelOperationCacheMarker(value) {
41
41
  return value !== null && typeof value === "object" && value.__weftParallelOperationCache === !0;
42
42
  }
43
43
  function hasValidBranchTopology(variant, branches, subOperationCount, branchNames) {
44
- if (variant === "race") {
45
- if (branches.length !== 1 || subOperationCount < 1)
46
- return !1;
47
- const winner = branches[0];
48
- if (winner == null || typeof winner !== "object")
49
- return !1;
50
- return winner.status === "fulfilled" && branchNames === void 0;
51
- }
44
+ if (variant === "race")
45
+ return hasValidRaceTopology(branches, subOperationCount, branchNames);
52
46
  if (branches.length !== subOperationCount)
53
47
  return !1;
54
48
  if (variant === "run-all")
55
49
  return Array.isArray(branchNames) && branchNames.length === subOperationCount;
56
50
  return branchNames === void 0;
57
51
  }
52
+ function hasValidRaceTopology(branches, subOperationCount, branchNames) {
53
+ if (branches.length !== 1 || subOperationCount < 1)
54
+ return !1;
55
+ const winner = branches[0];
56
+ if (winner == null || typeof winner !== "object")
57
+ return !1;
58
+ if (winner.status !== "fulfilled")
59
+ return !1;
60
+ return branchNames === void 0 || Array.isArray(branchNames) && branchNames.length === subOperationCount;
61
+ }
58
62
  export function isParallelOperationCacheEntry(value) {
59
63
  if (value === null || typeof value !== "object")
60
64
  return !1;
@@ -4,6 +4,6 @@ import type { ContextOperationRequest } from './operation-request.ts';
4
4
  import { assertValidParallelOperationCacheEntry, BranchTopologyChangedError, createParallelOperationCacheEntry, isParallelOperationCacheEntry, type ParallelBranchSlot, type ParallelOperationCacheEntry } from './parallel-cache-entry.ts';
5
5
  export { assertValidParallelOperationCacheEntry, BranchTopologyChangedError, createParallelOperationCacheEntry, isParallelOperationCacheEntry, type ParallelBranchSlot, type ParallelOperationCacheEntry, };
6
6
  export declare function all(context: Context, internals: ContextInternals, operations: Generator<ContextOperationRequest, unknown, unknown>[]): Generator<ContextOperationRequest, unknown[], unknown>;
7
- export declare function race(context: Context, internals: ContextInternals, operations: Generator<ContextOperationRequest, unknown, unknown>[]): Generator<ContextOperationRequest, unknown, unknown>;
7
+ export declare function race(context: Context, internals: ContextInternals, operations: Generator<ContextOperationRequest, unknown, unknown>[], branchNames?: string[]): Generator<ContextOperationRequest, unknown, unknown>;
8
8
  export declare function memo<T>(context: Context, internals: ContextInternals, key: string, fn: () => T | Promise<T>): Generator<ContextOperationRequest, T, unknown>;
9
9
  export declare function runAll<T extends Record<string, readonly [Function] | readonly [Function, unknown]>>(context: Context, internals: ContextInternals, branches: T): Generator<ContextOperationRequest, Record<keyof T, unknown>, unknown>;
@@ -83,7 +83,7 @@ function buildFulfilledAllEntry(values, operationId, subOperationCount) {
83
83
  operationId: `${operationId}:${i}`
84
84
  })), subOperationCount);
85
85
  }
86
- export function* race(context, internals, operations) {
86
+ export function* race(context, internals, operations, branchNames) {
87
87
  const step = internals.stepIndex++;
88
88
  if (internals.accumulatedResults?.has(step)) {
89
89
  const cached = internals.accumulatedResults.get(step);
@@ -91,32 +91,74 @@ export function* race(context, internals, operations) {
91
91
  if (isParallelOperationCacheEntry(cached)) {
92
92
  if (cached.variant !== "race")
93
93
  throw new BranchTopologyChangedError(`ctx.race step ${step} found a cached entry of variant '${cached.variant}'. The same step must use the same parallel primitive across retries.`);
94
- if (operations.length !== cached.subOperationCount)
95
- throw new BranchTopologyChangedError(`ctx.race branch count changed across retry: expected ${cached.subOperationCount}, got ${operations.length}. Branch count must be deterministic.`);
94
+ assertRaceBranchTopology(operations.length, branchNames, cached);
96
95
  const winner = cached.branches[0];
97
- if (winner?.status !== "fulfilled")
98
- throw new BranchTopologyChangedError(`ctx.race step ${step} cached entry has no fulfilled winner slot \u2014 entry is malformed.`);
99
96
  internals.stepIndex += cached.subOperationCount;
100
97
  return winner.value;
101
98
  }
99
+ if (branchNames !== void 0)
100
+ throw new BranchTopologyChangedError(`ctx.raceKeyed step ${step} found a raw cached race value without keyed branch topology. The same step must use raceKeyed across retries.`);
102
101
  return cached;
103
102
  }
104
- const subOperations = primeParallelOperations(operations), operationId = `race:${step}`;
103
+ const operationId = `race:${step}`;
104
+ let subOperations;
105
+ if (branchNames === void 0)
106
+ subOperations = primeParallelOperations(operations);
107
+ else {
108
+ const primed = primeKeyedRaceOperations(operations);
109
+ subOperations = primed.subOperations;
110
+ if (primed.synchronousWinner !== void 0) {
111
+ const result = { key: branchNames[primed.synchronousWinner.index], value: primed.synchronousWinner.value };
112
+ cacheRaceWinner(context, step, result, operationId, operations.length, branchNames);
113
+ return result;
114
+ }
115
+ }
105
116
  stampDeterministicOperationIds(subOperations, operationId);
106
117
  const callerStack = captureCallerStack(), result = yield {
107
118
  type: "race",
108
119
  operationId,
109
120
  operations: subOperations,
121
+ ...branchNames !== void 0 ? { branchNames } : {},
110
122
  callerStack
111
123
  };
124
+ cacheRaceWinner(context, step, result, operationId, subOperations.length, branchNames);
125
+ return result;
126
+ }
127
+ function primeKeyedRaceOperations(operations) {
128
+ const subOperations = [];
129
+ let synchronousWinner;
130
+ for (const [index, operation] of operations.entries()) {
131
+ const primed = operation.next();
132
+ if (primed.done)
133
+ synchronousWinner ??= { index, value: primed.value };
134
+ else
135
+ subOperations.push(primed.value);
136
+ }
137
+ return { subOperations, synchronousWinner };
138
+ }
139
+ function cacheRaceWinner(context, step, result, operationId, subOperationCount, branchNames) {
112
140
  context.accumulatedResults.set(step, {
113
141
  __weftParallelOperationCache: !0,
114
142
  formatVersion: 2,
115
143
  variant: "race",
116
144
  branches: [{ status: "fulfilled", value: result, operationId: `${operationId}:winner` }],
117
- subOperationCount: subOperations.length
145
+ ...branchNames !== void 0 ? { branchNames } : {},
146
+ subOperationCount
118
147
  });
119
- return result;
148
+ }
149
+ function sameBranchNames(left, right) {
150
+ if (left === void 0 || right === void 0)
151
+ return left === right;
152
+ return left.length === right.length && left.every((name, index) => name === right[index]);
153
+ }
154
+ function assertRaceBranchTopology(operationCount, branchNames, cached) {
155
+ if (operationCount !== cached.subOperationCount)
156
+ throw new BranchTopologyChangedError(`ctx.race branch count changed across retry: expected ${cached.subOperationCount}, got ${operationCount}. Branch count must be deterministic.`);
157
+ if (!sameBranchNames(branchNames, cached.branchNames))
158
+ throw new BranchTopologyChangedError(`ctx.race branch names changed across retry: expected ${formatBranchNames(cached.branchNames)}, got ${formatBranchNames(branchNames)}. Branch names and order must be deterministic.`);
159
+ }
160
+ function formatBranchNames(branchNames) {
161
+ return branchNames === void 0 ? "positional branches" : JSON.stringify(branchNames);
120
162
  }
121
163
  function stampDeterministicOperationIds(subOperations, parentOperationId) {
122
164
  for (let i = 0;i < subOperations.length; i++) {
@@ -26,9 +26,33 @@
26
26
  * arrives after token recovery but before replay has adopted the workflow
27
27
  * generator, the engine buffers the completion or failure outcome and drains it
28
28
  * when replay reaches the same deterministic token.
29
+ *
30
+ * Acknowledgement durability: `completeAsyncActivity` / `failAsyncActivity`
31
+ * resolve only after ONE fenced batch has durably (a) deleted the single-use
32
+ * token record and (b) written a resolution record
33
+ * ({@link KEYS.asyncActivityResolution}) carrying the supplied outcome. A crash
34
+ * any time after the acknowledgement therefore cannot lose the outcome:
35
+ * recovery reloads the resolution record, queues it, and redelivers it when
36
+ * replay re-parks on the same deterministic token. The resolution record is
37
+ * deleted through the atomic side-effect buffer, so in the normal case it rides
38
+ * the very checkpoint that records the resumed result; a record whose delete
39
+ * never commits is simply redelivered (idempotent for a deterministic token) or
40
+ * swept by terminal cleanup/purge. If the acknowledgement batch itself fails,
41
+ * the in-memory token claim is restored and the error propagates — the caller
42
+ * learns the completion did NOT stick and can retry the still-live token.
43
+ *
44
+ * One caveat survives a crash: the failure path's raw thrown reason
45
+ * (`originalReason`) is delivered as-is only within the acknowledging process.
46
+ * A redelivery after recovery reconstructs the error from the persisted outcome
47
+ * (message, name, failure category) — the same fidelity the worker resume path
48
+ * has always had.
49
+ *
50
+ * The persisted record shapes, decode guards, key derivations, and the queued
51
+ * resolution buffer live in `async-activity-records.ts`.
29
52
  */
30
53
  import type { OperationOutcome } from '../types.ts';
31
54
  import { WeftError } from '../weft-error.ts';
55
+ import { type PendingAsyncActivity } from './async-activity-records.ts';
32
56
  import type { EngineInternals } from './internals.ts';
33
57
  type AsyncActivityResolutionCallbacks = {
34
58
  feedOperationResult: (workflowId: string, outcome: OperationOutcome, originalReason?: {
@@ -36,12 +60,6 @@ type AsyncActivityResolutionCallbacks = {
36
60
  }) => void;
37
61
  finalizeTimeline: (workflowId: string, status: 'completed' | 'failed', output: unknown) => void;
38
62
  };
39
- /**
40
- * Per-workflow prefix for all async-activity storage keys. Used by cleanup and
41
- * purge paths that need to sweep every async-activity record for a workflow
42
- * without enumerating individual tokens.
43
- */
44
- export declare function asyncActivityWorkflowPrefix(workflowId: string): string;
45
63
  /**
46
64
  * Sentinel thrown by `ActivityContext.completeAsync()` to signal that the
47
65
  * activity is handing off to an out-of-band completion. The engine recognizes
@@ -73,48 +91,6 @@ export declare class AsyncActivityTokenNotFoundError extends WeftError<'AsyncAct
73
91
  readonly token: string;
74
92
  constructor(token: string);
75
93
  }
76
- /**
77
- * In-memory record of an activity that deferred to out-of-band completion and
78
- * is awaiting `completeAsyncActivity` / `failAsyncActivity`.
79
- */
80
- export type PendingAsyncActivity = {
81
- readonly token: string;
82
- readonly workflowId: string;
83
- readonly activityName: string;
84
- readonly operationId: string;
85
- readonly step: number;
86
- readonly attempt: number;
87
- readonly createdAt: number;
88
- };
89
- export type PendingAsyncActivityResolution = {
90
- readonly token: string;
91
- readonly outcome: OperationOutcome;
92
- readonly originalReason?: {
93
- value: unknown;
94
- };
95
- readonly timelineStatus: 'completed' | 'failed';
96
- readonly timelineOutput: unknown;
97
- };
98
- /**
99
- * Derive the durable, deterministic task token for an async activity.
100
- *
101
- * The token is anchored to the workflow id, the activity state key, and the
102
- * dispatch attempt — all of which are stable across replay — so a workflow that
103
- * crashes while parked on an async activity mints the identical token after
104
- * recovery. Plain `ctx.run()` uses the workflow step as the state key.
105
- * `operationId` is deliberately excluded because it is regenerated on every
106
- * yield and would change on replay.
107
- */
108
- export declare function deriveAsyncActivityToken(workflowId: string, step: number | string, attempt: number): string;
109
- /**
110
- * Register a deferred activity: record it in memory and durably, then announce
111
- * the token via {@link ActivityAsyncPendingEvent}. Idempotent on `token`: if the
112
- * token is already registered (e.g. because `recoverPendingAsyncActivities` loaded
113
- * it before the workflow replayed and re-deferred), the durable record is
114
- * refreshed but the event is NOT re-emitted, preventing duplicate side-effects
115
- * (e.g. re-sending a webhook notification) on replay.
116
- */
117
- export declare function registerPendingAsyncActivity(internals: EngineInternals, pending: PendingAsyncActivity): Promise<void>;
118
94
  /**
119
95
  * Park an activity that threw {@link AsyncActivityDeferral}: register the
120
96
  * pending entry durably and return a promise that never settles, so the
@@ -123,12 +99,6 @@ export declare function registerPendingAsyncActivity(internals: EngineInternals,
123
99
  * one-liner.
124
100
  */
125
101
  export declare function parkDeferredAsyncActivity(internals: EngineInternals, deferral: AsyncActivityDeferral, details: Omit<PendingAsyncActivity, 'token' | 'createdAt'>, callbacks: AsyncActivityResolutionCallbacks): Promise<never>;
126
- /**
127
- * Reload pending async-activity records from storage into memory. Called by
128
- * `recoverAll()` so a token minted before a crash is resolvable again — even
129
- * before the recovered workflow has replayed far enough to re-register it.
130
- */
131
- export declare function recoverPendingAsyncActivities(internals: EngineInternals): Promise<void>;
132
102
  /**
133
103
  * Complete a deferred activity out-of-band with `result`, resuming the parked
134
104
  * workflow as though the activity had returned `result` inline.
@@ -1,14 +1,15 @@
1
- import { KEYS, encodeStorageKeyComponent } from "../../storage/interface.js";
2
- import { decode, encode } from "../codec.js";
3
- import { ActivityAsyncPendingEvent } from "../events.js";
1
+ import { KEYS } from "../../storage/interface.js";
4
2
  import { assertPayloadWithinLimit } from "../payload-size.js";
5
3
  import { WeftError } from "../weft-error.js";
4
+ import {
5
+ buildAsyncActivityAcknowledgementOperations,
6
+ queuePendingAsyncActivityResolution,
7
+ registerPendingAsyncActivity,
8
+ shouldBufferPendingAsyncActivityResolution,
9
+ takePendingAsyncActivityResolution
10
+ } from "./async-activity-records.js";
6
11
  import { stageAtomicWorkflowCommitSideEffects } from "./checkpoint-side-effects.js";
7
12
  import { commitFencedEngineWrite } from "./fenced-write.js";
8
- const ASYNC_ACTIVITY_TOKEN_PREFIX = "async-act:v1", ASYNC_ACTIVITY_KEY_PREFIX = "async-act:v1:";
9
- export function asyncActivityWorkflowPrefix(workflowId) {
10
- return `${ASYNC_ACTIVITY_KEY_PREFIX}${encodeStorageKeyComponent(workflowId)}:`;
11
- }
12
13
 
13
14
  export class AsyncActivityDeferral extends Error {
14
15
  token;
@@ -26,43 +27,10 @@ export class AsyncActivityTokenNotFoundError extends WeftError {
26
27
  this.token = token;
27
28
  }
28
29
  }
29
- function isPersistedAsyncActivity(value) {
30
- if (typeof value !== "object" || value === null)
31
- return !1;
32
- const record = value;
33
- return record.version === 1 && typeof record.token === "string" && typeof record.workflowId === "string" && typeof record.activityName === "string" && typeof record.operationId === "string" && typeof record.step === "number" && typeof record.attempt === "number" && typeof record.createdAt === "number";
34
- }
35
- export function deriveAsyncActivityToken(workflowId, step, attempt) {
36
- return `${ASYNC_ACTIVITY_TOKEN_PREFIX}:${workflowId}:${step}:${attempt}`;
37
- }
38
- function buildPersistPendingAsyncActivityOperation(pending) {
39
- const record = {
40
- version: 1,
41
- token: pending.token,
42
- workflowId: pending.workflowId,
43
- activityName: pending.activityName,
44
- operationId: pending.operationId,
45
- step: pending.step,
46
- attempt: pending.attempt,
47
- createdAt: pending.createdAt
48
- };
49
- return {
50
- type: "put",
51
- key: KEYS.asyncActivity(pending.workflowId, pending.token),
52
- value: encode(record)
53
- };
54
- }
55
- export async function registerPendingAsyncActivity(internals, pending) {
56
- const alreadyRegistered = internals.pendingAsyncActivities.has(pending.token);
57
- internals.pendingAsyncActivities.set(pending.token, pending);
58
- await commitFencedEngineWrite(internals, [buildPersistPendingAsyncActivityOperation(pending)], [], () => Error(`Async activity registration for token "${pending.token}" lost its precondition.`));
59
- if (!alreadyRegistered)
60
- internals.engine.dispatchEvent(new ActivityAsyncPendingEvent(pending.token, pending.operationId, pending.workflowId, pending.activityName, pending.attempt));
61
- }
62
30
  export async function parkDeferredAsyncActivity(internals, deferral, details, callbacks) {
63
31
  const queuedResolution = takePendingAsyncActivityResolution(internals, details.workflowId, deferral.token);
64
32
  if (queuedResolution !== void 0) {
65
- deliverPendingAsyncActivityResolution(details.workflowId, queuedResolution, callbacks);
33
+ deliverPendingAsyncActivityResolution(internals, details.workflowId, queuedResolution, callbacks);
66
34
  return new Promise(() => {});
67
35
  }
68
36
  await registerPendingAsyncActivity(internals, {
@@ -72,64 +40,31 @@ export async function parkDeferredAsyncActivity(internals, deferral, details, ca
72
40
  });
73
41
  return new Promise(() => {});
74
42
  }
75
- export async function recoverPendingAsyncActivities(internals) {
76
- for await (const [, bytes] of internals.storage.scan(ASYNC_ACTIVITY_KEY_PREFIX)) {
77
- const decoded = decode(bytes);
78
- if (!isPersistedAsyncActivity(decoded))
79
- continue;
80
- internals.pendingAsyncActivities.set(decoded.token, {
81
- token: decoded.token,
82
- workflowId: decoded.workflowId,
83
- activityName: decoded.activityName,
84
- operationId: decoded.operationId,
85
- step: decoded.step,
86
- attempt: decoded.attempt,
87
- createdAt: decoded.createdAt
88
- });
89
- }
90
- }
91
- async function consumePendingAsyncActivity(internals, token) {
43
+ async function consumePendingAsyncActivity(internals, token, outcome) {
92
44
  const pending = internals.pendingAsyncActivities.get(token);
93
45
  if (!pending)
94
46
  throw new AsyncActivityTokenNotFoundError(token);
95
47
  internals.pendingAsyncActivities.delete(token);
96
- stageAtomicWorkflowCommitSideEffects(internals, pending.workflowId, {
97
- conditions: [],
98
- operations: [{ type: "delete", key: KEYS.asyncActivity(pending.workflowId, token) }]
99
- });
48
+ try {
49
+ await commitFencedEngineWrite(internals, buildAsyncActivityAcknowledgementOperations(pending, outcome), [], () => Error(`Async activity acknowledgement for token "${token}" lost its precondition.`));
50
+ } catch (error) {
51
+ internals.pendingAsyncActivities.set(token, pending);
52
+ throw error;
53
+ }
100
54
  return pending;
101
55
  }
102
- function shouldBufferPendingAsyncActivityResolution(internals, workflowId) {
103
- return internals.inlineStrategy !== null && !internals.inlineStrategy.hasGenerator(workflowId);
104
- }
105
- function queuePendingAsyncActivityResolution(internals, workflowId, resolution) {
106
- internals.pendingAsyncActivityResolutions ??= new Map;
107
- const queued = internals.pendingAsyncActivityResolutions.get(workflowId) ?? [];
108
- queued.push(resolution);
109
- internals.pendingAsyncActivityResolutions.set(workflowId, queued);
110
- }
111
- function takePendingAsyncActivityResolution(internals, workflowId, token) {
112
- internals.pendingAsyncActivityResolutions ??= new Map;
113
- const queued = internals.pendingAsyncActivityResolutions.get(workflowId);
114
- if (queued === void 0)
115
- return;
116
- const index = queued.findIndex((resolution) => resolution.token === token);
117
- if (index === -1)
118
- return;
119
- const resolution = queued[index];
120
- if (resolution === void 0)
121
- return;
122
- queued.splice(index, 1);
123
- if (queued.length === 0)
124
- internals.pendingAsyncActivityResolutions.delete(workflowId);
125
- return resolution;
126
- }
127
- function deliverPendingAsyncActivityResolution(workflowId, resolution, callbacks) {
56
+ function deliverPendingAsyncActivityResolution(internals, workflowId, resolution, callbacks) {
57
+ stageAtomicWorkflowCommitSideEffects(internals, workflowId, {
58
+ conditions: [],
59
+ operations: [
60
+ { type: "delete", key: KEYS.asyncActivityResolution(workflowId, resolution.token) }
61
+ ]
62
+ });
128
63
  callbacks.finalizeTimeline(workflowId, resolution.timelineStatus, resolution.timelineOutput);
129
64
  callbacks.feedOperationResult(workflowId, resolution.outcome, resolution.originalReason);
130
65
  }
131
66
  async function resolvePendingAsyncActivity(internals, token, outcome, callbacks, originalReason) {
132
- const pending = await consumePendingAsyncActivity(internals, token), timelineOutput = outcome.status === "completed" ? outcome.value : outcome.error, resolution = {
67
+ const pending = await consumePendingAsyncActivity(internals, token, outcome), timelineOutput = outcome.status === "completed" ? outcome.value : outcome.error, resolution = {
133
68
  token,
134
69
  outcome,
135
70
  timelineStatus: outcome.status,
@@ -140,7 +75,7 @@ async function resolvePendingAsyncActivity(internals, token, outcome, callbacks,
140
75
  queuePendingAsyncActivityResolution(internals, pending.workflowId, resolution);
141
76
  return;
142
77
  }
143
- deliverPendingAsyncActivityResolution(pending.workflowId, resolution, callbacks);
78
+ deliverPendingAsyncActivityResolution(internals, pending.workflowId, resolution, callbacks);
144
79
  }
145
80
  export async function completeAsyncActivity(internals, token, result, callbacks) {
146
81
  assertPayloadWithinLimit(result, internals.options.payloadSizePolicy.maxBytes, "activity result");
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Durable record layer for out-of-band ("async") activity completion.
3
+ *
4
+ * Two record shapes share the `async-act:v1:` keyspace, discriminated by their
5
+ * decode guards (never by key shape):
6
+ *
7
+ * - **Pending token records** ({@link KEYS.asyncActivity}): an activity that
8
+ * deferred via `ctx.completeAsync()` and is awaiting an external completion.
9
+ * - **Resolution records** ({@link KEYS.asyncActivityResolution}): an
10
+ * acknowledged completion or failure whose resumed-workflow checkpoint has
11
+ * not committed yet. Written atomically with the token-record delete so the
12
+ * acknowledgement is durable before the caller learns it succeeded.
13
+ *
14
+ * The completion orchestration (park, consume, deliver, resume) lives in
15
+ * `async-activity-completion.ts`; this module owns the persisted shapes, the
16
+ * key derivations, and the in-memory resolution queue that recovery drains.
17
+ */
18
+ import { type BatchOperation } from '../../storage/interface.ts';
19
+ import type { OperationOutcome } from '../types.ts';
20
+ import type { EngineInternals } from './internals.ts';
21
+ /**
22
+ * Storage-key prefix for durable async-activity records. Matches the base of
23
+ * {@link KEYS.asyncActivity}; the full key appends `<workflowId>:<token>` (and
24
+ * `:resolution` for resolution records). The trailing colon (absent from the
25
+ * token prefix) scopes the global recovery scan to record keys only.
26
+ */
27
+ export declare const ASYNC_ACTIVITY_KEY_PREFIX = "async-act:v1:";
28
+ /**
29
+ * Per-workflow prefix for all async-activity storage keys — pending token
30
+ * records AND resolution records. Used by cleanup and purge paths that need to
31
+ * sweep every async-activity record for a workflow without enumerating
32
+ * individual tokens.
33
+ */
34
+ export declare function asyncActivityWorkflowPrefix(workflowId: string): string;
35
+ /**
36
+ * In-memory record of an activity that deferred to out-of-band completion and
37
+ * is awaiting `completeAsyncActivity` / `failAsyncActivity`.
38
+ */
39
+ export type PendingAsyncActivity = {
40
+ readonly token: string;
41
+ readonly workflowId: string;
42
+ readonly activityName: string;
43
+ readonly operationId: string;
44
+ readonly step: number;
45
+ readonly attempt: number;
46
+ readonly createdAt: number;
47
+ };
48
+ /**
49
+ * In-memory form of an acknowledged outcome awaiting delivery into the workflow
50
+ * generator. `originalReason` (the raw thrown value on the failure path) exists
51
+ * only within the acknowledging process — it is not persisted, so a resolution
52
+ * reloaded by recovery reconstructs the error from the recorded outcome.
53
+ */
54
+ export type PendingAsyncActivityResolution = {
55
+ readonly token: string;
56
+ readonly outcome: OperationOutcome;
57
+ readonly originalReason?: {
58
+ value: unknown;
59
+ };
60
+ readonly timelineStatus: 'completed' | 'failed';
61
+ readonly timelineOutput: unknown;
62
+ };
63
+ /**
64
+ * Derive the durable, deterministic task token for an async activity.
65
+ *
66
+ * The token is anchored to the workflow id, the activity state key, and the
67
+ * dispatch attempt — all of which are stable across replay — so a workflow that
68
+ * crashes while parked on an async activity mints the identical token after
69
+ * recovery. Plain `ctx.run()` uses the workflow step as the state key.
70
+ * `operationId` is deliberately excluded because it is regenerated on every
71
+ * yield and would change on replay.
72
+ */
73
+ export declare function deriveAsyncActivityToken(workflowId: string, step: number | string, attempt: number): string;
74
+ /**
75
+ * Build the acknowledgement batch for a consumed token: delete the pending
76
+ * token record and persist the resolution record carrying `outcome`, in one
77
+ * batch, so the acknowledgement is durable before the caller learns it
78
+ * succeeded.
79
+ */
80
+ export declare function buildAsyncActivityAcknowledgementOperations(pending: PendingAsyncActivity, outcome: OperationOutcome): BatchOperation[];
81
+ /**
82
+ * Register a deferred activity: record it in memory and durably, then announce
83
+ * the token via {@link ActivityAsyncPendingEvent}. Idempotent on `token`: if the
84
+ * token is already registered (e.g. because `recoverPendingAsyncActivities` loaded
85
+ * it before the workflow replayed and re-deferred), the durable record is
86
+ * refreshed but the event is NOT re-emitted, preventing duplicate side-effects
87
+ * (e.g. re-sending a webhook notification) on replay.
88
+ */
89
+ export declare function registerPendingAsyncActivity(internals: EngineInternals, pending: PendingAsyncActivity): Promise<void>;
90
+ /**
91
+ * Reload async-activity records from storage into memory. Called by
92
+ * `recoverAll()` so a token minted before a crash is resolvable again — even
93
+ * before the recovered workflow has replayed far enough to re-register it —
94
+ * and so an acknowledged-but-not-yet-checkpointed resolution is redelivered
95
+ * when replay re-parks on the same deterministic token.
96
+ */
97
+ export declare function recoverPendingAsyncActivities(internals: EngineInternals): Promise<void>;
98
+ /**
99
+ * True when a resolution cannot be delivered yet because inline replay has not
100
+ * adopted the workflow generator (the post-recovery window).
101
+ */
102
+ export declare function shouldBufferPendingAsyncActivityResolution(internals: EngineInternals, workflowId: string): boolean;
103
+ /** Queue a resolution for delivery when replay reaches its token again. */
104
+ export declare function queuePendingAsyncActivityResolution(internals: EngineInternals, workflowId: string, resolution: PendingAsyncActivityResolution): void;
105
+ /** Take the queued resolution for `token`, if one is waiting. */
106
+ export declare function takePendingAsyncActivityResolution(internals: EngineInternals, workflowId: string, token: string): PendingAsyncActivityResolution | undefined;