@lostgradient/weft 0.20.0 → 0.22.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 (144) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/generated/operation-catalog.snapshot.json +207 -80
  3. package/dist/cli/generated/operation-client.generated.d.ts +120 -43
  4. package/dist/client/http-client-storage.js +1 -1
  5. package/dist/core/engine/activity-reconciliation.d.ts +2 -2
  6. package/dist/core/engine/activity-reconciliation.js +12 -8
  7. package/dist/core/engine/async-activity-completion.js +12 -4
  8. package/dist/core/engine/async-activity-records.d.ts +11 -1
  9. package/dist/core/engine/async-activity-records.js +4 -3
  10. package/dist/core/engine/attributes-tags.d.ts +11 -1
  11. package/dist/core/engine/attributes-tags.js +4 -3
  12. package/dist/core/engine/bulk-operations-purge.js +5 -2
  13. package/dist/core/engine/bulk-operations.js +17 -5
  14. package/dist/core/engine/callback-creators-bundles.js +1 -1
  15. package/dist/core/engine/callback-creators-core.js +4 -2
  16. package/dist/core/engine/checkpoint-io.js +1 -1
  17. package/dist/core/engine/child-workflow.js +2 -1
  18. package/dist/core/engine/completed-review-storage.js +1 -1
  19. package/dist/core/engine/condition-waiters.d.ts +45 -3
  20. package/dist/core/engine/condition-waiters.js +12 -3
  21. package/dist/core/engine/disposal.js +6 -0
  22. package/dist/core/engine/engine-internal-types.d.ts +12 -4
  23. package/dist/core/engine/fenced-write.d.ts +46 -15
  24. package/dist/core/engine/fenced-write.js +39 -6
  25. package/dist/core/engine/finalizer-state.d.ts +3 -1
  26. package/dist/core/engine/handle-result.d.ts +49 -1
  27. package/dist/core/engine/handle-result.js +95 -20
  28. package/dist/core/engine/index.d.ts +13 -5
  29. package/dist/core/engine/index.js +155 -5
  30. package/dist/core/engine/inline-launch-queue.js +9 -0
  31. package/dist/core/engine/internals.d.ts +73 -0
  32. package/dist/core/engine/invoke-update-handler.d.ts +19 -0
  33. package/dist/core/engine/invoke-update-handler.js +7 -0
  34. package/dist/core/engine/lease-deposition.d.ts +132 -0
  35. package/dist/core/engine/lease-deposition.js +31 -0
  36. package/dist/core/engine/lease-errors.d.ts +80 -9
  37. package/dist/core/engine/lease-errors.js +28 -2
  38. package/dist/core/engine/lifecycle/resume.js +3 -1
  39. package/dist/core/engine/lifecycle/standalone-claim-acquire.d.ts +33 -0
  40. package/dist/core/engine/lifecycle/standalone-claim-acquire.js +25 -0
  41. package/dist/core/engine/lifecycle/start-commit.js +20 -6
  42. package/dist/core/engine/lifecycle/transition.d.ts +15 -1
  43. package/dist/core/engine/lifecycle/transition.js +6 -3
  44. package/dist/core/engine/operations-activity.js +1 -1
  45. package/dist/core/engine/operations-time.js +21 -7
  46. package/dist/core/engine/owner-side-signal-poll.d.ts +132 -0
  47. package/dist/core/engine/owner-side-signal-poll.js +22 -0
  48. package/dist/core/engine/owner-side-update-poll.d.ts +116 -0
  49. package/dist/core/engine/owner-side-update-poll.js +22 -0
  50. package/dist/core/engine/ownership-bootstrap.d.ts +280 -0
  51. package/dist/core/engine/ownership-bootstrap.js +89 -0
  52. package/dist/core/engine/ownership-mode-marker.d.ts +82 -0
  53. package/dist/core/engine/ownership-mode-marker.js +52 -0
  54. package/dist/core/engine/ownership-options.d.ts +22 -6
  55. package/dist/core/engine/ownership-options.js +42 -12
  56. package/dist/core/engine/pending-updates.js +35 -24
  57. package/dist/core/engine/queries.d.ts +70 -0
  58. package/dist/core/engine/queries.js +58 -4
  59. package/dist/core/engine/schedules.js +1 -1
  60. package/dist/core/engine/signals.d.ts +11 -1
  61. package/dist/core/engine/signals.js +11 -7
  62. package/dist/core/engine/sleep-timer-acknowledgements.d.ts +68 -0
  63. package/dist/core/engine/sleep-timer-acknowledgements.js +19 -0
  64. package/dist/core/engine/storage-io.d.ts +58 -10
  65. package/dist/core/engine/storage-io.js +27 -9
  66. package/dist/core/engine/termination/cleanup.d.ts +5 -4
  67. package/dist/core/engine/termination/complete.js +23 -4
  68. package/dist/core/engine/termination/finalizer-claim.js +4 -4
  69. package/dist/core/engine/termination/state-commit-callbacks.d.ts +15 -0
  70. package/dist/core/engine/termination/state-commit-callbacks.js +0 -0
  71. package/dist/core/engine/termination/suspend.js +1 -1
  72. package/dist/core/engine/update-validation.d.ts +28 -0
  73. package/dist/core/engine/update-validation.js +37 -0
  74. package/dist/core/engine/updates.d.ts +17 -16
  75. package/dist/core/engine/updates.js +24 -47
  76. package/dist/core/engine/wake-ownership-check.d.ts +86 -0
  77. package/dist/core/engine/wake-ownership-check.js +19 -0
  78. package/dist/core/engine/wake-ownership-guard.d.ts +46 -0
  79. package/dist/core/engine/wake-ownership-guard.js +23 -0
  80. package/dist/core/engine/workflow-claim-codec.d.ts +64 -0
  81. package/dist/core/engine/workflow-claim-codec.js +44 -0
  82. package/dist/core/engine/workflow-claim-cooldown.d.ts +60 -0
  83. package/dist/core/engine/workflow-claim-cooldown.js +17 -0
  84. package/dist/core/engine/workflow-claim-fold.d.ts +82 -0
  85. package/dist/core/engine/workflow-claim-fold.js +53 -0
  86. package/dist/core/engine/workflow-claim-metrics.d.ts +131 -0
  87. package/dist/core/engine/workflow-claim-metrics.js +62 -0
  88. package/dist/core/engine/workflow-claim-reclaim-scan.d.ts +96 -0
  89. package/dist/core/engine/workflow-claim-reclaim-scan.js +66 -0
  90. package/dist/core/engine/workflow-claim-reclaim-target.d.ts +107 -0
  91. package/dist/core/engine/workflow-claim-reclaim-target.js +155 -0
  92. package/dist/core/engine/workflow-claim-registry.d.ts +227 -0
  93. package/dist/core/engine/workflow-claim-registry.js +198 -0
  94. package/dist/core/engine/workflow-claim-renewal-interval.d.ts +50 -0
  95. package/dist/core/engine/workflow-claim-renewal-interval.js +132 -0
  96. package/dist/core/engine/workflow-claim-renewal-subpasses.d.ts +265 -0
  97. package/dist/core/engine/workflow-claim-renewal-subpasses.js +104 -0
  98. package/dist/core/engine/workflow-claim-renewal-task.d.ts +159 -0
  99. package/dist/core/engine/workflow-claim-renewal-task.js +65 -0
  100. package/dist/core/engine/workflow-claim-transitions.d.ts +186 -0
  101. package/dist/core/engine/workflow-claim-transitions.js +120 -0
  102. package/dist/core/inline-execution-strategy.d.ts +20 -0
  103. package/dist/core/inline-execution-strategy.js +15 -4
  104. package/dist/core/scheduler/scheduler-class.js +3 -3
  105. package/dist/core/scheduler/timer-sources.d.ts +13 -0
  106. package/dist/core/scheduler/timer-sources.js +1 -1
  107. package/dist/core/types/options.d.ts +17 -1
  108. package/dist/core/weft-error.d.ts +1 -1
  109. package/dist/core/weft-error.js +2 -1
  110. package/dist/http.js +2 -2
  111. package/dist/index.d.ts +2 -1
  112. package/dist/index.js +8 -0
  113. package/dist/indexeddb.js +1 -1
  114. package/dist/json-schema.js +3 -3
  115. package/dist/observability/index.js +2 -2
  116. package/dist/server/operations/get-task-diagnostics.d.ts +115 -25
  117. package/dist/server/operations/get-task-diagnostics.js +93 -7
  118. package/dist/storage/auto.js +1 -1
  119. package/dist/storage/bun-sql.js +115 -2
  120. package/dist/storage/compressed-storage.js +1 -1
  121. package/dist/storage/http.js +1 -1
  122. package/dist/storage/index.d.ts +38 -35
  123. package/dist/storage/interface.d.ts +87 -125
  124. package/dist/storage/interface.js +1 -1
  125. package/dist/storage/key-encoding.d.ts +51 -0
  126. package/dist/storage/key-encoding.js +13 -0
  127. package/dist/storage/key-prefixes.d.ts +1 -1
  128. package/dist/storage/key-prefixes.js +3 -0
  129. package/dist/storage/lmdb.js +1 -1
  130. package/dist/storage/memory.js +1 -1
  131. package/dist/storage/neon.js +4 -4
  132. package/dist/storage/node-sqlite.js +115 -2
  133. package/dist/storage/ownership-keys.d.ts +55 -0
  134. package/dist/storage/ownership-keys.js +6 -0
  135. package/dist/storage/postgres.js +4 -4
  136. package/dist/storage/resolve.js +1 -1
  137. package/dist/storage/scoped-storage.js +1 -1
  138. package/dist/storage/testing.js +1 -1
  139. package/dist/storage/turso.js +2 -2
  140. package/dist/version.d.ts +1 -1
  141. package/dist/version.js +1 -1
  142. package/dist/web-extension.js +1 -1
  143. package/dist/worker/protocol.js +1 -1
  144. package/package.json +3 -2
@@ -1,9 +1,11 @@
1
1
  import { UpdateCompletedEvent, UpdateReceivedEvent } from "../events.js";
2
- import { isGeneratorResult } from "../step-context.js";
3
- import { UpdateValidationError } from "../updates.js";
4
2
  import { notifyConditionWaiters } from "./condition-waiters.js";
3
+ import { invokeUpdateHandler } from "./invoke-update-handler.js";
4
+ import { isLiveContextStale, isWorkflowClaimedByAnotherEngine } from "./queries.js";
5
5
  import { trackWaiterKey, untrackWaiterKey } from "./signals.js";
6
+ import { runUpdateValidator } from "./update-validation.js";
6
7
  import { waitForUpdateResponse } from "./waiting-update-response.js";
8
+ import { confirmWakeOwnership } from "./wake-ownership-guard.js";
7
9
  export async function update(internals, workflowId, name, payload, options, callbacks) {
8
10
  const timeout = options?.timeout ?? 30000;
9
11
  await runUpdateValidator(internals, workflowId, name, payload);
@@ -11,19 +13,32 @@ export async function update(internals, workflowId, name, payload, options, call
11
13
  const inlineResult = await tryInlineUpdateHandler(internals, workflowId, name, payload, callbacks);
12
14
  if (inlineResult.handled)
13
15
  return inlineResult.value;
16
+ if (inlineResult.reason === "not-owned-locally")
17
+ return await runCoordinatedUpdate(internals, workflowId, name, payload, timeout, callbacks);
14
18
  const waitingResult = await tryWaitingUpdateHandler(internals, workflowId, name, payload, timeout, callbacks);
15
19
  if (waitingResult.handled)
16
20
  return waitingResult.value;
17
21
  return await runCoordinatedUpdate(internals, workflowId, name, payload, timeout, callbacks);
18
22
  }
19
- async function tryInlineUpdateHandler(internals, workflowId, name, payload, callbacks) {
23
+ function refreshUpdateHandlerAfterAwait(internals, workflowId, name, capturedHandler, stale) {
24
+ if (stale === !1)
25
+ return capturedHandler;
26
+ return internals.inlineStrategy?.getContext(workflowId)?.updateHandlers.get(name);
27
+ }
28
+ export async function tryInlineUpdateHandler(internals, workflowId, name, payload, callbacks) {
20
29
  const handler = internals.inlineStrategy?.getContext(workflowId)?.updateHandlers.get(name);
21
30
  if (!handler)
22
- return { handled: !1 };
31
+ return { handled: !1, reason: await isWorkflowClaimedByAnotherEngine(internals, workflowId) ? "not-owned-locally" : "no-handler" };
32
+ const stale = isLiveContextStale(internals, workflowId);
33
+ if (stale !== !1 && await stale)
34
+ return { handled: !1, reason: "not-owned-locally" };
35
+ const liveHandler = refreshUpdateHandlerAfterAwait(internals, workflowId, name, handler, stale);
36
+ if (!liveHandler)
37
+ return { handled: !1, reason: "no-handler" };
23
38
  const updateId = crypto.randomUUID();
24
39
  callbacks.dispatchEvent(new UpdateReceivedEvent(updateId, workflowId, name, payload));
25
40
  try {
26
- const result = await invokeUpdateHandler(internals, name, handler, payload);
41
+ const result = await invokeUpdateHandler(internals, name, liveHandler, payload);
27
42
  callbacks.dispatchEvent(new UpdateCompletedEvent(updateId, workflowId, name, result));
28
43
  callbacks.broadcast({ type: "update:completed", workflowId, updateId });
29
44
  notifyConditionWaiters(internals, workflowId);
@@ -43,6 +58,8 @@ async function tryWaitingUpdateHandler(internals, workflowId, name, payload, tim
43
58
  const existingPendingUpdate = await callbacks.findPendingUpdateByName(workflowId, name);
44
59
  if (internals.updateWaiters.get(waiterKey) !== updateWaiter || existingPendingUpdate)
45
60
  return { handled: !1 };
61
+ if (await confirmWakeOwnership(internals, workflowId, "update") === "discard")
62
+ return { handled: !1 };
46
63
  internals.updateWaiters.delete(waiterKey);
47
64
  untrackWaiterKey(internals.updateWaitersByWorkflow, workflowId, waiterKey);
48
65
  const updateId = crypto.randomUUID();
@@ -152,6 +169,8 @@ export async function deliverCoordinatedUpdateToWaiterIfAvailable(internals, wor
152
169
  const oldestPendingUpdate = await callbacks.findPendingUpdateByName(workflowId, updateRequest.name);
153
170
  if (!oldestPendingUpdate || oldestPendingUpdate.updateId !== updateRequest.updateId)
154
171
  return !1;
172
+ if (await confirmWakeOwnership(internals, workflowId, "update") === "discard")
173
+ return !1;
155
174
  await internals.updateCoordinator.deleteRequest(workflowId, updateRequest.updateId);
156
175
  internals.updateWaiters.delete(waiterKey);
157
176
  untrackWaiterKey(internals.updateWaitersByWorkflow, workflowId, waiterKey);
@@ -166,45 +185,3 @@ export async function deliverCoordinatedUpdateToWaiterIfAvailable(internals, wor
166
185
  export async function findPendingUpdateByName(internals, workflowId, name) {
167
186
  return (await internals.updateCoordinator.getPendingUpdates(workflowId)).find((updateRequest) => updateRequest.name === name);
168
187
  }
169
- export async function invokeUpdateHandler(_internals, name, handler, payload) {
170
- const result = handler(payload);
171
- if (isGeneratorResult(result))
172
- throw TypeError(`Update handler "${name}" returned a generator. Update handlers must return a plain value or a Promise, not a generator.`);
173
- return await result;
174
- }
175
- async function runUpdateValidator(internals, workflowId, name, payload) {
176
- const validator = internals.inlineStrategy?.getContext(workflowId)?.updateValidators.get(name);
177
- if (validator === void 0)
178
- return;
179
- let result;
180
- try {
181
- result = await validator(payload);
182
- } catch (error) {
183
- const message = error instanceof Error ? error.message : String(error);
184
- throw new UpdateValidationError(name, [{ message }]);
185
- }
186
- const issues = extractStandardSchemaIssues(result);
187
- if (issues !== null && issues.length > 0)
188
- throw new UpdateValidationError(name, issues);
189
- }
190
- export function extractStandardSchemaIssues(result) {
191
- if (result === null || typeof result !== "object" || !("issues" in result))
192
- return null;
193
- const { issues } = result;
194
- if (!Array.isArray(issues))
195
- return null;
196
- return issues.flatMap((issue) => {
197
- if (issue === null || typeof issue !== "object")
198
- return [];
199
- const obj = issue;
200
- if (typeof obj.message !== "string")
201
- return [];
202
- const entry = { message: obj.message };
203
- if (Array.isArray(obj.path) && obj.path.length > 0)
204
- entry.path = obj.path.reduce((p, seg) => {
205
- const k = seg !== null && typeof seg === "object" && "key" in seg ? String(seg.key) : String(seg);
206
- return p + "/" + k.replace(/~/g, "~0").replace(/\//g, "~1");
207
- }, "");
208
- return [entry];
209
- });
210
- }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `wakeOwnershipCheck` — the read-only re-read of `wf-owner-holder:<id>`
3
+ * specified in
4
+ * [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions)
5
+ * (the paragraph immediately after the transitions table).
6
+ *
7
+ * **Why this exists.** Under `ownership: 'workflow-lease'`, every code path
8
+ * that is about to resolve an in-memory waiter or drive a generator for a
9
+ * parked workflow — a fired sleep timer, a re-evaluated `ctx.waitUntil()`
10
+ * condition, a delivered signal, an async-activity completion/failure, a
11
+ * child workflow's termination, or a deferred inline macrotask drive — must
12
+ * first confirm THIS engine still holds the claim generation it parked the
13
+ * workflow under. Skipping that check would let a stale in-memory resolver
14
+ * from a prior generation drive a workflow this engine no longer owns.
15
+ *
16
+ * **Why the FULL generation, not just the engine id.** When an engine
17
+ * releases a workflow and later reacquires the same id, its `engineId` is
18
+ * unchanged while `epoch` names a new generation. An engine-id-only check
19
+ * would let a delayed wake or a stale in-memory resolver from the PRIOR
20
+ * generation drive the NEW one — the exact ABA hazard the epoch exists to
21
+ * close. This check therefore compares `engineId` AND `epoch` together,
22
+ * never `engineId` alone.
23
+ *
24
+ * **What a match does and does not prove.** A match is a cheap pre-check,
25
+ * not the safety mechanism itself — the durable write that follows still
26
+ * carries the epoch as a `conditionalBatch` precondition, and that CAS is
27
+ * the actual backstop. A mismatch means this engine no longer holds the
28
+ * generation it parked under: the caller must discard its in-memory
29
+ * resolver without driving the generator. This module emits
30
+ * `WeftWorkflowWakeDiscardedWarning` on every mismatch; it does not decide
31
+ * what the caller does next (that decision, and the actual discard, belong
32
+ * to the wake call sites this check is wired into — a later stage).
33
+ *
34
+ * **Scope.** This module is the check alone: read the holder record, decide
35
+ * match or discard, warn on discard. It is NOT wired into any of the wake
36
+ * call sites named above — that wiring is a later stage's responsibility.
37
+ *
38
+ * @module core/engine/wake-ownership-check
39
+ */
40
+ import { type Storage } from '../../storage/interface.ts';
41
+ import { type EmitWorkflowLeaseWarning, type WorkflowWakeKind } from './lease-deposition.ts';
42
+ /** Parameters for {@link wakeOwnershipCheck}. */
43
+ export type WakeOwnershipCheckParams = {
44
+ /** Durable storage to re-read `wf-owner-holder:<workflowId>` from. */
45
+ storage: Storage;
46
+ /** The workflow whose parked wake is about to be resolved or driven. */
47
+ workflowId: string;
48
+ /** Which wake path is asking — folded into the discard warning for operator diagnosis. */
49
+ wakeKind: WorkflowWakeKind;
50
+ /** The `engineId` this engine parked the workflow under. */
51
+ expectedEngineId: string;
52
+ /** The `epoch` this engine parked the workflow under. */
53
+ expectedEpoch: number;
54
+ /** Operator-warning seam; defaults to `process.emitWarning` via `lease-deposition.ts`. */
55
+ warn?: EmitWorkflowLeaseWarning;
56
+ };
57
+ /** Why a re-read holder record failed to match the expected generation. */
58
+ export type WakeOwnershipDiscardReason = 'holder-absent' | 'holder-undecodable' | 'generation-mismatch';
59
+ /** Result of {@link wakeOwnershipCheck}. */
60
+ export type WakeOwnershipCheckResult = {
61
+ status: 'match';
62
+ } | {
63
+ status: 'discarded';
64
+ reason: WakeOwnershipDiscardReason;
65
+ /** The `engineId` actually found on re-read, or `null` when the holder was absent/undecodable. */
66
+ observedEngineId: string | null;
67
+ /** The `epoch` actually found on re-read, or `null` when the holder was absent/undecodable. */
68
+ observedEpoch: number | null;
69
+ };
70
+ /**
71
+ * Re-read `wf-owner-holder:<workflowId>` and compare it against the full
72
+ * generation (`engineId` + `epoch`) this engine parked the workflow under.
73
+ *
74
+ * Returns `{ status: 'match' }` when both fields agree — the caller may
75
+ * proceed to resolve its in-memory waiter or drive the generator (the
76
+ * following durable write still carries the epoch as the real backstop).
77
+ * Returns `{ status: 'discarded', reason }` and emits
78
+ * `WeftWorkflowWakeDiscardedWarning` in every other case: the holder key is
79
+ * absent (`'holder-absent'`), the stored bytes do not decode to a valid
80
+ * holder record (`'holder-undecodable'`), or a holder record was found but
81
+ * its `engineId`/`epoch` do not both match what was expected
82
+ * (`'generation-mismatch'`) — including the stale-generation case where
83
+ * `engineId` matches but `epoch` does not, the exact hazard this check
84
+ * exists to close.
85
+ */
86
+ export declare function wakeOwnershipCheck(params: WakeOwnershipCheckParams): Promise<WakeOwnershipCheckResult>;
@@ -0,0 +1,19 @@
1
+ import { KEYS } from "../../storage/interface.js";
2
+ import {
3
+ emitWorkflowWakeDiscardedWarning
4
+ } from "./lease-deposition.js";
5
+ import { decodeWorkflowClaimHolder } from "./workflow-claim-codec.js";
6
+ export async function wakeOwnershipCheck(params) {
7
+ const { storage, workflowId, wakeKind, expectedEngineId, expectedEpoch, warn } = params, discard = (reason, observedEngineId, observedEpoch) => {
8
+ emitWorkflowWakeDiscardedWarning(workflowId, wakeKind, warn);
9
+ return { status: "discarded", reason, observedEngineId, observedEpoch };
10
+ }, raw = await storage.get(KEYS.workflowOwnerHolder(workflowId));
11
+ if (raw === null)
12
+ return discard("holder-absent", null, null);
13
+ const holder = decodeWorkflowClaimHolder(raw);
14
+ if (holder === null)
15
+ return discard("holder-undecodable", null, null);
16
+ if (holder.engineId !== expectedEngineId || holder.epoch !== expectedEpoch)
17
+ return discard("generation-mismatch", holder.engineId, holder.epoch);
18
+ return { status: "match" };
19
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `confirmWakeOwnership` — the single call-site helper every claim-requiring
3
+ * wake path (sleep, wait-condition, async-activity, inline-macrotask-drive)
4
+ * uses to run `wakeOwnershipCheck` before resolving an in-memory waiter or
5
+ * driving a generator for a parked workflow, per
6
+ * [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions).
7
+ *
8
+ * **Why a wrapper instead of calling `wakeOwnershipCheck` directly at each
9
+ * site.** `wakeOwnershipCheck` requires a non-null `expectedEngineId` /
10
+ * `expectedEpoch` pair — it has no opinion on how a caller obtains those. Every
11
+ * wake site needs the exact same three-way decision tree first:
12
+ *
13
+ * - `internals.workflowClaimRegistry` is `null` (ownership is `'none'` or
14
+ * `'lease'`): this check is a no-op. Proceed — byte-identical to every
15
+ * pre-ADR-0002 deployment. This is the property every wake site must
16
+ * preserve above all else.
17
+ * - The registry is present but tracks no epoch for this workflow id
18
+ * (`currentEpoch(workflowId)` is `null` — e.g. a `renew()` loss already
19
+ * dropped this engine's local claim entry): there is nothing to compare
20
+ * against, so this is itself a discard. `wakeOwnershipCheck` cannot be
21
+ * called with a null epoch, so this module emits
22
+ * `WeftWorkflowWakeDiscardedWarning` itself rather than skipping the
23
+ * operator diagnostic.
24
+ * - The registry tracks an epoch: re-read the durable holder record and
25
+ * compare the full generation via `wakeOwnershipCheck`.
26
+ *
27
+ * **A thrown storage read is `'proceed'`, not `'discard'`.** This helper is a
28
+ * cheap pre-check; the epoch-conditioned durable write that follows every
29
+ * proceed path is the real backstop (`EngineDeposedError` if it has actually
30
+ * lost the claim). A storage blip during the pre-check must not permanently
31
+ * strand a wake — a fired sleep timer or a signalled `waitUntil` deadline is
32
+ * not retried — so a thrown read is treated the same as a match and the
33
+ * caller proceeds to its normal (already-fenced) write path.
34
+ *
35
+ * @module core/engine/wake-ownership-guard
36
+ */
37
+ import type { EngineInternals } from './internals.ts';
38
+ import { type WorkflowWakeKind } from './lease-deposition.ts';
39
+ /** Outcome of {@link confirmWakeOwnership}: whether the caller may proceed with this wake. */
40
+ export type WakeOwnershipDecision = 'proceed' | 'discard';
41
+ /**
42
+ * Decide whether a claim-requiring wake path may resolve its in-memory
43
+ * waiter / drive its generator for `workflowId`. See the module doc for the
44
+ * three-way decision tree and the thrown-read policy.
45
+ */
46
+ export declare function confirmWakeOwnership(internals: EngineInternals, workflowId: string, wakeKind: WorkflowWakeKind): Promise<WakeOwnershipDecision>;
@@ -0,0 +1,23 @@
1
+ import { emitWorkflowWakeDiscardedWarning } from "./lease-deposition.js";
2
+ import { wakeOwnershipCheck } from "./wake-ownership-check.js";
3
+ export async function confirmWakeOwnership(internals, workflowId, wakeKind) {
4
+ const registry = internals.workflowClaimRegistry;
5
+ if (registry === null)
6
+ return "proceed";
7
+ const expectedEpoch = registry.currentEpoch(workflowId);
8
+ if (expectedEpoch === null) {
9
+ emitWorkflowWakeDiscardedWarning(workflowId, wakeKind);
10
+ return "discard";
11
+ }
12
+ try {
13
+ return (await wakeOwnershipCheck({
14
+ storage: internals.storage,
15
+ workflowId,
16
+ wakeKind,
17
+ expectedEngineId: registry.engineId,
18
+ expectedEpoch
19
+ })).status === "match" ? "proceed" : "discard";
20
+ } catch {
21
+ return "proceed";
22
+ }
23
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure codecs for the per-workflow ownership claim keys (`wf-owner-epoch:<id>`
3
+ * and `wf-owner-holder:<id>`) and for the store-wide `ownership-mode-marker`.
4
+ *
5
+ * These back the `ownership: 'workflow-lease'` mode described in
6
+ * [ADR 0002](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md).
7
+ * The epoch codec itself is shared with the global lease — `encodeEpoch` and
8
+ * `decodeEpoch` are imported from `lease-codec.ts` rather than reimplemented, so
9
+ * both fencing tokens stay byte-identical in representation.
10
+ *
11
+ * Only the holder record differs: the global lease holds three fields for one
12
+ * store-wide lease, while a per-workflow claim holds four, adding `claimedAt`
13
+ * for operator visibility and carrying `engineId` rather than `holderId`.
14
+ *
15
+ * Every decoder is fail-closed: any structurally invalid, foreign, or corrupt
16
+ * value decodes to `null` rather than throwing, matching `lease-codec.ts`.
17
+ *
18
+ * @module core/engine/workflow-claim-codec
19
+ */
20
+ import { decodeEpoch, encodeEpoch } from './lease-codec.ts';
21
+ export { decodeEpoch, encodeEpoch };
22
+ /** The ownership modes that fence engine work and therefore stamp the store-wide marker. */
23
+ export type FencingOwnershipMode = 'lease' | 'workflow-lease';
24
+ /** The decoded `wf-owner-holder:<workflowId>` record. */
25
+ export type WorkflowClaimHolderRecord = {
26
+ /** Identity of the owning engine process, minted once per engine. */
27
+ engineId: string;
28
+ /** The claim generation this holder owns. Mirrors `wf-owner-epoch:<workflowId>`. */
29
+ epoch: number;
30
+ /** Engine-clock ms after which the claim becomes eligible for takeover. */
31
+ expiresAt: number;
32
+ /** Engine-clock ms when this epoch was first claimed. Unchanged across renewals. */
33
+ claimedAt: number;
34
+ };
35
+ /** The decoded store-wide `ownership-mode-marker` record. */
36
+ export type OwnershipModeMarkerRecord = {
37
+ /** The fencing mode the first fencing-mode engine stamped on this store. */
38
+ mode: FencingOwnershipMode;
39
+ /** Engine-clock ms when the marker was established. Diagnostics only. */
40
+ establishedAt: number;
41
+ };
42
+ /** Encode a per-workflow claim holder record to its stored JSON bytes. */
43
+ export declare function encodeWorkflowClaimHolder(record: WorkflowClaimHolderRecord): Uint8Array;
44
+ /**
45
+ * Decode a stored per-workflow claim holder record, tolerating any
46
+ * malformed or foreign value as `null`.
47
+ *
48
+ * An empty `engineId` is rejected: it can never match a real engine's identity,
49
+ * so admitting it would produce a holder that no engine can renew or release.
50
+ */
51
+ export declare function decodeWorkflowClaimHolder(raw: Uint8Array): WorkflowClaimHolderRecord | null;
52
+ /** Encode the store-wide ownership-mode marker to its stored JSON bytes. */
53
+ export declare function encodeOwnershipModeMarker(record: OwnershipModeMarkerRecord): Uint8Array;
54
+ /**
55
+ * Decode the store-wide ownership-mode marker, tolerating any malformed or
56
+ * foreign value as `null`.
57
+ *
58
+ * An unrecognized `mode` decodes to `null` rather than being preserved. The
59
+ * marker exists to make a mode mismatch detectable, and a mode this build does
60
+ * not understand cannot be compared meaningfully; treating it as absent lets the
61
+ * reader fail closed on its own terms instead of comparing against a string it
62
+ * cannot interpret.
63
+ */
64
+ export declare function decodeOwnershipModeMarker(raw: Uint8Array): OwnershipModeMarkerRecord | null;
@@ -0,0 +1,44 @@
1
+ import { decodeEpoch, encodeEpoch } from "./lease-codec.js";
2
+
3
+ export { decodeEpoch, encodeEpoch };
4
+ const textEncoder = new TextEncoder, textDecoder = new TextDecoder;
5
+ function isRecord(value) {
6
+ return typeof value === "object" && value !== null;
7
+ }
8
+ function isUsableTimestamp(value) {
9
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
10
+ }
11
+ function isUsableEpoch(value) {
12
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value < Number.MAX_SAFE_INTEGER;
13
+ }
14
+ function parseJsonBytes(raw) {
15
+ try {
16
+ return JSON.parse(textDecoder.decode(raw));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function encodeWorkflowClaimHolder(record) {
22
+ return textEncoder.encode(JSON.stringify(record));
23
+ }
24
+ export function decodeWorkflowClaimHolder(raw) {
25
+ const parsed = parseJsonBytes(raw);
26
+ if (!isRecord(parsed))
27
+ return null;
28
+ const { engineId, epoch, expiresAt, claimedAt } = parsed;
29
+ if (typeof engineId !== "string" || engineId.length === 0 || !isUsableEpoch(epoch) || !isUsableTimestamp(expiresAt) || !isUsableTimestamp(claimedAt))
30
+ return null;
31
+ return { engineId, epoch, expiresAt, claimedAt };
32
+ }
33
+ export function encodeOwnershipModeMarker(record) {
34
+ return textEncoder.encode(JSON.stringify(record));
35
+ }
36
+ export function decodeOwnershipModeMarker(raw) {
37
+ const parsed = parseJsonBytes(raw);
38
+ if (!isRecord(parsed))
39
+ return null;
40
+ const { mode, establishedAt } = parsed;
41
+ if (mode !== "lease" && mode !== "workflow-lease" || !isUsableTimestamp(establishedAt))
42
+ return null;
43
+ return { mode, establishedAt };
44
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-workflow-id anti-thrash takeover cooldown, specified in
3
+ * [ADR 0002 § Ownership transitions](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#ownership-transitions)
4
+ * (the `takeover` row's "A per-workflow-id anti-thrash cooldown" sentence).
5
+ *
6
+ * **The problem this dampens.** Without it, a reclaim scan and the engine
7
+ * that just lost a claim can thrash: this engine loses `workflowId` via a
8
+ * failed `renew`, its own recurring reclaim scan later judges the same
9
+ * holder stale (a genuine race — the deposing engine can itself stall or
10
+ * crash right after depositing this one), and this engine immediately steals
11
+ * it back, only to lose it again the same way. The cooldown makes that loop
12
+ * back off instead of spinning.
13
+ *
14
+ * **Shape.** In-memory, per-engine-process, keyed by workflow id — not
15
+ * durable and not shared across engines; a restart forgets every cooldown,
16
+ * which is safe, since a fresh process has not just been deposed from
17
+ * anything yet. `WorkflowClaimRegistry` owns one instance and drives it from
18
+ * exactly three points: a failed `renew` calls {@link recordDeposition}, a
19
+ * successful `acquire`/`takeover`/folded-acquire calls {@link clear}, and
20
+ * `takeover` consults {@link isActive} before attempting its CAS.
21
+ *
22
+ * **Curve.** A fixed window of `workflowClaimRenewInterval`, not exponential.
23
+ * Through `WorkflowClaimRegistry`'s real call graph, a second
24
+ * `recordDeposition` for the same id is only reachable after an intervening
25
+ * successful (re)acquire has already {@link clear}ed the tracked entry — this
26
+ * class never observes consecutive depositions for the same id without a
27
+ * clear between them, so a growing window would never actually grow. An
28
+ * exponential curve was tried here and found unreachable in review; keep this
29
+ * simple until a call-graph change (preserving loss history across successful
30
+ * reacquisition) makes a growing window meaningful.
31
+ *
32
+ * @module core/engine/workflow-claim-cooldown
33
+ */
34
+ /** Options for {@link WorkflowClaimTakeoverCooldown}. Mirrors `WorkflowClaimRegistryOptions`'s same-named field, so the caller need not compute the window itself. */
35
+ export type WorkflowClaimTakeoverCooldownOptions = {
36
+ /** Cooldown window (ms) — `workflowClaimRenewInterval`. */
37
+ claimRenewIntervalMs: number;
38
+ };
39
+ /**
40
+ * Tracks one engine's per-workflow-id takeover cooldown. See the module doc
41
+ * for the mechanism and curve.
42
+ */
43
+ export declare class WorkflowClaimTakeoverCooldown {
44
+ #private;
45
+ constructor(options: WorkflowClaimTakeoverCooldownOptions);
46
+ /**
47
+ * Whether `workflowId` is currently within its cooldown window at `now`.
48
+ * The boundary itself (`now === untilMs`) is NOT active — matches
49
+ * `isWorkflowClaimExpired`'s "strictly earlier" convention for the sibling
50
+ * expiry judgment.
51
+ */
52
+ isActive(workflowId: string, now: number): boolean;
53
+ /**
54
+ * Record that this engine just lost ownership of `workflowId` via a failed
55
+ * `renew`. Starts (or restarts) a fixed-length cooldown window.
56
+ */
57
+ recordDeposition(workflowId: string, now: number): void;
58
+ /** Clear any tracked cooldown for `workflowId` — call on a successful (re)acquire or takeover. */
59
+ clear(workflowId: string): void;
60
+ }
@@ -0,0 +1,17 @@
1
+ export class WorkflowClaimTakeoverCooldown {
2
+ #windowMs;
3
+ #untilMsByWorkflowId = new Map;
4
+ constructor(options) {
5
+ this.#windowMs = options.claimRenewIntervalMs;
6
+ }
7
+ isActive(workflowId, now) {
8
+ const untilMs = this.#untilMsByWorkflowId.get(workflowId);
9
+ return untilMs !== void 0 && now < untilMs;
10
+ }
11
+ recordDeposition(workflowId, now) {
12
+ this.#untilMsByWorkflowId.set(workflowId, now + this.#windowMs);
13
+ }
14
+ clear(workflowId) {
15
+ this.#untilMsByWorkflowId.delete(workflowId);
16
+ }
17
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared "fold `acquire` into an enabling write" helper for ADR 0002's
3
+ * CLAIM-ACQUIRING entry points this stage owns: workflow start
4
+ * (`lifecycle/start-commit.ts` — the create batch), delayed-start timer fire
5
+ * (`operations-time.ts`), and bulk retry reactivation (`bulk-operations.ts`).
6
+ * Scheduled runs and child-workflow launches funnel through `startWorkflow`
7
+ * (`lifecycle/start.ts`) and inherit the fold from there — see
8
+ * [ADR 0002 § Entry point classification](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#entry-point-classification),
9
+ * the `startWorkflow`/`buildAndCommitStartBatch` row, which explicitly groups
10
+ * `ctx.startChild` and `startScheduledRun` with it.
11
+ *
12
+ * A claim-acquiring write establishes a claim THIS commit has never held
13
+ * before, so it can never be fenced through `commitFencedEngineWrite` (which
14
+ * requires an ALREADY-tracked epoch in `EngineInternals.workflowClaimRegistry`
15
+ * — see `fenced-write.ts`'s `fencedCommitForWorkflow`). Instead, the claim's
16
+ * OWN `acquire` conditions (holder absent, epoch matches what was just read)
17
+ * ARE the fence for this first write: {@link prepareWorkflowClaimFold} reads
18
+ * fresh `wf-owner-epoch:<id>` bytes and builds the pure acquire fragment via
19
+ * `WorkflowClaimRegistry.prepareAcquireFragment`, the caller merges the
20
+ * fragment's conditions/operations into its own enabling write, and
21
+ * {@link commitWithWorkflowClaimFold} commits ONE atomic
22
+ * `storageConditionalBatch`. Only after that commit succeeds does it call
23
+ * `WorkflowClaimRegistry.recordFoldedAcquire`, installing the tracking entry
24
+ * every later `commitFencedEngineWrite` for this workflow needs.
25
+ *
26
+ * Under `ownership: 'none'`/`'lease'`, or `'workflow-lease'` with no registry
27
+ * constructed yet (Gate 1/Gate 2 wiring is a parallel/later stage) —
28
+ * {@link prepareWorkflowClaimFold} returns `undefined` and every caller's
29
+ * pre-existing code path runs byte-for-byte unchanged. It also returns
30
+ * `undefined` when this engine already tracks a claim for `workflowId`: bulk
31
+ * retry folds `acquire` into its own reactivation write, then calls
32
+ * `engine.resume()`, which must not attempt a second acquire for the same
33
+ * workflow (see `lifecycle/resume.ts`).
34
+ *
35
+ * @module core/engine/workflow-claim-fold
36
+ */
37
+ import { type BatchOperation, type ConditionalBatchCondition } from '../../storage/interface.ts';
38
+ import type { EngineInternals } from './internals.ts';
39
+ import type { WorkflowClaimAcquirePreparation, WorkflowClaimRegistry } from './workflow-claim-registry.ts';
40
+ /** A prepared, not-yet-committed claim-acquire fold for one workflow id. */
41
+ export type WorkflowClaimFold = {
42
+ registry: WorkflowClaimRegistry;
43
+ workflowId: string;
44
+ preparation: WorkflowClaimAcquirePreparation;
45
+ conditions: ConditionalBatchCondition[];
46
+ operations: BatchOperation[];
47
+ };
48
+ /**
49
+ * Prepare a claim-acquire fold for `workflowId`, or `undefined` when folding
50
+ * does not apply — see the module doc for the three cases that return
51
+ * `undefined`. Call again on every retry attempt of the caller's own
52
+ * enabling-write loop; never reuse a preparation across attempts, since a
53
+ * stale epoch read dooms the retry's CAS.
54
+ */
55
+ export declare function prepareWorkflowClaimFold(internals: EngineInternals, workflowId: string): Promise<WorkflowClaimFold | undefined>;
56
+ /** Outcome of {@link commitWithWorkflowClaimFold}. */
57
+ export type WorkflowClaimFoldCommitResult = {
58
+ status: 'committed';
59
+ } | {
60
+ status: 'lost-race';
61
+ /** Whether the fold's OWN conditions (not the caller's) were the ones that lost the CAS. */
62
+ claimConflict: boolean;
63
+ };
64
+ /**
65
+ * Commit `operations`/`conditions` merged with `fold`'s own acquire
66
+ * fragment, as ONE atomic `storageConditionalBatch`. On success, installs
67
+ * the fold's tracking entry via `WorkflowClaimRegistry.recordFoldedAcquire`.
68
+ * On a lost CAS, re-reads the fold's own conditions to report whether THEY
69
+ * were the ones that failed — distinct from the caller's own conditions
70
+ * (idempotency mapping, workflow-concurrency admission) — so the caller can
71
+ * raise `WorkflowClaimUnavailableError` instead of misreporting a claim loss
72
+ * as an unrelated precondition conflict, or vice versa.
73
+ */
74
+ export declare function commitWithWorkflowClaimFold(internals: EngineInternals, fold: WorkflowClaimFold, operations: BatchOperation[], conditions: ConditionalBatchCondition[], featureName: string): Promise<WorkflowClaimFoldCommitResult>;
75
+ /**
76
+ * Raise `WorkflowClaimUnavailableError` for `workflowId`, re-reading its
77
+ * current holder for the `heldBy` field — the shared shape every explicit,
78
+ * single-workflow claim-acquiring call site (start, bulk retry reactivation)
79
+ * uses on a confirmed claim conflict, instead of each repeating the
80
+ * re-read-then-throw.
81
+ */
82
+ export declare function throwWorkflowClaimUnavailable(internals: EngineInternals, workflowId: string): Promise<never>;
@@ -0,0 +1,53 @@
1
+ import {
2
+ KEYS,
3
+ requireStorageCapability,
4
+ storageConditionalBatch,
5
+ storageValuesEqual
6
+ } from "../../storage/interface.js";
7
+ import { WorkflowClaimUnavailableError } from "./lease-errors.js";
8
+ import { decodeWorkflowClaimHolder } from "./workflow-claim-codec.js";
9
+ export async function prepareWorkflowClaimFold(internals, workflowId) {
10
+ if (internals.options.ownershipMode !== "workflow-lease")
11
+ return;
12
+ const registry = internals.workflowClaimRegistry;
13
+ if (registry === null)
14
+ return;
15
+ if (registry.currentEpoch(workflowId) !== null)
16
+ return;
17
+ const preparation = await registry.prepareAcquireFragment(workflowId);
18
+ return {
19
+ registry,
20
+ workflowId,
21
+ preparation,
22
+ conditions: preparation.fragment.conditions,
23
+ operations: preparation.fragment.operations
24
+ };
25
+ }
26
+ export async function commitWithWorkflowClaimFold(internals, fold, operations, conditions, featureName) {
27
+ requireStorageCapability(internals.storage, "conditionalBatch", featureName);
28
+ if (await storageConditionalBatch(internals.storage, [...conditions, ...fold.conditions], [...operations, ...fold.operations])) {
29
+ fold.registry.recordFoldedAcquire(fold.workflowId, fold.preparation);
30
+ return { status: "committed" };
31
+ }
32
+ return {
33
+ status: "lost-race",
34
+ claimConflict: await hasWorkflowClaimFoldConflict(internals, fold)
35
+ };
36
+ }
37
+ async function hasWorkflowClaimFoldConflict(internals, fold) {
38
+ for (const condition of fold.conditions) {
39
+ const currentValue = await internals.storage.get(condition.key);
40
+ if (!storageValuesEqual(currentValue, condition.expectedValue))
41
+ return !0;
42
+ }
43
+ return !1;
44
+ }
45
+ async function resolveWorkflowClaimHolder(internals, workflowId) {
46
+ const raw = await internals.storage.get(KEYS.workflowOwnerHolder(workflowId));
47
+ if (raw === null)
48
+ return null;
49
+ return decodeWorkflowClaimHolder(raw)?.engineId ?? null;
50
+ }
51
+ export async function throwWorkflowClaimUnavailable(internals, workflowId) {
52
+ throw new WorkflowClaimUnavailableError(workflowId, await resolveWorkflowClaimHolder(internals, workflowId));
53
+ }