@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
@@ -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
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Bounded-cardinality observability recorder for the `ownership: 'workflow-lease'`
3
+ * per-workflow claim protocol described in
4
+ * [ADR 0002 § Observability](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#observability).
5
+ *
6
+ * This module is a standalone recorder, not wired into
7
+ * `src/observability/metrics.ts`/`metrics-catalog.ts` — those files already
8
+ * document a "keeping this as an interface" plug-point idiom for exactly this
9
+ * situation (see `PrometheusExporter` there), and the existing `METRICS`
10
+ * catalogue and `MetricsCollector` have no concept of a labelled series at
11
+ * all: every entry is a flat, unlabelled name. Folding `outcome` into that
12
+ * shape would require editing `metrics-catalog.ts` and `metrics.ts`, which is
13
+ * out of scope for this stage (see the module-level follow-up note below).
14
+ * Instead this module defines the same three signals as a small, injectable,
15
+ * structurally-typed recorder that a later stage can either back the existing
16
+ * exporter with directly, or bridge into `MetricsCollector` by fanning
17
+ * `snapshot().attempts` out into five unlabelled counter names.
18
+ *
19
+ * The ADR states twice that workflow ids are never Prometheus metric labels.
20
+ * That is enforced here at the type level, not by convention: the only value
21
+ * {@link WorkflowClaimMetricsRecorder.recordClaimAttempt} accepts is
22
+ * {@link WorkflowClaimAttemptOutcome}, a closed union of exactly five string
23
+ * literals. A workflow id — an arbitrary `string` — is not a member of that
24
+ * union, so passing one is a compile error, not a runtime cardinality bug
25
+ * waiting to happen. See `workflow-claim-metrics.test-d.ts` for the pinned
26
+ * proof.
27
+ *
28
+ * **Follow-up (not built here):** wiring this recorder's snapshot into the
29
+ * server's `/v1/metrics` `PrometheusExporter` (`src/observability/metrics.ts`)
30
+ * requires editing that file, which is owned by a different in-flight patch
31
+ * for this stage. {@link WorkflowClaimMetricsRecorder} is deliberately
32
+ * structural so that wiring can happen later without reshaping this module.
33
+ *
34
+ * @module core/engine/workflow-claim-metrics
35
+ */
36
+ /**
37
+ * The exact, closed set of outcomes {@link WorkflowClaimMetricsRecorder.recordClaimAttempt}
38
+ * accepts. Matches `weft_workflow_claim_attempts_total`'s `outcome` label
39
+ * value set in ADR 0002 § Observability — five fixed values, nothing else.
40
+ */
41
+ export type WorkflowClaimAttemptOutcome = 'acquired' | 'takeover' | 'lost_race' | 'deposed' | 'backoff_skipped';
42
+ /**
43
+ * Every {@link WorkflowClaimAttemptOutcome} value, in declaration order. The
44
+ * single source of truth for the label's cardinality bound — both the
45
+ * recorder's internal counter map and the Prometheus serializer iterate this
46
+ * tuple rather than re-listing the five values.
47
+ */
48
+ export declare const WORKFLOW_CLAIM_ATTEMPT_OUTCOMES: readonly ["acquired", "takeover", "lost_race", "deposed", "backoff_skipped"];
49
+ /** One count per {@link WorkflowClaimAttemptOutcome}, always present (zero-filled when never recorded). */
50
+ export type WorkflowClaimAttemptCounts = Readonly<Record<WorkflowClaimAttemptOutcome, number>>;
51
+ /** A point-in-time read of everything this module tracks. */
52
+ export type WorkflowClaimMetricsSnapshot = {
53
+ /** Per-outcome attempt counts. Mirrors `weft_workflow_claim_attempts_total{outcome="..."}`. */
54
+ attempts: WorkflowClaimAttemptCounts;
55
+ /** Mirrors `weft_workflow_claims_active` — workflows this engine currently holds a claim for. */
56
+ activeClaims: number;
57
+ /** Mirrors `weft_workflow_claim_renewal_failures_total`. */
58
+ renewalFailures: number;
59
+ };
60
+ /**
61
+ * Structural recorder interface for the three ADR 0002 observability signals.
62
+ * Defined as an interface — not just the concrete {@link WorkflowClaimMetricsCollector}
63
+ * class — so a caller can satisfy it with any implementation (a bridge into
64
+ * `MetricsCollector`, an OpenTelemetry adapter, a test double) without
65
+ * depending on this module's storage choices.
66
+ *
67
+ * `recordClaimAttempt`'s parameter type is the whole point of this interface:
68
+ * it is impossible to express "pass a workflow id here" without a type error,
69
+ * because the parameter is {@link WorkflowClaimAttemptOutcome}, not `string`.
70
+ */
71
+ export interface WorkflowClaimMetricsRecorder {
72
+ /** Record one claim-attempt outcome. Increments only that outcome's series. */
73
+ recordClaimAttempt(outcome: WorkflowClaimAttemptOutcome): void;
74
+ /**
75
+ * Set the absolute count of workflows this engine currently holds a claim
76
+ * for. An absolute set (not a delta), matching the existing
77
+ * `MetricsCollector.gauge()` idiom in `src/observability/metrics.ts`. Must
78
+ * be a safe, non-negative integer.
79
+ */
80
+ setActiveClaims(count: number): void;
81
+ /** Record one renewal-failure occurrence. */
82
+ recordClaimRenewalFailure(): void;
83
+ }
84
+ /**
85
+ * In-memory implementation of {@link WorkflowClaimMetricsRecorder}, one per
86
+ * engine process — matching the ADR's "single counter/gauge per engine
87
+ * process" cardinality note for these three signals.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { WorkflowClaimMetricsCollector } from './workflow-claim-metrics.ts';
92
+ *
93
+ * const collector = new WorkflowClaimMetricsCollector();
94
+ * collector.recordClaimAttempt('acquired');
95
+ * collector.setActiveClaims(1);
96
+ * console.log(collector.snapshot().attempts.acquired); // 1
97
+ * ```
98
+ */
99
+ export declare class WorkflowClaimMetricsCollector implements WorkflowClaimMetricsRecorder {
100
+ #private;
101
+ constructor();
102
+ recordClaimAttempt(outcome: WorkflowClaimAttemptOutcome): void;
103
+ setActiveClaims(count: number): void;
104
+ recordClaimRenewalFailure(): void;
105
+ /** Return a point-in-time snapshot of all three signals. */
106
+ snapshot(): WorkflowClaimMetricsSnapshot;
107
+ /** Clear all collected values back to zero. */
108
+ reset(): void;
109
+ }
110
+ /**
111
+ * Serialize a {@link WorkflowClaimMetricsSnapshot} as Prometheus text format,
112
+ * using the exact metric names ADR 0002 § Observability specifies. Every
113
+ * outcome always emits a line — including zero-valued ones — so a scraper
114
+ * sees a stable schema regardless of which outcomes have occurred yet,
115
+ * matching the stable-schema behavior of
116
+ * `serializeMetricsSnapshotForPrometheus` in `src/observability/metrics.ts`.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * import {
121
+ * WorkflowClaimMetricsCollector,
122
+ * serializeWorkflowClaimMetricsForPrometheus,
123
+ * } from './workflow-claim-metrics.ts';
124
+ *
125
+ * const collector = new WorkflowClaimMetricsCollector();
126
+ * collector.recordClaimAttempt('acquired');
127
+ * const body = serializeWorkflowClaimMetricsForPrometheus(collector.snapshot());
128
+ * console.log(body.includes('weft_workflow_claim_attempts_total{outcome="acquired"} 1'));
129
+ * ```
130
+ */
131
+ export declare function serializeWorkflowClaimMetricsForPrometheus(snapshot: WorkflowClaimMetricsSnapshot): string;