@lostgradient/weft 0.19.0 → 0.21.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 (146) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/generated/operation-catalog.snapshot.json +1334 -0
  3. package/dist/cli/generated/operation-client.generated.d.ts +9 -2
  4. package/dist/cli/generated/operation-client.generated.js +2 -0
  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-detail-schema.d.ts +341 -0
  117. package/dist/server/operations/get-task-detail-schema.js +118 -0
  118. package/dist/server/operations/get-task-detail.d.ts +346 -0
  119. package/dist/server/operations/get-task-detail.js +215 -0
  120. package/dist/server/operations/static-registrations.js +3 -0
  121. package/dist/storage/auto.js +1 -1
  122. package/dist/storage/bun-sql.js +113 -0
  123. package/dist/storage/compressed-storage.js +1 -1
  124. package/dist/storage/index.d.ts +38 -35
  125. package/dist/storage/interface.d.ts +87 -125
  126. package/dist/storage/interface.js +1 -1
  127. package/dist/storage/key-encoding.d.ts +51 -0
  128. package/dist/storage/key-encoding.js +13 -0
  129. package/dist/storage/key-prefixes.d.ts +1 -1
  130. package/dist/storage/key-prefixes.js +3 -0
  131. package/dist/storage/lmdb.js +1 -1
  132. package/dist/storage/memory.js +1 -1
  133. package/dist/storage/neon.js +2 -2
  134. package/dist/storage/node-sqlite.js +113 -0
  135. package/dist/storage/ownership-keys.d.ts +55 -0
  136. package/dist/storage/ownership-keys.js +6 -0
  137. package/dist/storage/postgres.js +2 -2
  138. package/dist/storage/resolve.js +1 -1
  139. package/dist/storage/scoped-storage.js +1 -1
  140. package/dist/storage/testing.js +1 -1
  141. package/dist/storage/turso.js +2 -2
  142. package/dist/version.d.ts +1 -1
  143. package/dist/version.js +1 -1
  144. package/dist/web-extension.js +1 -1
  145. package/dist/worker/protocol.js +1 -1
  146. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { type BatchOperation } from '../../storage/interface.ts';
2
2
  import type { ScheduleState, WorkflowState } from '../types.ts';
3
3
  import type { EngineInternals } from './internals.ts';
4
+ import { type WorkflowClaimTransitionFragment } from './workflow-claim-transitions.ts';
4
5
  /** Run a workflow-state write after any earlier write for that workflow has settled. */
5
6
  export declare function runSerializedWorkflowStateWrite<TResult>(internals: EngineInternals, workflowId: string, writeOperation: () => Promise<TResult>): Promise<TResult>;
6
7
  /**
@@ -15,23 +16,70 @@ export declare function runSerializedWorkflowStateWrite<TResult>(internals: Engi
15
16
  export declare function runSerializedScheduleStateOperation<TResult>(internals: EngineInternals, scheduleId: string, operation: () => Promise<TResult>): Promise<TResult>;
16
17
  /** Load and decode persisted workflow state by workflow ID. */
17
18
  export declare function loadWorkflowState(internals: EngineInternals, workflowId: string): Promise<WorkflowState | null>;
19
+ /**
20
+ * Derive a terminal result (or throw the persisted terminal error) from an
21
+ * ALREADY-LOADED `WorkflowState` — pure, no storage read. Callers that hold a
22
+ * state snapshot they have already validated as terminal (e.g.
23
+ * `bootstrapWorkflowResultResolver`) must use this instead of
24
+ * {@link loadWorkflowResult}: a second independent `loadWorkflowState` read
25
+ * can observe a DIFFERENT run than the one the caller validated, if
26
+ * `onTerminalConflict: 'start-new'` replaces the workflow between the two
27
+ * reads — attributing a replacement run's result (or a spurious "still
28
+ * running") to a waiter that was made terminal by the original run.
29
+ */
30
+ export declare function deriveWorkflowResultFromState(state: WorkflowState): unknown;
18
31
  /** Load a terminal workflow result or throw the persisted terminal error. */
19
32
  export declare function loadWorkflowResult(internals: EngineInternals, workflowId: string): Promise<unknown>;
20
33
  type WorkflowStateCommitOptions = {
21
34
  includePendingAtomicSideEffects?: boolean;
22
35
  };
23
36
  /**
24
- * Commit an engine-generator-owned workflow-state advance, FENCED on the lease
25
- * epoch under `ownership: 'lease'` (issue #470 Step 2). A deposed engine's write
26
- * loses its CAS instead of corrupting the successor's state; the deposition is
27
- * detected and the engine halts (see {@link commitFencedEngineWrite}). Under
28
- * `ownership: 'none'` it is byte-for-byte the pre-Step-2 commit shape the epoch
29
- * condition is only appended when a lease is held. Use this for suspend,
30
- * completion, and other state advances driven by the workflow lifecycle. Operator/
31
- * external mutations (search-attribute and tag edits) do NOT use this helper; they
32
- * batch directly and are intentionally never fenced.
37
+ * Build the `wf-owner-epoch:<id>` / `wf-owner-holder:<id>` ROTATION fragment
38
+ * for an EXTERNAL terminal transition cancel, timeout, suspend, purge (ADR
39
+ * 0002 § "External terminal transitions must rotate the epoch"). Any engine
40
+ * may commit these against a workflow it does not own; deleting the holder
41
+ * alone is not sufficient to make that safe, so the epoch is rotated in the
42
+ * SAME atomic batch that writes the terminal/suspended state, deposing a
43
+ * still-running owner its next write carries the now-stale epoch and loses
44
+ * its CAS. Meant to be folded (via `[...fragment.operations]` /
45
+ * `[...fragment.conditions]`) into the caller's own operations/conditions,
46
+ * never committed standalone.
47
+ *
48
+ * Under `ownership: 'none'` or `'lease'` this returns an EMPTY fragment with
49
+ * NO storage read: the `wf-owner-*` keyspace is not in play under those
50
+ * modes, and this function must not touch it — that is what keeps external
51
+ * terminal commits byte-for-byte unchanged there.
52
+ */
53
+ export declare function buildExternalTerminalRotationFragment(internals: EngineInternals, workflowId: string): Promise<WorkflowClaimTransitionFragment>;
54
+ /**
55
+ * Commit a SELF-transition workflow-state advance (complete, fail): this
56
+ * engine is finishing its OWN workflow, so the write is workflow-scoped and
57
+ * fenced on THIS engine's claim epoch under `ownership: 'workflow-lease'`
58
+ * (via {@link commitFencedEngineWrite}'s `workflowId` parameter), or the
59
+ * global lease epoch under `ownership: 'lease'`. A deposed engine's write
60
+ * loses its CAS instead of corrupting the successor's state; the deposition
61
+ * is detected and the engine (or, under `workflow-lease`, just this one
62
+ * workflow) halts. Under `ownership: 'none'` this is byte-for-byte the
63
+ * pre-ADR commit shape. Never rotates `wf-owner-epoch:<id>` — that is the
64
+ * external-transition shape below. Operator/external mutations
65
+ * (search-attribute and tag edits) do NOT use this helper; they batch
66
+ * directly and are intentionally never fenced.
67
+ */
68
+ export declare function commitSelfWorkflowStateOperations(internals: EngineInternals, state: WorkflowState, operations: BatchOperation[], options?: WorkflowStateCommitOptions): Promise<void>;
69
+ /**
70
+ * Commit an EXTERNAL terminal workflow-state transition — cancel, timeout, or
71
+ * suspend (ADR 0002 § "External terminal transitions must rotate the
72
+ * epoch"). ANY engine may commit these against a workflow it does not own, so
73
+ * — unlike {@link commitSelfWorkflowStateOperations} — this is never fenced
74
+ * on this engine's own workflow claim (`workflowId: null` is passed to
75
+ * {@link commitFencedEngineWrite}). Instead {@link
76
+ * buildExternalTerminalRotationFragment} folds a claim ROTATION into the same
77
+ * atomic batch under `ownership: 'workflow-lease'`, deposing a still-running
78
+ * owner. Under `ownership: 'lease'` the write still carries the global lease
79
+ * epoch condition (via `commitFencedEngineWrite`'s `workflowId: null` path);
80
+ * under `'none'` it is byte-for-byte unchanged.
33
81
  */
34
- export declare function commitFencedWorkflowStateOperations(internals: EngineInternals, state: WorkflowState, operations: BatchOperation[], options?: WorkflowStateCommitOptions): Promise<void>;
82
+ export declare function commitExternalTerminalWorkflowStateOperations(internals: EngineInternals, state: WorkflowState, operations: BatchOperation[], options?: WorkflowStateCommitOptions): Promise<void>;
35
83
  /** Load and decode persisted schedule state by schedule ID. */
36
84
  export declare function loadScheduleState(internals: EngineInternals, scheduleId: string): Promise<ScheduleState | null>;
37
85
  /** Load a schedule state. */
@@ -14,6 +14,9 @@ import { resolveEffectiveScheduleFireAt } from "./schedule-jitter.js";
14
14
  import { createScheduleTimerId, decodeWorkflowStartHeaders } from "./state-utilities.js";
15
15
  import { decodeWorkflowState } from "./validation.js";
16
16
  import { decodeScheduleState } from "./validation/schedule.js";
17
+ import {
18
+ buildWorkflowClaimExternalTerminalRotationTransition
19
+ } from "./workflow-claim-transitions.js";
17
20
  export async function runSerializedWorkflowStateWrite(internals, workflowId, writeOperation) {
18
21
  const execution = (internals.workflowStateWriteChains.get(workflowId) ?? Promise.resolve()).catch(() => {
19
22
  return;
@@ -51,10 +54,7 @@ export async function loadWorkflowState(internals, workflowId) {
51
54
  return null;
52
55
  return decodeWorkflowState(bytes);
53
56
  }
54
- export async function loadWorkflowResult(internals, workflowId) {
55
- const state = await loadWorkflowState(internals, workflowId);
56
- if (!state)
57
- throw Error(`Workflow "${workflowId}" not found`);
57
+ export function deriveWorkflowResultFromState(state) {
58
58
  if (state.status === "completed")
59
59
  return state.result;
60
60
  if (state.status === "failed") {
@@ -67,9 +67,15 @@ export async function loadWorkflowResult(internals, workflowId) {
67
67
  throw Error("Workflow cancelled");
68
68
  if (state.status === "timed-out") {
69
69
  const elapsed = state.executionDeadline ? state.executionDeadline - getWorkflowExecutionStartedAt(state) : 0;
70
- throw new WorkflowTimeoutError(workflowId, "execution", elapsed, state.terminationReason);
70
+ throw new WorkflowTimeoutError(state.id, "execution", elapsed, state.terminationReason);
71
71
  }
72
- throw Error(`Workflow "${workflowId}" is still ${state.status}`);
72
+ throw Error(`Workflow "${state.id}" is still ${state.status}`);
73
+ }
74
+ export async function loadWorkflowResult(internals, workflowId) {
75
+ const state = await loadWorkflowState(internals, workflowId);
76
+ if (!state)
77
+ throw Error(`Workflow "${workflowId}" not found`);
78
+ return deriveWorkflowResultFromState(state);
73
79
  }
74
80
  function buildWorkflowStateCommit(internals, workflowId, operations, options) {
75
81
  const pendingSideEffects = options.includePendingAtomicSideEffects ? takePendingAtomicWorkflowCommitSideEffects(internals, workflowId) : void 0, operationsWithSideEffects = pendingSideEffects === void 0 ? operations : [...operations, ...pendingSideEffects.operations], conditions = internals.storage.capabilities().conditionalBatch ? pendingSideEffects?.conditions ?? [] : [];
@@ -79,9 +85,21 @@ function buildWorkflowStateCommit(internals, workflowId, operations, options) {
79
85
  hasPendingSideEffects: pendingSideEffects !== void 0
80
86
  };
81
87
  }
82
- export async function commitFencedWorkflowStateOperations(internals, state, operations, options = {}) {
88
+ export async function buildExternalTerminalRotationFragment(internals, workflowId) {
89
+ if (internals.options.ownershipMode !== "workflow-lease")
90
+ return { conditions: [], operations: [] };
91
+ const observedEpochBytes = await internals.storage.get(KEYS.workflowOwnerEpoch(workflowId));
92
+ return buildWorkflowClaimExternalTerminalRotationTransition({ workflowId, observedEpochBytes });
93
+ }
94
+ export async function commitSelfWorkflowStateOperations(internals, state, operations, options = {}) {
83
95
  const commit = buildWorkflowStateCommit(internals, state.id, operations, options);
84
- await commitFencedEngineWrite(internals, commit.operations, commit.conditions, () => Error(`Workflow state commit for workflow "${state.id}" lost its atomic side-effect precondition.`));
96
+ await commitFencedEngineWrite(internals, state.id, commit.operations, commit.conditions, () => Error(`Workflow state commit for workflow "${state.id}" lost its atomic side-effect precondition.`));
97
+ if (commit.hasPendingSideEffects)
98
+ clearPendingAtomicWorkflowCommitSideEffects(internals, state.id);
99
+ }
100
+ export async function commitExternalTerminalWorkflowStateOperations(internals, state, operations, options = {}) {
101
+ const commit = buildWorkflowStateCommit(internals, state.id, operations, options), rotation = await buildExternalTerminalRotationFragment(internals, state.id);
102
+ await commitFencedEngineWrite(internals, null, [...commit.operations, ...rotation.operations], [...commit.conditions, ...rotation.conditions], () => Error(`External terminal commit for workflow "${state.id}" lost a commit precondition (staged side effects, or the ownership epoch rotation under \`workflow-lease\`).`));
85
103
  if (commit.hasPendingSideEffects)
86
104
  clearPendingAtomicWorkflowCommitSideEffects(internals, state.id);
87
105
  }
@@ -132,7 +150,7 @@ export async function writeScheduleState(internals, state, options) {
132
150
  ], includeTimer = options?.includeTimer ?? state.status === "active";
133
151
  operations.push(...buildScheduleTimerReplacementOperations(state, includeTimer, options?.replaceTimerFrom));
134
152
  operations.push(...options?.additionalOperations ?? []);
135
- await commitFencedEngineWrite(internals, operations, [], () => Error(`Schedule state commit for schedule "${state.id}" lost its precondition.`));
153
+ await commitFencedEngineWrite(internals, null, operations, [], () => Error(`Schedule state commit for schedule "${state.id}" lost its precondition.`));
136
154
  }
137
155
  export async function loadWorkflowStartHeaders(internals, workflowId) {
138
156
  const bytes = await internals.storage.get(KEYS.workflowHeaders(workflowId));
@@ -1,6 +1,6 @@
1
- import type { BatchOperation } from '../../../storage/interface.ts';
2
1
  import type { WorkflowState, WorkflowStatus } from '../../types.ts';
3
2
  import type { EngineInternals } from '../internals.ts';
3
+ import type { WorkflowStateCommitCallback } from './state-commit-callbacks.ts';
4
4
  export type TerminationCallbacks = {
5
5
  dispatchEvent: (event: Event) => void;
6
6
  forwardEventToHandle: (workflowId: string, event: Event) => void;
@@ -13,9 +13,10 @@ export type TerminationCallbacks = {
13
13
  handleScheduledWorkflowTerminal: (workflowId: string) => Promise<void>;
14
14
  loadWorkflowState: (workflowId: string) => Promise<WorkflowState | null>;
15
15
  runSerializedWorkflowStateWrite: <Result>(workflowId: string, writeOperation: () => Promise<Result>) => Promise<Result>;
16
- commitWorkflowStateOperations: (state: WorkflowState, operations: BatchOperation[], options?: {
17
- includePendingAtomicSideEffects?: boolean;
18
- }) => Promise<void>;
16
+ /** SELF-transition (complete): fences on this engine's own claim. See ADR 0002. */
17
+ commitSelfWorkflowStateOperations: WorkflowStateCommitCallback;
18
+ /** EXTERNAL terminal transition (suspend): rotates the claim epoch. See ADR 0002. */
19
+ commitExternalTerminalWorkflowStateOperations: WorkflowStateCommitCallback;
19
20
  cleanupReviews: (workflowId: string) => Promise<void>;
20
21
  };
21
22
  export declare const TERMINAL_WORKFLOW_STATUSES: ReadonlySet<WorkflowStatus>;
@@ -64,7 +64,7 @@ export async function terminateWorkflow(internals, workflowId, status, callbacks
64
64
  status,
65
65
  ...reason !== void 0 ? { terminationReason: reason } : {},
66
66
  ...terminalCleanupToken !== void 0 ? { terminalCleanupToken } : {}
67
- }, {
67
+ }, "external-terminal", {
68
68
  allowedStatuses: FORCIBLY_TERMINABLE_STATUSES,
69
69
  buildAdditionalOperations: (_previousState, updatedAt) => {
70
70
  finalizePendingTimelineEntry(internals, workflowId, status, terminationMessage, updatedAt);
@@ -78,6 +78,7 @@ export async function terminateWorkflow(internals, workflowId, status, callbacks
78
78
  });
79
79
  if (!terminationResult)
80
80
  return;
81
+ const claimEpoch = captureCurrentClaimEpoch(internals, workflowId);
81
82
  await runCancellationHandlersForStatus(internals, workflowId, status, callbacks);
82
83
  await releaseWorkflowConcurrencySlot(internals, workflowId);
83
84
  const { previousState, updatedAt } = terminationResult, elapsed = updatedAt - getWorkflowExecutionStartedAt(previousState);
@@ -87,6 +88,7 @@ export async function terminateWorkflow(internals, workflowId, status, callbacks
87
88
  if (previousState.status === "pending")
88
89
  callbacks.swallowPromiseRejection(internals.scheduler.cancel(`delayed-start:${workflowId}`, workflowId));
89
90
  const resolver = internals.resultResolvers.get(workflowId), terminalError = buildTerminalError(workflowId, status, elapsed, reason);
91
+ await releaseWorkflowClaimAfterTerminalSettlement(internals, workflowId, claimEpoch);
90
92
  try {
91
93
  await cleanupTerminalWorkflowSynchronously(internals, workflowId, !0, callbacks);
92
94
  const event = buildTerminalEvent(workflowId, status, elapsed, reason);
@@ -165,6 +167,19 @@ function notifyCompletionWaiters(internals, workflowId, result, duration, callba
165
167
  internals.resultResolvers.delete(workflowId);
166
168
  }
167
169
  }
170
+ function captureCurrentClaimEpoch(internals, workflowId) {
171
+ return internals.workflowClaimRegistry?.currentEpoch(workflowId) ?? null;
172
+ }
173
+ async function releaseWorkflowClaimAfterTerminalSettlement(internals, workflowId, capturedEpoch) {
174
+ const registry = internals.workflowClaimRegistry;
175
+ if (registry === null || capturedEpoch === null)
176
+ return;
177
+ if (registry.currentEpoch(workflowId) !== capturedEpoch)
178
+ return;
179
+ try {
180
+ await registry.release(workflowId);
181
+ } catch {}
182
+ }
168
183
  export async function completeWorkflow(internals, workflowId, result, callbacks) {
169
184
  const completionMetadata = await callbacks.runSerializedWorkflowStateWrite(workflowId, async () => {
170
185
  const state = await callbacks.loadWorkflowState(workflowId);
@@ -180,14 +195,16 @@ export async function completeWorkflow(internals, workflowId, result, callbacks)
180
195
  appendSearchAttributeOperations(completionOperations, workflowId, currentAttributes);
181
196
  if (terminalCleanupToken !== void 0)
182
197
  completionOperations.push(...buildTerminalCleanupTimerOperations(internals, workflowId, !1, now, terminalCleanupToken));
183
- await callbacks.commitWorkflowStateOperations(state, completionOperations, {
198
+ await callbacks.commitSelfWorkflowStateOperations(state, completionOperations, {
184
199
  includePendingAtomicSideEffects: !0
185
200
  });
186
- return { duration };
201
+ const claimEpoch = captureCurrentClaimEpoch(internals, workflowId);
202
+ return { duration, claimEpoch };
187
203
  });
188
204
  if (!completionMetadata)
189
205
  return;
190
206
  await releaseWorkflowConcurrencySlot(internals, workflowId);
207
+ await releaseWorkflowClaimAfterTerminalSettlement(internals, workflowId, completionMetadata.claimEpoch);
191
208
  notifyCompletionWaiters(internals, workflowId, result, completionMetadata.duration, callbacks);
192
209
  }
193
210
  export async function failWorkflow(internals, workflowId, error, callbacks, failureCategory = "system") {
@@ -202,7 +219,7 @@ export async function failWorkflow(internals, workflowId, error, callbacks, fail
202
219
  stateUpdate.errorStack = error.stack;
203
220
  if (terminalCleanupToken !== void 0)
204
221
  stateUpdate.terminalCleanupToken = terminalCleanupToken;
205
- if (!await updateWorkflowState(internals, workflowId, stateUpdate, {
222
+ if (!await updateWorkflowState(internals, workflowId, stateUpdate, "self", {
206
223
  allowedStatuses: FORCIBLY_TERMINABLE_STATUSES,
207
224
  buildAdditionalOperations: (_previousState, updatedAt) => {
208
225
  finalizePendingTimelineEntry(internals, workflowId, "failed", error.message, updatedAt);
@@ -214,11 +231,13 @@ export async function failWorkflow(internals, workflowId, error, callbacks, fail
214
231
  }
215
232
  }))
216
233
  return;
234
+ const claimEpoch = captureCurrentClaimEpoch(internals, workflowId);
217
235
  await releaseWorkflowConcurrencySlot(internals, workflowId);
218
236
  await cleanupAttributeIndex(internals, workflowId, attributes);
219
237
  callbacks.swallowPromiseRejection(internals.scheduler.cancel(`deadline:${workflowId}`, workflowId));
220
238
  await writeRetainedTerminalSearchAttributes(internals, workflowId, retainedAttributes);
221
239
  const resolver = internals.resultResolvers.get(workflowId);
240
+ await releaseWorkflowClaimAfterTerminalSettlement(internals, workflowId, claimEpoch);
222
241
  try {
223
242
  await cleanupTerminalWorkflowSynchronously(internals, workflowId, !1, callbacks);
224
243
  const event = new WorkflowFailedEvent(workflowId, error);
@@ -45,22 +45,22 @@ export function encodeRunningClaim(attempts, token, claimedAt) {
45
45
  export async function rearmTeardownTimer(internals, workflowId, token, delayMs) {
46
46
  const fireAt = internals.options.getNow() + delayMs;
47
47
  try {
48
- await commitFencedEngineWrite(internals, teardownTimerOperations(token, workflowId, fireAt), [], () => Error("teardown self-heal re-arm lost the lease fence"));
48
+ await commitFencedEngineWrite(internals, workflowId, teardownTimerOperations(token, workflowId, fireAt), [], () => Error("teardown self-heal re-arm lost the lease fence"));
49
49
  } catch {}
50
50
  }
51
51
  export async function clearTeardownMarker(internals, workflowId, expectedBytes) {
52
52
  try {
53
- return await commitFencedEngineWriteAllowingPreconditionFailure(internals, [{ type: "delete", key: KEYS.teardownOwed(workflowId) }], [{ key: KEYS.teardownOwed(workflowId), expectedValue: expectedBytes }]);
53
+ return await commitFencedEngineWriteAllowingPreconditionFailure(internals, workflowId, [{ type: "delete", key: KEYS.teardownOwed(workflowId) }], [{ key: KEYS.teardownOwed(workflowId), expectedValue: expectedBytes }]);
54
54
  } catch {
55
55
  return !1;
56
56
  }
57
57
  }
58
58
  export async function claimTeardownMarker(internals, workflowId, expectedBytes, attempts, token) {
59
59
  const runningBytes = encodeRunningClaim(attempts, token, internals.options.getNow());
60
- return await commitFencedEngineWriteAllowingPreconditionFailure(internals, [{ type: "put", key: KEYS.teardownOwed(workflowId), value: runningBytes }], [{ key: KEYS.teardownOwed(workflowId), expectedValue: expectedBytes }]) ? runningBytes : null;
60
+ return await commitFencedEngineWriteAllowingPreconditionFailure(internals, workflowId, [{ type: "put", key: KEYS.teardownOwed(workflowId), value: runningBytes }], [{ key: KEYS.teardownOwed(workflowId), expectedValue: expectedBytes }]) ? runningBytes : null;
61
61
  }
62
62
  export async function settleOnRunningClaim(internals, workflowId, runningBytes, operations) {
63
- return commitFencedEngineWriteAllowingPreconditionFailure(internals, operations, [
63
+ return commitFencedEngineWriteAllowingPreconditionFailure(internals, workflowId, operations, [
64
64
  { key: KEYS.teardownOwed(workflowId), expectedValue: runningBytes }
65
65
  ]);
66
66
  }
@@ -0,0 +1,15 @@
1
+ import type { BatchOperation } from '../../../storage/interface.ts';
2
+ import type { WorkflowState } from '../../types.ts';
3
+ /**
4
+ * Shared function shape for the two workflow-state commit callbacks
5
+ * {@link TerminationCallbacks} exposes, split by ADR 0002's self/
6
+ * external-terminal transition classification (see
7
+ * `documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md`).
8
+ * A self-transition (complete, fail) fences on this engine's own claim; an
9
+ * external terminal transition (cancel, timeout, suspend) rotates the claim
10
+ * epoch instead, so any engine may commit it. Kept as one shared type so the
11
+ * two `TerminationCallbacks` fields cannot drift in shape.
12
+ */
13
+ export type WorkflowStateCommitCallback = (state: WorkflowState, operations: BatchOperation[], options?: {
14
+ includePendingAtomicSideEffects?: boolean;
15
+ }) => Promise<void>;
@@ -18,7 +18,7 @@ export async function suspendWorkflow(internals, workflowId, callbacks) {
18
18
  internals.parkedInlineWorkflows.delete(workflowId);
19
19
  evictSuspendedWorkflowWaiters(internals, workflowId, callbacks);
20
20
  const updatedAt = internals.options.getNow(), updatedState = { ...state, status: "suspended", updatedAt };
21
- await callbacks.commitWorkflowStateOperations(state, [
21
+ await callbacks.commitExternalTerminalWorkflowStateOperations(state, [
22
22
  { type: "put", key: KEYS.workflow(workflowId), value: encode(updatedState) },
23
23
  ...buildWorkflowVisibilityIndexTransition(workflowId, state, updatedState).batchOps,
24
24
  ...buildDeadlineTimerDeleteOperations(workflowId, state.executionDeadline)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pre-acceptance validation for workflow updates: running a registered
3
+ * `updateValidators` entry and normalizing a Standard Schema v1 failure result
4
+ * into the issue list `UpdateValidationError` carries.
5
+ *
6
+ * Lives apart from `updates.ts` because it is shared — `pending-updates.ts`
7
+ * normalizes validator results through the same helper — and because it is
8
+ * pure payload validation with no coupling to the in-memory waiter maps or the
9
+ * durable coordinated-update protocol that make up the rest of update delivery.
10
+ *
11
+ * @module core/engine/update-validation
12
+ */
13
+ import type { EngineInternals } from './internals.ts';
14
+ /**
15
+ * Run the pre-acceptance validator for an update, if one is registered.
16
+ * Throws `UpdateValidationError` if the validator rejects (by throwing or by
17
+ * returning a Standard Schema `{ issues: [...] }` failure result).
18
+ */
19
+ export declare function runUpdateValidator(internals: EngineInternals, workflowId: string, name: string, payload: unknown): Promise<void>;
20
+ /**
21
+ * Extract issues from a Standard Schema v1 failure result, or null if absent.
22
+ * No string-`message` entries yields `[]`; callers reject only on a non-empty
23
+ * array, so `null` and `[]` both mean acceptance. Preserves `path` (RFC 6901).
24
+ */
25
+ export declare function extractStandardSchemaIssues(result: unknown): Array<{
26
+ message: string;
27
+ path?: string;
28
+ }> | null;
@@ -0,0 +1,37 @@
1
+ import { UpdateValidationError } from "../updates.js";
2
+ export async function runUpdateValidator(internals, workflowId, name, payload) {
3
+ const validator = internals.inlineStrategy?.getContext(workflowId)?.updateValidators.get(name);
4
+ if (validator === void 0)
5
+ return;
6
+ let result;
7
+ try {
8
+ result = await validator(payload);
9
+ } catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ throw new UpdateValidationError(name, [{ message }]);
12
+ }
13
+ const issues = extractStandardSchemaIssues(result);
14
+ if (issues !== null && issues.length > 0)
15
+ throw new UpdateValidationError(name, issues);
16
+ }
17
+ export function extractStandardSchemaIssues(result) {
18
+ if (result === null || typeof result !== "object" || !("issues" in result))
19
+ return null;
20
+ const { issues } = result;
21
+ if (!Array.isArray(issues))
22
+ return null;
23
+ return issues.flatMap((issue) => {
24
+ if (issue === null || typeof issue !== "object")
25
+ return [];
26
+ const obj = issue;
27
+ if (typeof obj.message !== "string")
28
+ return [];
29
+ const entry = { message: obj.message };
30
+ if (Array.isArray(obj.path) && obj.path.length > 0)
31
+ entry.path = obj.path.reduce((p, seg) => {
32
+ const k = seg !== null && typeof seg === "object" && "key" in seg ? String(seg.key) : String(seg);
33
+ return p + "/" + k.replace(/~/g, "~0").replace(/\//g, "~1");
34
+ }, "");
35
+ return [entry];
36
+ });
37
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ContextOperationRequest } from '../context.ts';
2
2
  import type { CoordinatedUpdateResult } from '../types.ts';
3
- import { type UpdateRequest, type UpdateResponse } from '../updates.ts';
3
+ import type { UpdateRequest, UpdateResponse } from '../updates.ts';
4
4
  import type { EngineInternals } from './internals.ts';
5
5
  export type UpdateCallbacks = {
6
6
  dispatchEvent: (event: Event) => boolean;
@@ -22,6 +22,22 @@ export type UpdateCallbacks = {
22
22
  export declare function update(internals: EngineInternals, workflowId: string, name: string, payload: unknown, options: {
23
23
  timeout?: number;
24
24
  } | undefined, callbacks: UpdateCallbacks): Promise<unknown>;
25
+ /**
26
+ * `handled: false` used to conflate two reasons: no live local context at all
27
+ * (`'not-owned-locally'`, possible under `ownership: 'workflow-lease'` when
28
+ * another engine holds the claim) versus a live context with no handler
29
+ * registered for `name` (`'no-handler'`). See the `update()` call site for
30
+ * why neither is routed differently — the reason is exposed so a caller that
31
+ * DOES care (tests, future routing) can tell them apart.
32
+ */
33
+ export type InlineUpdateAttemptResult = {
34
+ handled: true;
35
+ value: unknown;
36
+ } | {
37
+ handled: false;
38
+ reason: 'not-owned-locally' | 'no-handler';
39
+ };
40
+ export declare function tryInlineUpdateHandler(internals: EngineInternals, workflowId: string, name: string, payload: unknown, callbacks: UpdateCallbacks): Promise<InlineUpdateAttemptResult>;
25
41
  /** Retrieve the result of a coordinated update by its ID. */
26
42
  export declare function getUpdateResult(internals: EngineInternals, updateId: string): Promise<UpdateResponse | null>;
27
43
  /**
@@ -39,18 +55,3 @@ export declare function dispatchPendingUpdateReceived(_internals: EngineInternal
39
55
  export declare function createCoordinatedUpdateResponder(_internals: EngineInternals, workflowId: string, updateName: string, updateRequest: UpdateRequest, callbacks: Pick<UpdateCallbacks, 'persistCoordinatedUpdateResponse'>): (value: unknown) => void;
40
56
  export declare function deliverCoordinatedUpdateToWaiterIfAvailable(internals: EngineInternals, workflowId: string, updateRequest: UpdateRequest, dispatchReceivedEvent: boolean | undefined, callbacks: UpdateCallbacks): Promise<boolean>;
41
57
  export declare function findPendingUpdateByName(internals: EngineInternals, workflowId: string, name: string): Promise<UpdateRequest | undefined>;
42
- /**
43
- * Invoke an update handler, checking that it does not return a generator.
44
- * Centralises the runtime generator guard for both the inline-handler path
45
- * in `update()` and the pending-drain path on resume.
46
- */
47
- export declare function invokeUpdateHandler(_internals: EngineInternals, name: string, handler: (payload: unknown) => unknown, payload: unknown): Promise<unknown>;
48
- /**
49
- * Extract issues from a Standard Schema v1 failure result, or null if absent.
50
- * No string-`message` entries yields `[]`; callers reject only on a non-empty
51
- * array, so `null` and `[]` both mean acceptance. Preserves `path` (RFC 6901).
52
- */
53
- export declare function extractStandardSchemaIssues(result: unknown): Array<{
54
- message: string;
55
- path?: string;
56
- }> | null;
@@ -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
- }