@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
@@ -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;
@@ -0,0 +1,62 @@
1
+ export const WORKFLOW_CLAIM_ATTEMPT_OUTCOMES = [
2
+ "acquired",
3
+ "takeover",
4
+ "lost_race",
5
+ "deposed",
6
+ "backoff_skipped"
7
+ ];
8
+ function assertSafeNonNegativeInteger(value, label) {
9
+ if (!Number.isInteger(value) || !Number.isSafeInteger(value) || value < 0)
10
+ throw RangeError(`${label} must be a safe, non-negative integer; received ${String(value)}`);
11
+ }
12
+ function zeroFilledAttemptCounts() {
13
+ const counts = {};
14
+ for (const outcome of WORKFLOW_CLAIM_ATTEMPT_OUTCOMES)
15
+ counts[outcome] = 0;
16
+ return counts;
17
+ }
18
+
19
+ export class WorkflowClaimMetricsCollector {
20
+ #attempts;
21
+ #activeClaims;
22
+ #renewalFailures;
23
+ constructor() {
24
+ this.#attempts = zeroFilledAttemptCounts();
25
+ this.#activeClaims = 0;
26
+ this.#renewalFailures = 0;
27
+ }
28
+ recordClaimAttempt(outcome) {
29
+ this.#attempts[outcome] += 1;
30
+ }
31
+ setActiveClaims(count) {
32
+ assertSafeNonNegativeInteger(count, "activeClaims");
33
+ this.#activeClaims = count;
34
+ }
35
+ recordClaimRenewalFailure() {
36
+ this.#renewalFailures += 1;
37
+ }
38
+ snapshot() {
39
+ return {
40
+ attempts: { ...this.#attempts },
41
+ activeClaims: this.#activeClaims,
42
+ renewalFailures: this.#renewalFailures
43
+ };
44
+ }
45
+ reset() {
46
+ this.#attempts = zeroFilledAttemptCounts();
47
+ this.#activeClaims = 0;
48
+ this.#renewalFailures = 0;
49
+ }
50
+ }
51
+ export function serializeWorkflowClaimMetricsForPrometheus(snapshot) {
52
+ const lines = [
53
+ "# HELP weft_workflow_claim_attempts_total Total per-workflow ownership claim attempts, by outcome",
54
+ "# TYPE weft_workflow_claim_attempts_total counter"
55
+ ];
56
+ for (const outcome of WORKFLOW_CLAIM_ATTEMPT_OUTCOMES)
57
+ lines.push(`weft_workflow_claim_attempts_total{outcome="${outcome}"} ${snapshot.attempts[outcome]}`);
58
+ lines.push("# HELP weft_workflow_claims_active Workflows this engine currently holds a claim for", "# TYPE weft_workflow_claims_active gauge", `weft_workflow_claims_active ${snapshot.activeClaims}`, "# HELP weft_workflow_claim_renewal_failures_total Total per-workflow claim renewal failures", "# TYPE weft_workflow_claim_renewal_failures_total counter", `weft_workflow_claim_renewal_failures_total ${snapshot.renewalFailures}`);
59
+ return lines.join(`
60
+ `) + `
61
+ `;
62
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Storage-scan candidate discovery for the recurring reclaim pass described
3
+ * in [ADR 0002 § Reclaiming stranded claims](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md#reclaiming-stranded-claims).
4
+ *
5
+ * A single boot-time `recoverAll()` sweep only catches a stranded claim that
6
+ * happens to already be past its grace-adjusted expiry at boot. An engine
7
+ * that crashes (or whose graceful-shutdown release fails) later leaves its
8
+ * `wf-owner-holder:<id>` record behind for every OTHER engine to discover;
9
+ * nothing rescans for that unless something recurring does. This module is
10
+ * the discovery half of that recurring scan: enumerate every workflow id
11
+ * with a currently-persisted holder record, store-wide, so a caller can
12
+ * attempt `WorkflowClaimRegistry.takeover` against each one.
13
+ *
14
+ * **Deliberately not a liveness filter.** This module does not decode holder
15
+ * bytes or judge staleness — `WorkflowClaimRegistry.takeover` already does
16
+ * that with a fresh read at the moment of the attempt (see its own
17
+ * grace-adjusted `isWorkflowClaimExpired` check), and re-deciding it here
18
+ * from a possibly-stale scan read would be redundant at best and a source of
19
+ * a second, drifting judgment at worst. This is discovery only.
20
+ *
21
+ * **Ownerless-but-running workflows.** The holder-keyed scan above misses a
22
+ * real rolling-deploy shape: an incoming engine boots and runs its
23
+ * `recoverAll()` sweep WHILE an outgoing engine is still the live holder of
24
+ * some workflow — recovery correctly skips it, since the holder is not yet
25
+ * expired. If the outgoing engine then disposes gracefully, its
26
+ * `releaseAll()` DELETES that workflow's `wf-owner-holder:<id>` record
27
+ * (per the ADR's `release` row — the epoch is retained, only the holder
28
+ * goes). The workflow is now `running` in `WorkflowState` with no holder at
29
+ * all, and the holder-keyed scan above will never find it again — there is
30
+ * no `wf-owner-holder:<id>` key left to enumerate. The already-running
31
+ * incoming engine has nothing further to trigger a re-scan, so the workflow
32
+ * is stranded until an operator explicitly calls `recoverAll()`/`resume()`.
33
+ * This module closes that gap with a second scan over the `running`-status
34
+ * workflow-visibility index (`wf-idx-status:running:`), checking each
35
+ * candidate's holder key fresh and including only the genuinely holderless
36
+ * ones — bounded to the same cost class as the holder-keyed scan (one extra
37
+ * index prefix scan per pass, plus one point read per running-and-holderless
38
+ * candidate).
39
+ *
40
+ * **Workflows with no visibility-index entry at all (WFT-79 Finding 2).**
41
+ * The index-based scan above is itself incomplete on a Bun SQLite deployment
42
+ * that predates the workflow visibility indexes and has not yet run the
43
+ * one-time backfill (see
44
+ * [Workflow Visibility Backfill](../../../documentation/guides/workflow-visibility-backfill.md)):
45
+ * such a workflow has no `wf-idx-status:running:<id>` row to enumerate. If
46
+ * that workflow is the outgoing engine's live holder at the moment an
47
+ * incoming engine's `recoverAll()` sweep runs, recovery correctly skips it
48
+ * (the holder is not yet expired); if the outgoing engine then disposes
49
+ * gracefully, `releaseAll()` deletes the holder record and the workflow is
50
+ * left `running` with no holder AND no visibility-index entry — invisible to
51
+ * both scans above. Left there, it is stranded indefinitely: nothing else
52
+ * re-scans for it.
53
+ *
54
+ * This module closes that second gap with a bounded, cursor-rotated fallback
55
+ * scan directly over the authoritative `wf:<id>` workflow records (the same
56
+ * source `recoverAll()`'s own preflight already scans at boot — see
57
+ * `lifecycle/transition.ts`'s `preflightRecoverAll`), decoding each record
58
+ * and including only genuinely holderless `running` ones not already found
59
+ * by either scan above. This IS a store-wide operation and therefore more
60
+ * expensive than the index-based scans, so it is bounded per pass to
61
+ * {@link WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT} records rather than
62
+ * scanning the entire keyspace on every call. A per-storage cursor (advanced
63
+ * past the last key read, and wrapped back to the start once a pass reaches
64
+ * the end of the keyspace) rotates the scanned window across passes, so a
65
+ * store with more un-backfilled workflows than the per-pass limit still gets
66
+ * full coverage over several reclaim-scan passes rather than only ever
67
+ * re-scanning the same lexicographically-first window. This fallback is
68
+ * expected to do genuine work only on deployments that have not yet run the
69
+ * visibility backfill; run that backfill to eliminate this scan's ongoing
70
+ * cost entirely.
71
+ *
72
+ * @module core/engine/workflow-claim-reclaim-scan
73
+ */
74
+ import { type Storage } from '../../storage/interface.ts';
75
+ /**
76
+ * Per-pass bound on the authoritative-record fallback scan
77
+ * ({@link listOwnerlessRunningCandidatesFromAuthoritativeRecords}). This
78
+ * fallback is a store-wide scan, unlike the two index-based scans above, so
79
+ * it is capped rather than run to exhaustion on every pass — see the module
80
+ * doc's "Workflows with no visibility-index entry at all" section for why
81
+ * the cap is safe (a per-storage cursor rotates the scanned window across
82
+ * passes, so a store with more un-backfilled workflows than this limit still
83
+ * gets full coverage over several passes).
84
+ */
85
+ export declare const WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT = 500;
86
+ /**
87
+ * Full candidate discovery for one reclaim-scan pass: every workflow id with
88
+ * a currently-persisted holder record, every `running`-status workflow id
89
+ * with NO holder record at all found via the visibility index (see the
90
+ * module doc's "Ownerless-but-running workflows" section), and every
91
+ * `running`-status, holderless workflow id found by the bounded
92
+ * authoritative-record fallback for workflows with no visibility-index entry
93
+ * at all (see the module doc's "Workflows with no visibility-index entry at
94
+ * all" section) — excluding `excludeWorkflowIds` from all three.
95
+ */
96
+ export declare function listWorkflowClaimReclaimCandidates(storage: Storage, excludeWorkflowIds: ReadonlySet<string>): Promise<string[]>;
@@ -0,0 +1,66 @@
1
+ import { KEYS, tryDecodeStorageKeyComponent } from "../../storage/interface.js";
2
+ import { decodeWorkflowState } from "./validation.js";
3
+ const RUNNING_STATUS = "running";
4
+ export const WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT = 500;
5
+ const authoritativeScanCursors = new WeakMap;
6
+ async function listHolderScanCandidates(storage, excludeWorkflowIds) {
7
+ const prefix = KEYS.workflowOwnerHolder(""), candidates = [];
8
+ for await (const [key] of storage.scan(prefix)) {
9
+ const workflowId = tryDecodeStorageKeyComponent(key.slice(prefix.length));
10
+ if (workflowId === null || excludeWorkflowIds.has(workflowId))
11
+ continue;
12
+ candidates.push(workflowId);
13
+ }
14
+ return candidates;
15
+ }
16
+ async function listOwnerlessRunningCandidates(storage, excludeWorkflowIds, seen) {
17
+ const prefix = KEYS.workflowVisibilityStatus(RUNNING_STATUS, ""), candidates = [];
18
+ for await (const [key] of storage.scan(prefix)) {
19
+ const workflowId = tryDecodeStorageKeyComponent(key.slice(prefix.length));
20
+ if (workflowId === null || excludeWorkflowIds.has(workflowId) || seen.has(workflowId) || candidates.includes(workflowId))
21
+ continue;
22
+ if (await storage.get(KEYS.workflowOwnerHolder(workflowId)) !== null)
23
+ continue;
24
+ candidates.push(workflowId);
25
+ }
26
+ return candidates;
27
+ }
28
+ async function classifyAuthoritativeScanEntry(storage, prefix, key, value, excludeWorkflowIds, seen, alreadyCollected) {
29
+ const workflowId = tryDecodeStorageKeyComponent(key.slice(prefix.length));
30
+ if (workflowId === null)
31
+ return null;
32
+ if (KEYS.workflow(workflowId) !== key)
33
+ return null;
34
+ if (excludeWorkflowIds.has(workflowId) || seen.has(workflowId) || alreadyCollected.has(workflowId))
35
+ return null;
36
+ let status;
37
+ try {
38
+ status = decodeWorkflowState(value).status;
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (status !== RUNNING_STATUS)
43
+ return null;
44
+ if (await storage.get(KEYS.workflowOwnerHolder(workflowId)) !== null)
45
+ return null;
46
+ return workflowId;
47
+ }
48
+ async function listOwnerlessRunningCandidatesFromAuthoritativeRecords(storage, excludeWorkflowIds, seen) {
49
+ const prefix = KEYS.workflow(""), cursor = authoritativeScanCursors.get(storage), scanOptions = cursor === void 0 ? { limit: WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT } : { limit: WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT, gt: cursor }, candidates = [], collected = new Set;
50
+ let scannedCount = 0, lastKey;
51
+ for await (const [key, value] of storage.scan(prefix, scanOptions)) {
52
+ scannedCount += 1;
53
+ lastKey = key;
54
+ const workflowId = await classifyAuthoritativeScanEntry(storage, prefix, key, value, excludeWorkflowIds, seen, collected);
55
+ if (workflowId === null)
56
+ continue;
57
+ collected.add(workflowId);
58
+ candidates.push(workflowId);
59
+ }
60
+ authoritativeScanCursors.set(storage, scannedCount < WORKFLOW_CLAIM_RECLAIM_AUTHORITATIVE_SCAN_LIMIT ? void 0 : lastKey);
61
+ return candidates;
62
+ }
63
+ export async function listWorkflowClaimReclaimCandidates(storage, excludeWorkflowIds) {
64
+ const holderCandidates = await listHolderScanCandidates(storage, excludeWorkflowIds), ownerlessCandidates = await listOwnerlessRunningCandidates(storage, excludeWorkflowIds, new Set(holderCandidates)), authoritativeFallbackCandidates = await listOwnerlessRunningCandidatesFromAuthoritativeRecords(storage, excludeWorkflowIds, new Set([...holderCandidates, ...ownerlessCandidates]));
65
+ return [...holderCandidates, ...ownerlessCandidates, ...authoritativeFallbackCandidates];
66
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The reclaim-scan candidate-discovery and takeover-attempt machinery for
3
+ * `ownership: 'workflow-lease'` ([ADR 0002](../../../documentation/contributing/architecture-decisions/0002-multiengine-per-workflow-ownership.md)).
4
+ * Extracted from `ownership-bootstrap.ts` — which still composes this module's
5
+ * {@link createWorkflowClaimReclaimTarget} into the renewal task's
6
+ * `reclaimTarget` option — so that file stays under the repository's
7
+ * implementation-file-size ceiling as this seam grows disposal-quiescence and
8
+ * epoch-fenced-release handling on top of the original takeover-retry loop.
9
+ * This is a real responsibility boundary, not an arbitrary split: everything
10
+ * here is "how one engine discovers and attempts to reclaim a stranded
11
+ * workflow's claim," while `ownership-bootstrap.ts` keeps gate execution,
12
+ * registry/renewal-task construction, and the owner-side signal-poll seam.
13
+ *
14
+ * @module core/engine/workflow-claim-reclaim-target
15
+ */
16
+ import type { Storage } from '../../storage/interface.ts';
17
+ import type { WorkflowClaimMetricsCollector } from './workflow-claim-metrics.ts';
18
+ import type { WorkflowClaimRegistry } from './workflow-claim-registry.ts';
19
+ import type { WorkflowClaimReclaimTarget } from './workflow-claim-renewal-subpasses.ts';
20
+ /** Bound on retrying a lost-race `takeover` CAS for one reclaim candidate within one pass — ADR 0002's `takeover` row. */
21
+ export declare const WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS = 5;
22
+ /**
23
+ * A {@link WorkflowClaimReclaimTarget} with one additional, non-interface
24
+ * method: {@link markDisposing}. Structurally still a valid
25
+ * `WorkflowClaimReclaimTarget` (every caller that only knows that narrower
26
+ * type — e.g. `createWorkflowClaimRenewalTask`'s `reclaimTarget` option —
27
+ * keeps working unchanged), so existing tests that only exercise
28
+ * `listReclaimCandidateWorkflowIds`/`attemptWorkflowClaimTakeover` are
29
+ * unaffected by this addition.
30
+ */
31
+ export type WorkflowClaimReclaimTargetHandle = WorkflowClaimReclaimTarget & {
32
+ /**
33
+ * Synchronously flip this target into "disposing" mode: every future
34
+ * `attemptWorkflowClaimTakeover` call becomes an immediate `'not-eligible'`
35
+ * no-op (no CAS attempted, no `onReclaimed` drive invoked), and
36
+ * `listReclaimCandidateWorkflowIds` returns `[]`. A reclaim attempt already
37
+ * past this checkpoint when disposal begins keeps running to its next
38
+ * checkpoint — every checkpoint after an `await` re-checks the flag — and,
39
+ * if it lands a takeover/acquire CAS after disposal was signaled, releases
40
+ * that claim immediately instead of driving it or leaving it held. See this
41
+ * module's doc and `ownership-bootstrap.ts`'s `bootstrapWorkflowLeaseOwnership`
42
+ * for how this is wired to `WorkflowClaimRenewalTask.stop()`.
43
+ *
44
+ * Idempotent. Calling this before any pass has started simply prevents one
45
+ * from ever discovering or attempting a candidate.
46
+ */
47
+ markDisposing(): void;
48
+ };
49
+ /**
50
+ * Adapt a {@link WorkflowClaimRegistry} plus `storage` to the renewal task's
51
+ * {@link WorkflowClaimReclaimTarget} contract. Candidate discovery excludes
52
+ * this engine's own currently-held ids (`registry.listHeldWorkflowIds()`) —
53
+ * see `workflow-claim-reclaim-scan.ts`'s doc for why — then adds back any
54
+ * workflow this engine holds but whose `onReclaimed` drive previously
55
+ * failed (see `driveReclaimedWorkflow` below). `attemptWorkflowClaimTakeover`
56
+ * retries a `'lost-race'` CAS, bounded at {@link WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS}
57
+ * per the ADR, and records `weft_workflow_claim_attempts_total{outcome="backoff_skipped"}`
58
+ * the moment the registry's own anti-thrash cooldown suppresses an attempt —
59
+ * the one `WorkflowClaimAttemptOutcome` this stage wires; the other four
60
+ * remain unrecorded by design (see the ADR's Observability section for the
61
+ * full set — that wiring is a later stage's work).
62
+ *
63
+ * **A failed `onReclaimed` drive is retried in place, never released.**
64
+ * Releasing on failure was considered and rejected: `onReclaimed` (bound to
65
+ * `resumeWorkflowFromStorage` in production) can throw AFTER
66
+ * `relaunchInlineWorkflowAfterResume` has already adopted the generator —
67
+ * `InlineExecutionStrategy#continueWorkflow` fires the drive and returns
68
+ * without awaiting it, so a caught error here does not prove no local user
69
+ * code started. Releasing the claim in that state would let another engine
70
+ * `acquire` it while this engine may still be mid-turn — the exact
71
+ * duplicate-execution hazard ADR 0002 exists to close, just re-opened via
72
+ * the failure path instead of the happy path. Retrying in place keeps the
73
+ * claim (and its write fence) intact and simply asks `onReclaimed` again on
74
+ * a later pass, via `pendingRedriveWorkflowIds` below.
75
+ *
76
+ * **Discovered and pending-redrive candidates are merged through a `Set`
77
+ * (WFT-79 Finding 4).** After a failed redrive loses this engine's local
78
+ * claim (a renewal loss between the failed drive and the next pass), the
79
+ * same workflow id can surface BOTH through `listWorkflowClaimReclaimCandidates`
80
+ * (as a foreign holder, since `registry.listHeldWorkflowIds()` no longer
81
+ * excludes it) AND through `pendingRedriveWorkflowIds`. Without deduping,
82
+ * one renewal pass would call `attemptWorkflowClaimTakeover` for that id
83
+ * twice, and each call independently retries up to
84
+ * {@link WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS} — doubling the advertised
85
+ * per-pass bound to 10 attempts for that workflow, exactly when contention is
86
+ * already highest (deposition churn).
87
+ *
88
+ * **Disposal quiescence (WFT-79 Finding 2).** {@link WorkflowClaimReclaimTargetHandle.markDisposing}
89
+ * closes two related hazards when disposal overlaps an in-flight
90
+ * interval-driven pass: a late-landing takeover/acquire CAS stranding a claim
91
+ * this engine will never renew again, and a late `onReclaimed` drive running
92
+ * against a torn-down host. Every checkpoint that follows an `await` inside
93
+ * this target re-checks the flag; a CAS that lands after disposal was
94
+ * signaled is released immediately rather than driven, so the claim never
95
+ * outlives this engine's own best-effort `WorkflowClaimRegistry.releaseAll()`
96
+ * call regardless of the ordering race between them.
97
+ */
98
+ export declare function createWorkflowClaimReclaimTarget(registry: WorkflowClaimRegistry, storage: Storage, metrics: WorkflowClaimMetricsCollector, onReclaimed?: (workflowId: string) => Promise<void>,
99
+ /**
100
+ * Optional workflow-type eligibility check, consulted before this engine
101
+ * ever attempts a fresh takeover/acquire CAS for a candidate (never for a
102
+ * `redriveAlreadyHeldClaim` retry, since that claim already passed this
103
+ * check when it was first taken). Omitted (the default) skips the check —
104
+ * every existing caller/test that does not care about mixed workflow-type
105
+ * fleets keeps working unchanged.
106
+ */
107
+ isTypeRegistered?: (workflowType: string) => boolean): WorkflowClaimReclaimTargetHandle;
@@ -0,0 +1,155 @@
1
+ import { KEYS } from "../../storage/interface.js";
2
+ import { decodeWorkflowState } from "./validation.js";
3
+ import { listWorkflowClaimReclaimCandidates } from "./workflow-claim-reclaim-scan.js";
4
+ export const WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS = 5;
5
+ async function isWorkflowStillRunning(storage, workflowId) {
6
+ const bytes = await storage.get(KEYS.workflow(workflowId));
7
+ if (bytes === null)
8
+ return !1;
9
+ try {
10
+ return decodeWorkflowState(bytes).status === "running";
11
+ } catch {
12
+ return !1;
13
+ }
14
+ }
15
+ async function isWorkflowTypeRegistered(storage, workflowId, isTypeRegistered) {
16
+ const bytes = await storage.get(KEYS.workflow(workflowId));
17
+ if (bytes === null)
18
+ return !1;
19
+ try {
20
+ return isTypeRegistered(decodeWorkflowState(bytes).type);
21
+ } catch {
22
+ return !1;
23
+ }
24
+ }
25
+ export function createWorkflowClaimReclaimTarget(registry, storage, metrics, onReclaimed, isTypeRegistered) {
26
+ const pendingRedriveWorkflowIds = new Map;
27
+ let disposing = !1;
28
+ async function driveReclaimedWorkflow(workflowId, epoch) {
29
+ if (onReclaimed === void 0) {
30
+ pendingRedriveWorkflowIds.delete(workflowId);
31
+ return { status: "reclaimed" };
32
+ }
33
+ try {
34
+ await onReclaimed(workflowId);
35
+ pendingRedriveWorkflowIds.delete(workflowId);
36
+ return { status: "reclaimed" };
37
+ } catch (error) {
38
+ pendingRedriveWorkflowIds.set(workflowId, epoch);
39
+ throw error;
40
+ }
41
+ }
42
+ async function releaseClaimAcquiredWhileDisposing(workflowId) {
43
+ await registry.release(workflowId);
44
+ }
45
+ async function confirmStillRunningOrReleaseFreshClaim(workflowId, acquiredEpoch) {
46
+ if (await isWorkflowStillRunning(storage, workflowId))
47
+ return !0;
48
+ if (registry.currentEpoch(workflowId) === acquiredEpoch)
49
+ await registry.release(workflowId);
50
+ return !1;
51
+ }
52
+ async function redriveAlreadyHeldClaim(workflowId, expectedEpoch) {
53
+ if (disposing)
54
+ return { status: "not-eligible" };
55
+ if (!await isWorkflowStillRunning(storage, workflowId)) {
56
+ pendingRedriveWorkflowIds.delete(workflowId);
57
+ if (registry.currentEpoch(workflowId) === expectedEpoch)
58
+ await registry.release(workflowId);
59
+ return { status: "not-eligible" };
60
+ }
61
+ if (disposing)
62
+ return { status: "not-eligible" };
63
+ return await driveReclaimedWorkflow(workflowId, expectedEpoch);
64
+ }
65
+ async function acquireOwnerlessRunningClaim(workflowId) {
66
+ if (disposing)
67
+ return { status: "not-eligible" };
68
+ if (!await isWorkflowStillRunning(storage, workflowId))
69
+ return { status: "not-eligible" };
70
+ if (disposing)
71
+ return { status: "not-eligible" };
72
+ const acquireResult = await registry.acquire(workflowId);
73
+ if (acquireResult.status === "lost-race") {
74
+ metrics.recordClaimAttempt("lost_race");
75
+ return "retry";
76
+ }
77
+ metrics.recordClaimAttempt("acquired");
78
+ if (disposing) {
79
+ await releaseClaimAcquiredWhileDisposing(workflowId);
80
+ return { status: "not-eligible" };
81
+ }
82
+ if (!await confirmStillRunningOrReleaseFreshClaim(workflowId, acquireResult.epoch))
83
+ return { status: "not-eligible" };
84
+ return await driveReclaimedWorkflow(workflowId, acquireResult.epoch);
85
+ }
86
+ async function handleTakeoverAcquired(workflowId, acquiredEpoch) {
87
+ metrics.recordClaimAttempt("takeover");
88
+ if (disposing) {
89
+ await releaseClaimAcquiredWhileDisposing(workflowId);
90
+ return { status: "not-eligible" };
91
+ }
92
+ if (!await confirmStillRunningOrReleaseFreshClaim(workflowId, acquiredEpoch))
93
+ return { status: "not-eligible" };
94
+ return await driveReclaimedWorkflow(workflowId, acquiredEpoch);
95
+ }
96
+ async function isEligibleForFreshTakeover(workflowId) {
97
+ if (isTypeRegistered === void 0)
98
+ return !0;
99
+ return await isWorkflowTypeRegistered(storage, workflowId, isTypeRegistered);
100
+ }
101
+ async function takeoverWithRetries(workflowId) {
102
+ if (!await isEligibleForFreshTakeover(workflowId))
103
+ return { status: "not-eligible" };
104
+ for (let attempt = 0;attempt < WORKFLOW_CLAIM_TAKEOVER_MAX_ATTEMPTS; attempt += 1) {
105
+ if (disposing)
106
+ return { status: "not-eligible" };
107
+ const result = await registry.takeover(workflowId);
108
+ switch (result.status) {
109
+ case "acquired":
110
+ return await handleTakeoverAcquired(workflowId, result.epoch);
111
+ case "backoff-skipped":
112
+ metrics.recordClaimAttempt("backoff_skipped");
113
+ return { status: "backoff-skipped" };
114
+ case "not-expired":
115
+ return { status: "not-eligible" };
116
+ case "no-claim": {
117
+ const outcome = await acquireOwnerlessRunningClaim(workflowId);
118
+ if (outcome === "retry")
119
+ continue;
120
+ return outcome;
121
+ }
122
+ case "lost-race":
123
+ metrics.recordClaimAttempt("lost_race");
124
+ continue;
125
+ }
126
+ }
127
+ return { status: "lost-race" };
128
+ }
129
+ return {
130
+ async listReclaimCandidateWorkflowIds() {
131
+ if (disposing)
132
+ return [];
133
+ const discovered = await listWorkflowClaimReclaimCandidates(storage, new Set(registry.listHeldWorkflowIds()));
134
+ return [...new Set([...discovered, ...pendingRedriveWorkflowIds.keys()])];
135
+ },
136
+ async attemptWorkflowClaimTakeover(workflowId) {
137
+ if (disposing)
138
+ return { status: "not-eligible" };
139
+ const currentEpoch = registry.currentEpoch(workflowId);
140
+ if (currentEpoch !== null) {
141
+ const pendingRedriveEpoch = pendingRedriveWorkflowIds.get(workflowId);
142
+ if (pendingRedriveEpoch !== void 0 && pendingRedriveEpoch !== currentEpoch) {
143
+ pendingRedriveWorkflowIds.delete(workflowId);
144
+ return await takeoverWithRetries(workflowId);
145
+ }
146
+ return await redriveAlreadyHeldClaim(workflowId, currentEpoch);
147
+ }
148
+ pendingRedriveWorkflowIds.delete(workflowId);
149
+ return await takeoverWithRetries(workflowId);
150
+ },
151
+ markDisposing() {
152
+ disposing = !0;
153
+ }
154
+ };
155
+ }