@lostgradient/weft 0.7.0 → 0.8.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 (62) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-main.js +63 -63
  3. package/dist/core/context/activity-retry-state.d.ts +6 -5
  4. package/dist/core/context/activity-retry-state.js +31 -21
  5. package/dist/core/context/durable-activity.d.ts +117 -0
  6. package/dist/core/context/durable-activity.js +79 -0
  7. package/dist/core/context/operation-request.d.ts +15 -0
  8. package/dist/core/context/parallel-operations.js +1 -0
  9. package/dist/core/context/run-operation-cached-request.d.ts +8 -0
  10. package/dist/core/context/run-operation-cached-request.js +59 -0
  11. package/dist/core/context/run-operation.d.ts +23 -5
  12. package/dist/core/context/run-operation.js +62 -47
  13. package/dist/core/engine/activity-heartbeat-tracking.d.ts +8 -17
  14. package/dist/core/engine/activity-heartbeat-tracking.js +7 -1
  15. package/dist/core/engine/activity-reconciliation.d.ts +1 -0
  16. package/dist/core/engine/activity-reconciliation.js +7 -2
  17. package/dist/core/engine/anonymous-signal-sequence.js +6 -4
  18. package/dist/core/engine/async-activity-completion.d.ts +5 -7
  19. package/dist/core/engine/async-activity-completion.js +13 -9
  20. package/dist/core/engine/bulk-operations-purge.js +2 -1
  21. package/dist/core/engine/bulk-operations.js +8 -7
  22. package/dist/core/engine/callback-checkpoint-persistence.d.ts +3 -0
  23. package/dist/core/engine/callback-checkpoint-persistence.js +25 -0
  24. package/dist/core/engine/callback-creators-bundles.js +4 -1
  25. package/dist/core/engine/checkpoint-io.d.ts +4 -1
  26. package/dist/core/engine/checkpoint-io.js +19 -10
  27. package/dist/core/engine/completed-review-storage.d.ts +2 -1
  28. package/dist/core/engine/completed-review-storage.js +4 -3
  29. package/dist/core/engine/index.d.ts +31 -0
  30. package/dist/core/engine/index.js +6 -1
  31. package/dist/core/engine/internals.d.ts +2 -1
  32. package/dist/core/engine/lease-manager.js +2 -2
  33. package/dist/core/engine/memo-durable-activity.d.ts +11 -0
  34. package/dist/core/engine/memo-durable-activity.js +282 -0
  35. package/dist/core/engine/operations-activity.d.ts +5 -1
  36. package/dist/core/engine/operations-activity.js +19 -7
  37. package/dist/core/engine/operations-data.d.ts +4 -1
  38. package/dist/core/engine/operations-data.js +3 -3
  39. package/dist/core/engine/reviews.js +7 -4
  40. package/dist/core/engine/schedule-timer.js +2 -0
  41. package/dist/core/engine/storage-io.js +1 -1
  42. package/dist/core/json.js +1 -1
  43. package/dist/core/weft-error.d.ts +1 -1
  44. package/dist/core/weft-error.js +2 -0
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.js +6 -0
  47. package/dist/json-schema.js +1 -1
  48. package/dist/mcp/cli.js +26 -26
  49. package/dist/server/handler.js +21 -21
  50. package/dist/server/index.js +17 -17
  51. package/dist/server/runtime/websocket-worker.js +7 -2
  52. package/dist/server/serve-internals.d.ts +28 -0
  53. package/dist/server/serve-internals.js +4 -2
  54. package/dist/service-worker/index.js +22 -22
  55. package/dist/service-worker/setup.d.ts +18 -1
  56. package/dist/service-worker/setup.js +7 -4
  57. package/dist/storage/typed-storage.d.ts +1 -1
  58. package/dist/storage/typed-storage.js +1 -1
  59. package/dist/testing/index.js +27 -27
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +1 -1
@@ -16,6 +16,7 @@ import {
16
16
  resolveActivityTimeout
17
17
  } from "./activity-schedule-to-close.js";
18
18
  import { getInternals, hasContextInternals } from "./internals.js";
19
+ import { getCachedRunActivityRequest } from "./run-operation-cached-request.js";
19
20
  import { isActivityCallOptions } from "./session-state.js";
20
21
  import { captureCallerStack } from "./validation.js";
21
22
  export function asConcreteContext(context) {
@@ -42,13 +43,27 @@ function parseRunArguments(activity, rest) {
42
43
  return { input: values[0], options };
43
44
  }
44
45
  export const readActivityRetryAttemptForTesting = readActivityRetryAttempt, readCompletedRetrySleepCountForTesting = readCompletedRetrySleepCount, completeActivityRetryAttemptForTesting = completeActivityRetryAttempt;
45
- function resolveScheduleToCloseBudget(internals, step, scheduleToCloseTimeout) {
46
+ const ACTIVITY_RETRY_SLEEP_DEADLINE_LOCAL_PREFIX = "__weftActivityRetrySleepDeadline:";
47
+ export function readOrInitActivityRetrySleepFireAt(context, operationId, duration) {
48
+ const internals = getInternals(context), localKey = `${ACTIVITY_RETRY_SLEEP_DEADLINE_LOCAL_PREFIX}${operationId}`, existing = internals.checkpointLocals[localKey];
49
+ if (typeof existing === "number" && Number.isFinite(existing))
50
+ return existing;
51
+ if (existing !== void 0)
52
+ throw Error(`Invalid checkpointed activity retry sleep deadline ${JSON.stringify(existing)} for "${operationId}".`);
53
+ const scheduledFireAt = internals.getNow() + duration;
54
+ internals.checkpointLocals = {
55
+ ...internals.checkpointLocals,
56
+ [localKey]: scheduledFireAt
57
+ };
58
+ return scheduledFireAt;
59
+ }
60
+ export function resolveScheduleToCloseBudget(internals, retryStateKey, scheduleToCloseTimeout) {
46
61
  const budgetMs = parseScheduleToCloseBudgetMs(scheduleToCloseTimeout);
47
62
  if (budgetMs === void 0)
48
63
  return;
49
64
  return {
50
65
  budgetMs,
51
- dispatchedAt: readOrInitActivityDispatchedAt(internals, step, internals.getNow())
66
+ dispatchedAt: readOrInitActivityDispatchedAt(internals, retryStateKey, internals.getNow())
52
67
  };
53
68
  }
54
69
  function getActivityName(activity, explicitName) {
@@ -59,39 +74,7 @@ function getActivityName(activity, explicitName) {
59
74
  function getActivityFunction(activity) {
60
75
  return typeof activity === "function" ? activity : void 0;
61
76
  }
62
- function getCachedRunActivityRequest(internals, step, activityName, input) {
63
- const hasCachedResult = internals.accumulatedResults?.has(step) ?? !1;
64
- if (hasCachedResult) {
65
- const cachedResult = internals.accumulatedResults?.get(step);
66
- internals.stepIndex += readCompletedRetrySleepCount(internals, step);
67
- if (internals.explainMode)
68
- console.log(`[weft] ctx.run(${activityName}) \u2192 Returning cached result from step ${step}`);
69
- return {
70
- request: { type: "activity", operationId: "", activityName, input },
71
- step,
72
- hasCachedResult,
73
- cachedResult,
74
- retryAttempt: 1
75
- };
76
- }
77
- const retryAttempt = readActivityRetryAttempt(internals, step);
78
- if (retryAttempt !== void 0) {
79
- internals.stepIndex += retryAttempt - 2;
80
- return {
81
- request: { type: "activity", operationId: "", activityName, input },
82
- step,
83
- hasCachedResult: !1,
84
- retryAttempt
85
- };
86
- }
87
- return {
88
- request: { type: "activity", operationId: "", activityName, input },
89
- step,
90
- hasCachedResult,
91
- retryAttempt: 1
92
- };
93
- }
94
- function createFreshRunActivityRequest(internals, step, activityName, activityFunction, input, options) {
77
+ function createFreshRunActivityRequest(internals, step, activityName, activityFunction, input, options, activityStateKey) {
95
78
  const queue = options?.queue ?? "default";
96
79
  if (internals.explainMode) {
97
80
  console.log(`[weft] ctx.run(${activityName}, ${JSON.stringify(input)})`);
@@ -103,6 +86,7 @@ function createFreshRunActivityRequest(internals, step, activityName, activityFu
103
86
  operationId: crypto.randomUUID(),
104
87
  activityName,
105
88
  step,
89
+ ...activityStateKey === void 0 ? {} : { activityStateKey },
106
90
  ...activityFunction !== void 0 ? { fn: activityFunction } : {},
107
91
  input,
108
92
  callerStack: captureCallerStack(),
@@ -130,7 +114,7 @@ function isNonRetryableActivityError(error, policy) {
130
114
  export function shouldRetryActivityError(error, policy, attempt) {
131
115
  return policy !== void 0 && attempt < policy.maxAttempts && !isNonRetryableActivityError(error, policy);
132
116
  }
133
- function prepareActivityRetryRequest(request, attempt) {
117
+ export function prepareActivityRetryRequest(request, attempt) {
134
118
  if (attempt === 1)
135
119
  return request;
136
120
  return {
@@ -140,45 +124,76 @@ function prepareActivityRetryRequest(request, attempt) {
140
124
  };
141
125
  }
142
126
  export function createRunActivityRequest(context, activity, rest, explicitName) {
143
- const { input, options } = parseRunArguments(activity, rest), activityName = getActivityName(activity, explicitName), activityFunction = getActivityFunction(activity), internals = getInternals(context), step = internals.stepIndex++, cachedRequest = getCachedRunActivityRequest(internals, step, activityName, input);
127
+ parseRunArguments(activity, rest);
128
+ const internals = getInternals(context), step = internals.stepIndex++;
129
+ return createRunActivityRequestAtStep(context, activity, rest, step, {
130
+ ...explicitName === void 0 ? {} : { explicitName },
131
+ advanceStepIndexForCachedRetryState: !0,
132
+ retryStateKey: step,
133
+ cacheResultStep: step
134
+ });
135
+ }
136
+ export function createRunActivityRequestAtStep(context, activity, rest, step, configuration = {}) {
137
+ const { input, options } = parseRunArguments(activity, rest), activityName = getActivityName(activity, configuration.explicitName), activityFunction = getActivityFunction(activity), internals = getInternals(context), retryStateKey = configuration.retryStateKey ?? step, cacheResultStep = configuration.cacheResultStep === !1 ? void 0 : configuration.cacheResultStep ?? step, cachedRequest = getCachedRunActivityRequest(internals, step, retryStateKey, cacheResultStep, activityName, input, {
138
+ advanceStepIndexForCachedRetryState: configuration.advanceStepIndexForCachedRetryState ?? !1
139
+ });
144
140
  if (cachedRequest.hasCachedResult)
145
141
  return cachedRequest;
146
- const retryPolicy = resolveActivityRetryPolicy(activity, options), scheduleToCloseTimeout = resolveActivityScheduleToCloseTimeout(activity, options), effectiveTimeout = resolveActivityTimeout(activity, options), dispatchedOptions = effectiveTimeout === void 0 ? options : { ...options, timeout: effectiveTimeout };
142
+ const retryPolicy = resolveActivityRetryPolicy(activity, options), scheduleToCloseTimeout = resolveActivityScheduleToCloseTimeout(activity, options);
147
143
  return {
148
- request: createFreshRunActivityRequest(internals, step, activityName, activityFunction, input, dispatchedOptions),
144
+ request: createFreshRunActivityRequest(internals, step, activityName, activityFunction, input, resolveDispatchedActivityOptions(activity, options), configuration.activityStateKey),
149
145
  step,
146
+ retryStateKey,
147
+ ...cacheResultStep === void 0 ? {} : { cacheResultStep },
150
148
  hasCachedResult: !1,
151
149
  retryAttempt: cachedRequest.retryAttempt,
152
150
  ...retryPolicy === void 0 ? {} : { retryPolicy },
153
151
  ...scheduleToCloseTimeout === void 0 ? {} : { scheduleToCloseTimeout }
154
152
  };
155
153
  }
154
+ function resolveDispatchedActivityOptions(activity, options) {
155
+ const effectiveTimeout = resolveActivityTimeout(activity, options);
156
+ return effectiveTimeout === void 0 ? options : { ...options, timeout: effectiveTimeout };
157
+ }
156
158
  export function* runActivityWithRetry(context, activity, rest, explicitName) {
159
+ return yield* runPreparedActivityWithRetry(context, createRunActivityRequest(context, activity, rest, explicitName), defaultActivityRetrySleep(context));
160
+ }
161
+ export function* runActivityWithRetryAtStep(context, activity, rest, step, configuration = {}) {
162
+ return yield* runPreparedActivityWithRetry(context, createRunActivityRequestAtStep(context, activity, rest, step, configuration), configuration.retrySleep ?? defaultActivityRetrySleep(context));
163
+ }
164
+ function defaultActivityRetrySleep(context) {
165
+ return function* sleepForRetry(duration) {
166
+ yield* context.sleep(duration);
167
+ };
168
+ }
169
+ function* runPreparedActivityWithRetry(context, prepared, retrySleep) {
157
170
  const {
158
171
  request,
159
- step,
172
+ retryStateKey,
173
+ cacheResultStep,
160
174
  hasCachedResult,
161
175
  cachedResult,
162
176
  retryAttempt,
163
177
  retryPolicy,
164
178
  scheduleToCloseTimeout
165
- } = createRunActivityRequest(context, activity, rest, explicitName);
179
+ } = prepared;
166
180
  if (hasCachedResult)
167
181
  return cachedResult;
168
- const internals = getInternals(context), budget = retryPolicy === void 0 ? void 0 : resolveScheduleToCloseBudget(internals, step, scheduleToCloseTimeout);
182
+ const internals = getInternals(context), budget = retryPolicy === void 0 ? void 0 : resolveScheduleToCloseBudget(internals, retryStateKey, scheduleToCloseTimeout);
169
183
  let attempt = retryAttempt;
170
184
  if (attempt > 1) {
171
185
  if (retryPolicy === void 0)
172
186
  throw Error(`Missing activity retry policy for checkpointed retry attempt ${attempt}`);
173
- yield* context.sleep(calculateBackoff(attempt - 1, retryPolicy));
187
+ yield* retrySleep(calculateBackoff(attempt - 1, retryPolicy), attempt);
174
188
  }
175
189
  while (!0) {
176
190
  if (attempt > 1)
177
191
  assertScheduleToCloseBudgetNotExhausted(budget, request.activityName, internals.getNow());
178
192
  try {
179
193
  const result = yield prepareActivityRetryRequest(request, attempt);
180
- context.accumulatedResults.set(step, result);
181
- completeActivityRetryAttempt(internals, step, attempt - 1);
194
+ if (cacheResultStep !== void 0)
195
+ context.accumulatedResults.set(cacheResultStep, result);
196
+ completeActivityRetryAttempt(internals, retryStateKey, attempt - 1);
182
197
  return result;
183
198
  } catch (error) {
184
199
  if (!shouldRetryActivityError(error, retryPolicy, attempt))
@@ -186,8 +201,8 @@ export function* runActivityWithRetry(context, activity, rest, explicitName) {
186
201
  const backoff = calculateBackoff(attempt, retryPolicy), now = internals.getNow();
187
202
  assertScheduleToCloseBudgetNotExhausted(budget, request.activityName, now, now + backoff);
188
203
  const nextAttempt = attempt + 1;
189
- writeActivityRetryAttempt(internals, step, nextAttempt);
190
- yield* context.sleep(backoff);
204
+ writeActivityRetryAttempt(internals, retryStateKey, nextAttempt);
205
+ yield* retrySleep(backoff, nextAttempt);
191
206
  attempt = nextAttempt;
192
207
  }
193
208
  }
@@ -1,19 +1,10 @@
1
1
  import type { ActivityContext } from '../types.ts';
2
2
  import type { EngineInternals } from './internals.ts';
3
- /**
4
- * Per-step heartbeat tracking for {@link ActivityContext.lastHeartbeatDetails}.
5
- *
6
- * The store is keyed `workflowId -> step` (not `workflowId` alone) so a retry of
7
- * an activity step reads the heartbeat ITS prior attempt recorded — and so a
8
- * later step's first attempt never inherits an earlier step's heartbeat, and
9
- * concurrent `ctx.all` activities never clobber one another. The step is stable
10
- * across retry attempts (assigned once at `stepIndex++`). Held only in engine
11
- * memory and cleared by workflowId (the outer key) on terminal cleanup and purge.
12
- */
13
- /** Record the heartbeat the current attempt of a step sent. */
14
- export declare function recordLastHeartbeatForStep(internals: EngineInternals, workflowId: string, step: number, details: unknown): void;
15
- /** Read the heartbeat a prior attempt of this step recorded, or `undefined`. */
16
- export declare function readLastHeartbeatForStep(internals: EngineInternals, workflowId: string, step: number): unknown;
3
+ export type ActivityHeartbeatKey = number | string;
4
+ /** Record the heartbeat the current attempt of an activity sent. */
5
+ export declare function recordLastHeartbeatForStep(internals: EngineInternals, workflowId: string, step: ActivityHeartbeatKey, details: unknown): void;
6
+ /** Read the heartbeat a prior attempt of this activity recorded, or `undefined`. */
7
+ export declare function readLastHeartbeatForStep(internals: EngineInternals, workflowId: string, step: ActivityHeartbeatKey): unknown;
17
8
  /**
18
9
  * Drop the heartbeat tracked for a single step once that step has completed
19
10
  * successfully — after inline verify, and (on the idempotency path) after the
@@ -26,7 +17,7 @@ export declare function readLastHeartbeatForStep(internals: EngineInternals, wor
26
17
  * resumable-batch heartbeat a retry depends on, so this is only ever called from
27
18
  * the non-speculative success path.
28
19
  */
29
- export declare function clearLastHeartbeatForStep(internals: EngineInternals, workflowId: string, step: number): void;
20
+ export declare function clearLastHeartbeatForStep(internals: EngineInternals, workflowId: string, step: ActivityHeartbeatKey): void;
30
21
  /**
31
22
  * #493: in development mode, emit a COARSE warning when an inline activity RETRY
32
23
  * (`attempt > 1`) starts with no `lastHeartbeatDetails` — the closest runtime
@@ -53,7 +44,7 @@ export declare function clearLastHeartbeatForStep(internals: EngineInternals, wo
53
44
  * routing model would need this gate to key on the resolved per-dispatch mode
54
45
  * instead.
55
46
  */
56
- export declare function warnIfRetryMissingHeartbeat(internals: EngineInternals, workflowId: string, step: number, attempt: number): void;
47
+ export declare function warnIfRetryMissingHeartbeat(internals: EngineInternals, workflowId: string, step: ActivityHeartbeatKey, attempt: number): void;
57
48
  /**
58
49
  * Build the {@link ActivityContext} handed to an inline activity function. The
59
50
  * signal comes from the per-workflow AbortController (so workflow cancellation
@@ -61,4 +52,4 @@ export declare function warnIfRetryMissingHeartbeat(internals: EngineInternals,
61
52
  * tracking above; `completeAsync` is supplied by the caller (it owns the
62
53
  * async-completion token machinery).
63
54
  */
64
- export declare function buildActivityContext(internals: EngineInternals, workflowId: string, step: number, signal: AbortSignal, completeAsync: () => never): ActivityContext;
55
+ export declare function buildActivityContext(internals: EngineInternals, workflowId: string, step: ActivityHeartbeatKey, signal: AbortSignal, completeAsync: () => never): ActivityContext;
@@ -1,4 +1,10 @@
1
1
  import { DevelopmentWarningEvent } from "../events.js";
2
+ function activityHeartbeatKeyLabel(key) {
3
+ return typeof key === "number" ? `step ${String(key)}` : `activity state key ${key}`;
4
+ }
5
+ function activityHeartbeatFieldPath(key) {
6
+ return typeof key === "number" ? `step.${String(key)}.lastHeartbeatDetails` : `activityStateKey.${key}.lastHeartbeatDetails`;
7
+ }
2
8
  export function recordLastHeartbeatForStep(internals, workflowId, step, details) {
3
9
  let byStep = internals.lastHeartbeatDetailsByStep.get(workflowId);
4
10
  if (byStep === void 0) {
@@ -27,7 +33,7 @@ export function warnIfRetryMissingHeartbeat(internals, workflowId, step, attempt
27
33
  return;
28
34
  if (readLastHeartbeatForStep(internals, workflowId, step) !== void 0)
29
35
  return;
30
- internals.engine.dispatchEvent(new DevelopmentWarningEvent(workflowId, `Activity retry (attempt ${attempt}) at step ${step} has no lastHeartbeatDetails. Either the previous attempt never recorded heartbeat details (it never called heartbeat(), or called it with no details), or the engine process restarted and discarded the in-memory heartbeat (it is not durable). The resumable-batch pattern only resumes across in-process retries; design the activity to restart cleanly when lastHeartbeatDetails is undefined.`, [`step.${step}.lastHeartbeatDetails`]));
36
+ internals.engine.dispatchEvent(new DevelopmentWarningEvent(workflowId, `Activity retry (attempt ${attempt}) at ${activityHeartbeatKeyLabel(step)} has no lastHeartbeatDetails. Either the previous attempt never recorded heartbeat details (it never called heartbeat(), or called it with no details), or the engine process restarted and discarded the in-memory heartbeat (it is not durable). The resumable-batch pattern only resumes across in-process retries; design the activity to restart cleanly when lastHeartbeatDetails is undefined.`, [activityHeartbeatFieldPath(step)]));
31
37
  }
32
38
  export function buildActivityContext(internals, workflowId, step, signal, completeAsync) {
33
39
  return {
@@ -102,6 +102,7 @@ export declare function resolveStartedActivityReconciliationRecord(internals: En
102
102
  }>;
103
103
  export declare function writeActivityReconciliationTransition(storage: Storage, reference: ActivityReconciliationReference, expectedRecord: ActivityReconciliationRecord, nextRecord: ActivityReconciliationRecord): Promise<void>;
104
104
  export declare function stageActivityReconciliationTransitionWithAtomicWorkflowCommit(internals: EngineInternals, workflowId: string, reference: ActivityReconciliationReference, expectedRecord: ActivityReconciliationRecord, nextRecord: ActivityReconciliationRecord): void;
105
+ export declare function commitActivityReconciliationTransitionWithFencedWrite(internals: EngineInternals, reference: ActivityReconciliationReference, expectedRecord: ActivityReconciliationRecord, nextRecord: ActivityReconciliationRecord): Promise<void>;
105
106
  export declare function normalizePreDispatchVerificationResult(result: ActivityVerificationResult): 'not-completed' | 'completed-result-unavailable' | 'indeterminate' | {
106
107
  result: unknown;
107
108
  };
@@ -7,6 +7,7 @@ import { decode, encode } from "../codec.js";
7
7
  import { assertPayloadWithinLimit } from "../payload-size.js";
8
8
  import { WeftError } from "../weft-error.js";
9
9
  import { stageAtomicWorkflowCommitSideEffects } from "./checkpoint-side-effects.js";
10
+ import { commitFencedEngineWrite } from "./fenced-write.js";
10
11
 
11
12
  export class ActivityReconciliationCapabilityError extends WeftError {
12
13
  constructor() {
@@ -83,7 +84,7 @@ export async function resolveStartedActivityReconciliationRecord(internals, work
83
84
  ownerId: crypto.randomUUID(),
84
85
  updatedAt: internals.options.getNow()
85
86
  };
86
- await writeActivityReconciliationTransition(internals.storage, reference, record, nextRecord);
87
+ await commitActivityReconciliationTransitionWithFencedWrite(internals, reference, record, nextRecord);
87
88
  return nextRecord;
88
89
  }
89
90
  if (normalized === "completed-result-unavailable")
@@ -92,7 +93,7 @@ export async function resolveStartedActivityReconciliationRecord(internals, work
92
93
  throw new ActivityReconciliationIndeterminateError(`Activity "${operation.activityName}" reconciliation is indeterminate.`);
93
94
  validateActivityResultForReconciliation(normalized.result, internals.options.payloadSizePolicy.maxBytes);
94
95
  const completedRecord = createCompletedActivityReconciliationRecord(record, normalized.result, internals.options.getNow());
95
- await writeActivityReconciliationTransition(internals.storage, reference, record, completedRecord);
96
+ await commitActivityReconciliationTransitionWithFencedWrite(internals, reference, record, completedRecord);
96
97
  return { completedResult: normalized.result };
97
98
  }
98
99
  export async function writeActivityReconciliationTransition(storage, reference, expectedRecord, nextRecord) {
@@ -103,6 +104,10 @@ export async function writeActivityReconciliationTransition(storage, reference,
103
104
  export function stageActivityReconciliationTransitionWithAtomicWorkflowCommit(internals, workflowId, reference, expectedRecord, nextRecord) {
104
105
  stageAtomicWorkflowCommitSideEffects(internals, workflowId, buildActivityReconciliationTransitionSideEffects(reference, expectedRecord, nextRecord));
105
106
  }
107
+ export async function commitActivityReconciliationTransitionWithFencedWrite(internals, reference, expectedRecord, nextRecord) {
108
+ const sideEffects = buildActivityReconciliationTransitionSideEffects(reference, expectedRecord, nextRecord);
109
+ await commitFencedEngineWrite(internals, sideEffects.operations, sideEffects.conditions, () => new ActivityReconciliationConflictError("Activity reconciliation completion lost compare-and-set ownership."));
110
+ }
106
111
  function buildActivityReconciliationTransitionSideEffects(reference, expectedRecord, nextRecord) {
107
112
  return {
108
113
  conditions: [{ key: reference.key, expectedValue: encode(expectedRecord) }],
@@ -79,16 +79,18 @@ async function scanNextAnonymousSignalSequence(internals, workflowId) {
79
79
  if (sequence !== null && sequence >= nextSequence)
80
80
  nextSequence = sequence + 1;
81
81
  }
82
+ if (!Number.isSafeInteger(nextSequence))
83
+ throw Error(`Anonymous signal sequence overflow for workflow "${workflowId}": computed ${nextSequence}`);
82
84
  return nextSequence;
83
85
  }
84
86
  function extractAnonymousSignalSequence(key) {
85
- const marker = ":anonymous%3A", markerIndex = key.lastIndexOf(marker);
86
- if (markerIndex === -1)
87
+ const idComponent = key.slice(key.lastIndexOf(":") + 1), marker = "anonymous%3A";
88
+ if (!idComponent.startsWith(marker))
87
89
  return null;
88
- const sequenceStart = markerIndex + marker.length, sequenceEnd = key.indexOf("%3A", sequenceStart);
90
+ const sequenceStart = marker.length, sequenceEnd = idComponent.indexOf("%3A", sequenceStart);
89
91
  if (sequenceEnd === -1)
90
92
  return null;
91
- const sequence = Number(key.slice(sequenceStart, sequenceEnd));
93
+ const sequence = Number(idComponent.slice(sequenceStart, sequenceEnd));
92
94
  return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : null;
93
95
  }
94
96
  function decodeSignalSequence(bytes) {
@@ -26,9 +26,6 @@
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
- * Internal-only. Imported from `src/core/engine/**` and the `ActivityContext`
31
- * construction path.
32
29
  */
33
30
  import type { OperationOutcome } from '../types.ts';
34
31
  import { WeftError } from '../weft-error.ts';
@@ -101,13 +98,14 @@ export type PendingAsyncActivityResolution = {
101
98
  /**
102
99
  * Derive the durable, deterministic task token for an async activity.
103
100
  *
104
- * The token is anchored to the workflow id, the workflow step index, and the
101
+ * The token is anchored to the workflow id, the activity state key, and the
105
102
  * dispatch attempt — all of which are stable across replay — so a workflow that
106
103
  * crashes while parked on an async activity mints the identical token after
107
- * recovery. `operationId` is deliberately excluded because it is regenerated on
108
- * every yield and would change on replay.
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.
109
107
  */
110
- export declare function deriveAsyncActivityToken(workflowId: string, step: number, attempt: number): string;
108
+ export declare function deriveAsyncActivityToken(workflowId: string, step: number | string, attempt: number): string;
111
109
  /**
112
110
  * Register a deferred activity: record it in memory and durably, then announce
113
111
  * the token via {@link ActivityAsyncPendingEvent}. Idempotent on `token`: if the
@@ -3,6 +3,8 @@ import { decode, encode } from "../codec.js";
3
3
  import { ActivityAsyncPendingEvent } from "../events.js";
4
4
  import { assertPayloadWithinLimit } from "../payload-size.js";
5
5
  import { WeftError } from "../weft-error.js";
6
+ import { stageAtomicWorkflowCommitSideEffects } from "./checkpoint-side-effects.js";
7
+ import { commitFencedEngineWrite } from "./fenced-write.js";
6
8
  const ASYNC_ACTIVITY_TOKEN_PREFIX = "async-act:v1", ASYNC_ACTIVITY_KEY_PREFIX = "async-act:v1:";
7
9
  export function asyncActivityWorkflowPrefix(workflowId) {
8
10
  return `${ASYNC_ACTIVITY_KEY_PREFIX}${encodeStorageKeyComponent(workflowId)}:`;
@@ -33,7 +35,7 @@ function isPersistedAsyncActivity(value) {
33
35
  export function deriveAsyncActivityToken(workflowId, step, attempt) {
34
36
  return `${ASYNC_ACTIVITY_TOKEN_PREFIX}:${workflowId}:${step}:${attempt}`;
35
37
  }
36
- function persistPendingAsyncActivity(storage, pending) {
38
+ function buildPersistPendingAsyncActivityOperation(pending) {
37
39
  const record = {
38
40
  version: 1,
39
41
  token: pending.token,
@@ -44,12 +46,16 @@ function persistPendingAsyncActivity(storage, pending) {
44
46
  attempt: pending.attempt,
45
47
  createdAt: pending.createdAt
46
48
  };
47
- return storage.put(KEYS.asyncActivity(pending.workflowId, pending.token), encode(record));
49
+ return {
50
+ type: "put",
51
+ key: KEYS.asyncActivity(pending.workflowId, pending.token),
52
+ value: encode(record)
53
+ };
48
54
  }
49
55
  export async function registerPendingAsyncActivity(internals, pending) {
50
56
  const alreadyRegistered = internals.pendingAsyncActivities.has(pending.token);
51
57
  internals.pendingAsyncActivities.set(pending.token, pending);
52
- await persistPendingAsyncActivity(internals.storage, pending);
58
+ await commitFencedEngineWrite(internals, [buildPersistPendingAsyncActivityOperation(pending)], [], () => Error(`Async activity registration for token "${pending.token}" lost its precondition.`));
53
59
  if (!alreadyRegistered)
54
60
  internals.engine.dispatchEvent(new ActivityAsyncPendingEvent(pending.token, pending.operationId, pending.workflowId, pending.activityName, pending.attempt));
55
61
  }
@@ -87,12 +93,10 @@ async function consumePendingAsyncActivity(internals, token) {
87
93
  if (!pending)
88
94
  throw new AsyncActivityTokenNotFoundError(token);
89
95
  internals.pendingAsyncActivities.delete(token);
90
- try {
91
- await internals.storage.delete(KEYS.asyncActivity(pending.workflowId, token));
92
- } catch (error) {
93
- internals.pendingAsyncActivities.set(token, pending);
94
- throw error;
95
- }
96
+ stageAtomicWorkflowCommitSideEffects(internals, pending.workflowId, {
97
+ conditions: [],
98
+ operations: [{ type: "delete", key: KEYS.asyncActivity(pending.workflowId, token) }]
99
+ });
96
100
  return pending;
97
101
  }
98
102
  function shouldBufferPendingAsyncActivityResolution(internals, workflowId) {
@@ -10,6 +10,7 @@ import { buildIndexOperations } from "../search-attributes.js";
10
10
  import { buildWorkflowTagIndexOperations, normalizeWorkflowTags } from "../workflow-tags.js";
11
11
  import { asyncActivityWorkflowPrefix } from "./async-activity-completion.js";
12
12
  import { forgetCommittedCheckpointBytes } from "./checkpoint-commit-snapshots.js";
13
+ import { commitFencedEngineWrite } from "./fenced-write.js";
13
14
  import { streamWorkflowStates } from "./listing.js";
14
15
  import { createTerminalCleanupTimerId } from "./state-utilities.js";
15
16
  import {
@@ -131,7 +132,7 @@ function getWorkflowRetentionDeadline(internals, state) {
131
132
  }
132
133
  export async function purgeWorkflow(internals, state, cleanupWaiters) {
133
134
  const deleteOperations = await collectWorkflowPurgeDeleteOperations(internals, state);
134
- await internals.storage.batch(deleteOperations);
135
+ await commitFencedEngineWrite(internals, deleteOperations, [], () => Error(`Purge commit for workflow "${state.id}" lost its precondition.`));
135
136
  clearPurgedWorkflowInMemoryState(internals, state.id, cleanupWaiters);
136
137
  }
137
138
  export async function collectWorkflowPurgeDeleteOperations(internals, state) {
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  KEYS,
3
3
  requireStorageCapability,
4
- storageConditionalBatch,
5
4
  storageHas
6
5
  } from "../../storage/interface.js";
7
6
  import { assertScopedBulkWorkflowFilter } from "../bulk-workflow-filter.js";
@@ -24,6 +23,10 @@ import {
24
23
  withBulkAuditEvent
25
24
  } from "./bulk-operations-shared.js";
26
25
  import { BulkDeleteRequiresTerminalWorkflowsError } from "./errors.js";
26
+ import {
27
+ assertLeaseHeldForEngineWork,
28
+ commitFencedEngineWriteAllowingPreconditionFailure
29
+ } from "./fenced-write.js";
27
30
  import { BULK_OPERATION_BATCH_SIZE } from "./listing.js";
28
31
  import { createTerminalCleanupTimerId } from "./state-utilities.js";
29
32
  import { loadWorkflowState, runSerializedWorkflowStateWrite } from "./storage-io.js";
@@ -132,6 +135,7 @@ async function runBulkFailedWorkflowRetry(internals, filter, options = {}) {
132
135
  return withBulkAuditEvent(internals, preparation, options, result, retried);
133
136
  }
134
137
  export async function retryFailedAll(internals, filter, options = {}) {
138
+ assertLeaseHeldForEngineWork(internals);
135
139
  return runBulkFailedWorkflowRetry(internals, filter, options);
136
140
  }
137
141
  async function retryFailedWorkflow(internals, workflowId) {
@@ -194,12 +198,9 @@ async function reactivateFailedWorkflowFromCheckpointSerialized(internals, workf
194
198
  throw Error(`Workflow concurrency admission for "${lastConcurrencyStateKey ?? workflowId}" changed too many times while retrying failed workflow "${workflowId}"`);
195
199
  }
196
200
  async function commitFailedWorkflowReactivation(internals, operations, conditions) {
197
- if (conditions.length === 0) {
198
- await internals.storage.batch(operations);
199
- return !0;
200
- }
201
- requireStorageCapability(internals.storage, "conditionalBatch", "retry failed workflow");
202
- return storageConditionalBatch(internals.storage, conditions, operations);
201
+ if (conditions.length > 0)
202
+ requireStorageCapability(internals.storage, "conditionalBatch", "retry failed workflow");
203
+ return commitFencedEngineWriteAllowingPreconditionFailure(internals, operations, conditions);
203
204
  }
204
205
  function buildReactivatedWorkflowState(internals, state) {
205
206
  const reactivatedState = {
@@ -0,0 +1,3 @@
1
+ import type { ContextOperationRequest } from '../context.ts';
2
+ import type { Engine } from './index.ts';
3
+ export declare function persistCheckpointForDataOperation<TWorkflows extends object, TActivities extends object>(engine: Engine<TWorkflows, TActivities>, workflowId: string, operation: ContextOperationRequest): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { HISTORY_CIRCUIT_BREAKER_REASON } from "../types.js";
2
+ import { validateAttributeValueSizes } from "./attributes-tags.js";
3
+ import { createTerminationCallbacks } from "./callback-creators-core.js";
4
+ import {
5
+ appendTimelineBatchOperations,
6
+ persistCheckpoint,
7
+ pruneCheckpointHistory
8
+ } from "./checkpoint-io.js";
9
+ import { getInternals } from "./internals.js";
10
+ import { swallowPromiseRejection } from "./strategy-helpers.js";
11
+ import { terminateWorkflow } from "./termination.js";
12
+ export function persistCheckpointForDataOperation(engine, workflowId, operation) {
13
+ return persistCheckpoint(getInternals(engine), workflowId, operation, void 0, {
14
+ appendTimelineBatchOperations: (id, checkpointOperation, step, timestamp, operations) => appendTimelineBatchOperations(getInternals(engine), id, checkpointOperation, step, timestamp, operations),
15
+ swallowPromiseRejection: (promise) => {
16
+ swallowPromiseRejection(promise);
17
+ },
18
+ validateAttributeValueSizes,
19
+ pruneCheckpointHistory: (id, step) => pruneCheckpointHistory(getInternals(engine), id, step),
20
+ dispatchEvent: (event) => {
21
+ engine.dispatchEvent(event);
22
+ },
23
+ enforceHistoryCircuitBreaker: (id) => terminateWorkflow(getInternals(engine), id, "timed-out", createTerminationCallbacks(engine), HISTORY_CIRCUIT_BREAKER_REASON)
24
+ }, { timeline: "preserve-pending" });
25
+ }
@@ -3,6 +3,7 @@ import {
3
3
  broadcast as broadcastFromInternals,
4
4
  dispatchPendingUpdateReceived as dispatchPendingUpdateReceivedFromBroadcast
5
5
  } from "./broadcast.js";
6
+ import { persistCheckpointForDataOperation } from "./callback-checkpoint-persistence.js";
6
7
  import {
7
8
  createBroadcastCallbacks,
8
9
  createGuardCallbacks,
@@ -97,7 +98,9 @@ export function createConditionOperationCallbacks(engine) {
97
98
  }
98
99
  export function createDataOperationCallbacks(engine) {
99
100
  return {
100
- runOperationWithResult: (workflowId, operation, execute) => runOperationWithResultForEngine(engine, workflowId, operation, execute)
101
+ runOperationWithResult: (workflowId, operation, execute) => runOperationWithResultForEngine(engine, workflowId, operation, execute),
102
+ persistCheckpoint: (workflowId, operation) => persistCheckpointForDataOperation(engine, workflowId, operation),
103
+ getActivityOperationCallbacks: () => createActivityOperationCallbacks(engine)
101
104
  };
102
105
  }
103
106
  export function createStateOperationCallbacks(engine) {
@@ -6,6 +6,9 @@ type PendingTimelineEntryValue = {
6
6
  startedAt: number;
7
7
  entry: WorkflowTimelineEntry;
8
8
  };
9
+ export type PersistCheckpointOptions = {
10
+ timeline?: 'record-operation' | 'preserve-pending';
11
+ };
9
12
  type PersistCheckpointCallbacks = {
10
13
  appendTimelineBatchOperations: (workflowId: string, operation: ContextOperationRequest, step: number, timestamp: number, operations: BatchOperation[]) => PendingTimelineEntryValue;
11
14
  swallowPromiseRejection: (promise: Promise<void>) => void;
@@ -26,7 +29,7 @@ type DevelopmentCheckpointCallbacks = {
26
29
  };
27
30
  export declare function appendTimelineBatchOperations(internals: EngineInternals, workflowId: string, operation: ContextOperationRequest, step: number, timestamp: number, operations: BatchOperation[]): PendingTimelineEntryValue;
28
31
  /** Persist a workflow checkpoint, history entry, timeline record, and event log record. */
29
- export declare function persistCheckpoint(internals: EngineInternals, workflowId: string, operation: ContextOperationRequest, workerCheckpointBytes: ArrayBuffer | undefined, callbacks: PersistCheckpointCallbacks): Promise<void>;
32
+ export declare function persistCheckpoint(internals: EngineInternals, workflowId: string, operation: ContextOperationRequest, workerCheckpointBytes: ArrayBuffer | undefined, callbacks: PersistCheckpointCallbacks, options?: PersistCheckpointOptions): Promise<void>;
30
33
  /** Delete the single checkpoint history entry that overflows the retention limit. */
31
34
  export declare function pruneCheckpointHistory(internals: EngineInternals, workflowId: string, currentStep: number): Promise<void>;
32
35
  /** Validate checkpoint serialization in development mode and dispatch warnings. */
@@ -60,13 +60,13 @@ export function appendTimelineBatchOperations(internals, workflowId, operation,
60
60
  entry
61
61
  };
62
62
  }
63
- export async function persistCheckpoint(internals, workflowId, operation, workerCheckpointBytes, callbacks) {
63
+ export async function persistCheckpoint(internals, workflowId, operation, workerCheckpointBytes, callbacks, options = {}) {
64
64
  if (internals.inlineStrategy?.getContext(workflowId))
65
- await persistInlineCheckpoint(internals, workflowId, operation, callbacks);
65
+ await persistInlineCheckpoint(internals, workflowId, operation, callbacks, options);
66
66
  else if (workerCheckpointBytes && workerCheckpointBytes.byteLength > 0)
67
- await persistWorkerCheckpoint(internals, workflowId, operation, workerCheckpointBytes, callbacks);
67
+ await persistWorkerCheckpoint(internals, workflowId, operation, workerCheckpointBytes, callbacks, options);
68
68
  }
69
- async function persistInlineCheckpoint(internals, workflowId, operation, callbacks) {
69
+ async function persistInlineCheckpoint(internals, workflowId, operation, callbacks, options) {
70
70
  const current = internals.checkpoints.get(workflowId), context = internals.inlineStrategy?.getContext(workflowId);
71
71
  if (!current || !context)
72
72
  return;
@@ -76,13 +76,13 @@ async function persistInlineCheckpoint(internals, workflowId, operation, callbac
76
76
  ...pendingAttributeChanges !== void 0 ? { searchAttributes: pendingAttributeChanges } : {}
77
77
  }), pruned = pruneCheckpointReplayState(advanced, resolvePendingOperationStep(operation, context.stepIndex)), commit = createCheckpointCommit(internals, workflowId, pruned.checkpoint, serializeCheckpoint(attachTransientCheckpointReplayPayload(pruned.checkpoint, pruned.replayPayload)), pruned.replayPayload);
78
78
  appendAttributeOperations(workflowId, commit, previousAttributes, pendingAttributeChanges, hasPendingAttributeChangesValue, callbacks);
79
- await commitCheckpoint(internals, workflowId, operation, commit, callbacks);
79
+ await commitCheckpoint(internals, workflowId, operation, commit, callbacks, options);
80
80
  if (hasPendingAttributeChangesValue)
81
81
  callbacks.dispatchEvent(new AttributesChangedEvent(workflowId, { ...pendingAttributeChanges }));
82
82
  }
83
- async function persistWorkerCheckpoint(internals, workflowId, operation, workerCheckpointBytes, callbacks) {
83
+ async function persistWorkerCheckpoint(internals, workflowId, operation, workerCheckpointBytes, callbacks, options) {
84
84
  const serialized = new Uint8Array(workerCheckpointBytes), checkpoint = deserializeCheckpoint(serialized), workerReplayPayload = readCheckpointReplayPayload(checkpoint), pruned = pruneCheckpointReplayState(checkpoint, resolvePendingOperationStep(operation, checkpoint.step)), replayPayload = mergeCheckpointReplayPayloads(workerReplayPayload, pruned.replayPayload), prunedSerialized = serializeCheckpoint(attachTransientCheckpointReplayPayload(pruned.checkpoint, replayPayload));
85
- await commitCheckpoint(internals, workflowId, operation, createCheckpointCommit(internals, workflowId, pruned.checkpoint, prunedSerialized, replayPayload), callbacks);
85
+ await commitCheckpoint(internals, workflowId, operation, createCheckpointCommit(internals, workflowId, pruned.checkpoint, prunedSerialized, replayPayload), callbacks, options);
86
86
  }
87
87
  function createCheckpointCommit(internals, workflowId, checkpoint, serialized, replayPayload) {
88
88
  const operations = [
@@ -116,9 +116,9 @@ function appendAttributeOperations(workflowId, commit, previousAttributes, pendi
116
116
  });
117
117
  commit.operations.push(...buildIndexOperations(workflowId, previousAttributes, commit.checkpoint.searchAttributes));
118
118
  }
119
- async function commitCheckpoint(internals, workflowId, operation, commit, callbacks) {
119
+ async function commitCheckpoint(internals, workflowId, operation, commit, callbacks, options) {
120
120
  dispatchCheckpointSizeWarning(internals, workflowId, commit, callbacks);
121
- const nextPendingTimelineEntry = callbacks.appendTimelineBatchOperations(workflowId, operation, commit.step, commit.timestamp, commit.operations), { newHead, timestamp } = appendCheckpointEventLog(internals, workflowId, commit), retentionWindow = internals.options.historyPolicy.retentionWindow, compaction = retentionWindow === null ? null : await appendCompactionOperations(internals.storage, workflowId, newHead.sequence, retentionWindow, commit.operations), pendingSideEffects = takePendingAtomicWorkflowCommitSideEffects(internals, workflowId);
121
+ const nextPendingTimelineEntry = options.timeline === "preserve-pending" ? preservePendingTimelineEntry(internals, workflowId, commit.operations) : callbacks.appendTimelineBatchOperations(workflowId, operation, commit.step, commit.timestamp, commit.operations), { newHead, timestamp } = appendCheckpointEventLog(internals, workflowId, commit), retentionWindow = internals.options.historyPolicy.retentionWindow, compaction = retentionWindow === null ? null : await appendCompactionOperations(internals.storage, workflowId, newHead.sequence, retentionWindow, commit.operations), pendingSideEffects = takePendingAtomicWorkflowCommitSideEffects(internals, workflowId);
122
122
  if (pendingSideEffects !== void 0)
123
123
  commit.operations.push(...pendingSideEffects.operations);
124
124
  const storageSupportsConditionalBatch = internals.storage.capabilities().conditionalBatch, sideEffectConditions = checkpointSideEffectConditions(pendingSideEffects, storageSupportsConditionalBatch), conditions = buildCheckpointCommitConditions(workflowId, commit, storageSupportsConditionalBatch, sideEffectConditions);
@@ -129,7 +129,10 @@ async function commitCheckpoint(internals, workflowId, operation, commit, callba
129
129
  clearPendingAtomicWorkflowCommitSideEffects(internals, workflowId);
130
130
  if (commit.expectedSerialized !== void 0)
131
131
  rememberCommittedCheckpointBytes(internals, workflowId, commit.serialized);
132
- internals.pendingTimelineEntries.set(workflowId, nextPendingTimelineEntry);
132
+ if (nextPendingTimelineEntry === void 0)
133
+ internals.pendingTimelineEntries.delete(workflowId);
134
+ else
135
+ internals.pendingTimelineEntries.set(workflowId, nextPendingTimelineEntry);
133
136
  internals.checkpoints.set(workflowId, commit.checkpoint);
134
137
  internals.eventLogHeads.set(workflowId, newHead);
135
138
  if (compaction !== null)
@@ -146,6 +149,12 @@ async function commitCheckpoint(internals, workflowId, operation, commit, callba
146
149
  if (historyEventLimitBreached(internals, newHead.sequence))
147
150
  await callbacks.enforceHistoryCircuitBreaker(workflowId);
148
151
  }
152
+ function preservePendingTimelineEntry(internals, workflowId, operations) {
153
+ const pendingTimelineOperation = buildPendingTimelineOperation(internals, workflowId);
154
+ if (pendingTimelineOperation)
155
+ operations.push(pendingTimelineOperation);
156
+ return internals.pendingTimelineEntries.get(workflowId);
157
+ }
149
158
  function checkpointSideEffectConditions(pendingSideEffects, storageSupportsConditionalBatch) {
150
159
  if (!storageSupportsConditionalBatch)
151
160
  return [];