@smthrs/run-store 0.0.0-stage → 1.0.0-rc.4

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 (157) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +195 -2
  4. package/dist/cjs/AttemptStore.d.ts +359 -0
  5. package/dist/cjs/AttemptStore.d.ts.map +1 -0
  6. package/dist/cjs/AttemptStore.js +731 -0
  7. package/dist/cjs/AttemptStore.js.map +7 -0
  8. package/dist/cjs/Heartbeat.d.ts +46 -0
  9. package/dist/cjs/Heartbeat.d.ts.map +1 -0
  10. package/dist/cjs/Heartbeat.js +28 -0
  11. package/dist/cjs/Heartbeat.js.map +7 -0
  12. package/dist/cjs/Migrations.d.ts +42 -0
  13. package/dist/cjs/Migrations.d.ts.map +1 -0
  14. package/dist/cjs/Migrations.js +55 -0
  15. package/dist/cjs/Migrations.js.map +7 -0
  16. package/dist/cjs/Ownership.d.ts +275 -0
  17. package/dist/cjs/Ownership.d.ts.map +1 -0
  18. package/dist/cjs/Ownership.js +129 -0
  19. package/dist/cjs/Ownership.js.map +7 -0
  20. package/dist/cjs/RunStore.d.ts +571 -0
  21. package/dist/cjs/RunStore.d.ts.map +1 -0
  22. package/dist/cjs/RunStore.js +1079 -0
  23. package/dist/cjs/RunStore.js.map +7 -0
  24. package/dist/cjs/RunStoreMetrics.d.ts +151 -0
  25. package/dist/cjs/RunStoreMetrics.d.ts.map +1 -0
  26. package/dist/cjs/RunStoreMetrics.js +91 -0
  27. package/dist/cjs/RunStoreMetrics.js.map +7 -0
  28. package/dist/cjs/index.d.ts +47 -0
  29. package/dist/cjs/index.d.ts.map +1 -0
  30. package/dist/cjs/index.js +43 -0
  31. package/dist/cjs/index.js.map +7 -0
  32. package/dist/cjs/internal/Boundary.d.ts +80 -0
  33. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  34. package/dist/cjs/internal/Boundary.js +92 -0
  35. package/dist/cjs/internal/Boundary.js.map +7 -0
  36. package/dist/cjs/internal/Companion.d.ts +44 -0
  37. package/dist/cjs/internal/Companion.d.ts.map +1 -0
  38. package/dist/cjs/internal/Companion.js +50 -0
  39. package/dist/cjs/internal/Companion.js.map +7 -0
  40. package/dist/cjs/internal/SpanOutcome.d.ts +37 -0
  41. package/dist/cjs/internal/SpanOutcome.d.ts.map +1 -0
  42. package/dist/cjs/internal/SpanOutcome.js +62 -0
  43. package/dist/cjs/internal/SpanOutcome.js.map +7 -0
  44. package/dist/cjs/migrations/0001_initial.d.ts +15 -0
  45. package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
  46. package/dist/cjs/migrations/0001_initial.js +110 -0
  47. package/dist/cjs/migrations/0001_initial.js.map +7 -0
  48. package/dist/cjs/migrations/0002_lineage.d.ts +27 -0
  49. package/dist/cjs/migrations/0002_lineage.d.ts.map +1 -0
  50. package/dist/cjs/migrations/0002_lineage.js +43 -0
  51. package/dist/cjs/migrations/0002_lineage.js.map +7 -0
  52. package/dist/cjs/migrations/0003_execution_revisions.d.ts +15 -0
  53. package/dist/cjs/migrations/0003_execution_revisions.d.ts.map +1 -0
  54. package/dist/cjs/migrations/0003_execution_revisions.js +77 -0
  55. package/dist/cjs/migrations/0003_execution_revisions.js.map +7 -0
  56. package/dist/cjs/migrations/0004_waiting_request.d.ts +32 -0
  57. package/dist/cjs/migrations/0004_waiting_request.d.ts.map +1 -0
  58. package/dist/cjs/migrations/0004_waiting_request.js +42 -0
  59. package/dist/cjs/migrations/0004_waiting_request.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/cjs/test/TestRunStore.d.ts +17 -0
  62. package/dist/cjs/test/TestRunStore.d.ts.map +1 -0
  63. package/dist/cjs/test/TestRunStore.js +42 -0
  64. package/dist/cjs/test/TestRunStore.js.map +7 -0
  65. package/dist/esm/AttemptStore.d.ts +359 -0
  66. package/dist/esm/AttemptStore.d.ts.map +1 -0
  67. package/dist/esm/AttemptStore.js +835 -0
  68. package/dist/esm/AttemptStore.js.map +1 -0
  69. package/dist/esm/Heartbeat.d.ts +46 -0
  70. package/dist/esm/Heartbeat.d.ts.map +1 -0
  71. package/dist/esm/Heartbeat.js +46 -0
  72. package/dist/esm/Heartbeat.js.map +1 -0
  73. package/dist/esm/Migrations.d.ts +42 -0
  74. package/dist/esm/Migrations.d.ts.map +1 -0
  75. package/dist/esm/Migrations.js +56 -0
  76. package/dist/esm/Migrations.js.map +1 -0
  77. package/dist/esm/Ownership.d.ts +275 -0
  78. package/dist/esm/Ownership.d.ts.map +1 -0
  79. package/dist/esm/Ownership.js +299 -0
  80. package/dist/esm/Ownership.js.map +1 -0
  81. package/dist/esm/RunStore.d.ts +571 -0
  82. package/dist/esm/RunStore.d.ts.map +1 -0
  83. package/dist/esm/RunStore.js +1287 -0
  84. package/dist/esm/RunStore.js.map +1 -0
  85. package/dist/esm/RunStoreMetrics.d.ts +151 -0
  86. package/dist/esm/RunStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/RunStoreMetrics.js +120 -0
  88. package/dist/esm/RunStoreMetrics.js.map +1 -0
  89. package/dist/esm/index.d.ts +47 -0
  90. package/dist/esm/index.d.ts.map +1 -0
  91. package/dist/esm/index.js +47 -0
  92. package/dist/esm/index.js.map +1 -0
  93. package/dist/esm/internal/Boundary.d.ts +80 -0
  94. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  95. package/dist/esm/internal/Boundary.js +109 -0
  96. package/dist/esm/internal/Boundary.js.map +1 -0
  97. package/dist/esm/internal/Companion.d.ts +44 -0
  98. package/dist/esm/internal/Companion.d.ts.map +1 -0
  99. package/dist/esm/internal/Companion.js +53 -0
  100. package/dist/esm/internal/Companion.js.map +1 -0
  101. package/dist/esm/internal/SpanOutcome.d.ts +37 -0
  102. package/dist/esm/internal/SpanOutcome.d.ts.map +1 -0
  103. package/dist/esm/internal/SpanOutcome.js +48 -0
  104. package/dist/esm/internal/SpanOutcome.js.map +1 -0
  105. package/dist/esm/migrations/0001_initial.d.ts +15 -0
  106. package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
  107. package/dist/esm/migrations/0001_initial.js +88 -0
  108. package/dist/esm/migrations/0001_initial.js.map +1 -0
  109. package/dist/esm/migrations/0002_lineage.d.ts +27 -0
  110. package/dist/esm/migrations/0002_lineage.d.ts.map +1 -0
  111. package/dist/esm/migrations/0002_lineage.js +33 -0
  112. package/dist/esm/migrations/0002_lineage.js.map +1 -0
  113. package/dist/esm/migrations/0003_execution_revisions.d.ts +15 -0
  114. package/dist/esm/migrations/0003_execution_revisions.d.ts.map +1 -0
  115. package/dist/esm/migrations/0003_execution_revisions.js +58 -0
  116. package/dist/esm/migrations/0003_execution_revisions.js.map +1 -0
  117. package/dist/esm/migrations/0004_waiting_request.d.ts +32 -0
  118. package/dist/esm/migrations/0004_waiting_request.d.ts.map +1 -0
  119. package/dist/esm/migrations/0004_waiting_request.js +37 -0
  120. package/dist/esm/migrations/0004_waiting_request.js.map +1 -0
  121. package/dist/esm/test/TestRunStore.d.ts +17 -0
  122. package/dist/esm/test/TestRunStore.d.ts.map +1 -0
  123. package/dist/esm/test/TestRunStore.js +19 -0
  124. package/dist/esm/test/TestRunStore.js.map +1 -0
  125. package/docs/README.md +142 -0
  126. package/docs/api.md +901 -0
  127. package/docs/concepts/durable-values.md +140 -0
  128. package/docs/concepts/execution-revisions.md +53 -0
  129. package/docs/concepts/fencing.md +146 -0
  130. package/docs/concepts/leases.md +154 -0
  131. package/docs/concepts/liveness-evidence.md +142 -0
  132. package/docs/guides/cancel-a-run.md +156 -0
  133. package/docs/guides/claim-and-finish-a-run.md +175 -0
  134. package/docs/guides/compose-the-stores.md +142 -0
  135. package/docs/guides/observe-outcomes.md +182 -0
  136. package/docs/guides/record-step-attempts.md +170 -0
  137. package/docs/guides/recover-a-stalled-run.md +173 -0
  138. package/docs/guides/testing.md +141 -0
  139. package/docs/installation.md +109 -0
  140. package/docs/quickstart.md +201 -0
  141. package/docs/troubleshooting.md +255 -0
  142. package/package.json +183 -3
  143. package/src/AttemptStore.ts +1195 -0
  144. package/src/Heartbeat.ts +47 -0
  145. package/src/Migrations.ts +59 -0
  146. package/src/Ownership.ts +407 -0
  147. package/src/RunStore.ts +2063 -0
  148. package/src/RunStoreMetrics.ts +165 -0
  149. package/src/index.ts +51 -0
  150. package/src/internal/Boundary.ts +130 -0
  151. package/src/internal/Companion.ts +64 -0
  152. package/src/internal/SpanOutcome.ts +71 -0
  153. package/src/migrations/0001_initial.ts +114 -0
  154. package/src/migrations/0002_lineage.ts +38 -0
  155. package/src/migrations/0003_execution_revisions.ts +69 -0
  156. package/src/migrations/0004_waiting_request.ts +39 -0
  157. package/src/test/TestRunStore.ts +22 -0
@@ -0,0 +1,1287 @@
1
+ /**
2
+ * Fenced run persistence and ownership compare-and-swap operations.
3
+ *
4
+ * Operations that accept or verify `LivenessEvidence` (`claim`,
5
+ * `claimAndOwn`, `steal`, `heartbeat`, `requestCancel`, and `recoverClaim`)
6
+ * take the caller's `nowMs` and judge it literally, including the lease cutoff
7
+ * and exact `evidence.checkedAtMs === nowMs` rule. The lease operations
8
+ * (`claim`, `claimAndOwn`, `steal`, `heartbeat`, and `recoverClaim`) also
9
+ * refuse a `nowMs` that runs ahead of the injected Effect `Clock` by more than
10
+ * `heartbeatSkewAllowance`: no composition produces one honestly, because the
11
+ * caller and the store share a process, and such a reading is the one lever
12
+ * that steals a fresh owner or pins a lease past the cutoff. A reading behind
13
+ * the clock is admitted, since it only makes staleness judgments more
14
+ * conservative and the monotonic heartbeat absorbs it. `requestCancel` keeps
15
+ * the literal reading because its timestamp is request data, never a lease
16
+ * predicate. Lifecycle stamps use the Effect `Clock`: `create` writes
17
+ * `created_at_ms`, `activate` writes `started_at_ms` and `heartbeat_at_ms`,
18
+ * and `transitionOwned` writes `finished_at_ms`. A row can therefore carry
19
+ * readings from two clocks. Within the allowance the store trusts the caller's
20
+ * clock, which is right for an in-process library over local SQLite and must
21
+ * not cross a trust boundary.
22
+ *
23
+ * Arbitration itself — who holds the claim, who holds the lease, whether the
24
+ * fence still stands — is delegated to `@smthrs/journal`'s injected
25
+ * `Consensus` strategy. The run row mirrors the strategy's answer in the same
26
+ * write transaction, and every ownership transition the strategy grants is
27
+ * appended to the run's `run-store` companion journal stream in that
28
+ * transaction (see {@link OwnershipTransition}); heartbeats renew the lease
29
+ * and never enter the journal.
30
+ *
31
+ * @since 0.1.0
32
+ */
33
+ import * as Dialect from "@smthrs/database/Dialect";
34
+ import { afterCommit, DatabaseError, DurableWriter, fromSqlError } from "@smthrs/database/DurableWriter";
35
+ import { Consensus, matchesEvidence, sameOwner } from "@smthrs/journal/Consensus";
36
+ import { isCompanionRunId } from "@smthrs/journal/JournalEvent";
37
+ import { OwnerId } from "@smthrs/journal/OwnerId";
38
+ import * as SqlConsensus from "@smthrs/journal/SqlConsensus";
39
+ import * as ObservabilityMetric from "@smthrs/observability/Metric";
40
+ import { Clock, Context, Duration, Effect, Layer, Metric, Schema } from "effect";
41
+ import * as SqlClient from "effect/unstable/sql/SqlClient";
42
+ import { heartbeatSkewAllowance, heartbeatStaleAfter } from "./Heartbeat.js";
43
+ import * as Boundary from "./internal/Boundary.js";
44
+ import * as Companion from "./internal/Companion.js";
45
+ import { observeExit, observeOutcome } from "./internal/SpanOutcome.js";
46
+ import * as RunStoreMetrics from "./RunStoreMetrics.js";
47
+ /** JSON text carrying an arbitrary decoded value. */
48
+ const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
49
+ /**
50
+ * A UTF-16 surrogate without its partner. The journal uses the same ES2022
51
+ * compatible check because `String.prototype.isWellFormed` needs ES2024.
52
+ */
53
+ const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
54
+ /** A non-empty identifier whose UTF-16 encoding survives SQLite unchanged. */
55
+ const wellFormedIdentifier = (value) => value.length > 0 && !loneSurrogate.test(value);
56
+ const NonNegativeSafeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
57
+ const durableIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(Boundary.maximumIdentifierLength), Schema.makeFilter((value) => wellFormedIdentifier(value) && Boundary.isDurableText(value), { title: "durableIdentifier" }));
58
+ /**
59
+ * Maximum nesting admitted for executable run state.
60
+ *
61
+ * @since 1.0.0-rc.0
62
+ * @category constants
63
+ */
64
+ export const maximumRunJsonDepth = 128;
65
+ /**
66
+ * Maximum values and members admitted for executable run state.
67
+ *
68
+ * @since 1.0.0-rc.0
69
+ * @category constants
70
+ */
71
+ export const maximumRunJsonNodes = 100_000;
72
+ const runJsonLimits = {
73
+ maxDepth: maximumRunJsonDepth,
74
+ maxMembers: maximumRunJsonNodes,
75
+ maxNodes: maximumRunJsonNodes
76
+ };
77
+ /**
78
+ * Stable run states understood by the durability layer.
79
+ *
80
+ * @since 0.1.0
81
+ * @category models
82
+ */
83
+ export const RunStatus = Schema.Literals([
84
+ "pending",
85
+ "running",
86
+ "suspended",
87
+ "completed",
88
+ "failed",
89
+ "cancelled"
90
+ ]);
91
+ /**
92
+ * The run states a run never leaves.
93
+ *
94
+ * Named because two rules are written against exactly this set: a settled run
95
+ * refuses new cancellation intent ({@link RequestCancelOutcome}), and a
96
+ * settled row is never claimed, activated, or swept.
97
+ *
98
+ * @since 0.1.0
99
+ * @category models
100
+ */
101
+ export const TerminalRunStatus = Schema.Literals(["completed", "failed", "cancelled"]);
102
+ /**
103
+ * Whether a status is one a run never leaves.
104
+ *
105
+ * @since 0.1.0
106
+ * @category models
107
+ */
108
+ export const isTerminalRunStatus = (status) => status === "completed" || status === "failed" || status === "cancelled";
109
+ /**
110
+ * Stable failure codes surfaced by `RunStore`.
111
+ *
112
+ * @since 0.1.0
113
+ * @category errors
114
+ */
115
+ export const RunStoreErrorCode = Schema.Literals([
116
+ "invalid_run",
117
+ "not_found_row",
118
+ "constraint",
119
+ "decode_failed",
120
+ "persistence_failed"
121
+ ]);
122
+ /**
123
+ * A normalized run persistence failure.
124
+ *
125
+ * Compare-and-swap competition is represented by successful outcome values,
126
+ * never by this error channel.
127
+ *
128
+ * The identity string equals the defining module path, which is how every
129
+ * tagged identity in this package is named.
130
+ *
131
+ * @since 0.1.0
132
+ * @category errors
133
+ */
134
+ export class RunStoreError extends Schema.TaggedError()("@smthrs/run-store/RunStoreError", {
135
+ code: RunStoreErrorCode,
136
+ method: Schema.String,
137
+ message: Schema.String,
138
+ cause: Schema.Unknown
139
+ }) {
140
+ }
141
+ /**
142
+ * An extra compare-and-swap predicate over first-class run metadata.
143
+ *
144
+ * A guard is the seam for lifecycle rules a harness must enforce atomically —
145
+ * `{ cancelRequested: "absent" }` is the "do not finalize a run someone asked
146
+ * to cancel" rule, expressed as SQL rather than as a read-then-write race.
147
+ *
148
+ * @since 0.1.0
149
+ * @category models
150
+ */
151
+ export const TransitionGuard = Schema.Struct({
152
+ cancelRequested: Schema.optional(Schema.Literals(["absent", "present"]))
153
+ });
154
+ /**
155
+ * An ownership transition the consensus strategy granted on a run.
156
+ *
157
+ * `claimed` and `stolen` reserve the run for a claimant, `activated` promotes
158
+ * a claim to ownership, `released` gives a claim or ownership back, and
159
+ * `expired` clears a dead claimant's stale claim. Each is appended as a
160
+ * `flows.consensus.<transition>` fact, with payload `{ runId, owner,
161
+ * grantedAtMs }`, to the run's companion stream
162
+ * `JournalEvent.companionRunId("run-store", runId)` in the transaction that
163
+ * made it; a rolled-back transition leaves no fact. Heartbeats are not
164
+ * transitions and never enter the journal.
165
+ *
166
+ * The facts live beside the run's own stream rather than in it, so a consumer
167
+ * that reads the run's stream by position never sees one, and a rewind or
168
+ * compaction of that stream never truncates one. They are recorded when the
169
+ * caller runs with a `Journal` in its context.
170
+ *
171
+ * @since 1.0.0
172
+ * @category models
173
+ */
174
+ export const OwnershipTransition = Schema.Literals(["claimed", "activated", "released", "stolen", "expired"]);
175
+ /**
176
+ * The name of the run store's journal companion stream: every fact the store
177
+ * records about a run goes to `JournalEvent.companionRunId(companionStream,
178
+ * runId)`.
179
+ *
180
+ * @since 1.0.0
181
+ * @category constants
182
+ */
183
+ export const companionStream = Companion.stream;
184
+ /**
185
+ * The journal event type an ownership transition is appended under.
186
+ *
187
+ * @since 1.0.0
188
+ * @category constants
189
+ */
190
+ export const ownershipEventType = (transition) => `flows.consensus.${transition}`;
191
+ /**
192
+ * Service tag for fenced run persistence.
193
+ *
194
+ * The identity string equals the defining module path, which is how every
195
+ * service in this package is named.
196
+ *
197
+ * @since 0.1.0
198
+ * @category services
199
+ */
200
+ export class RunStore extends Context.Service()("@smthrs/run-store/RunStore") {
201
+ }
202
+ const claimed = (claimedAtMs) => ({ _tag: "Claimed", claimedAtMs });
203
+ const notFound = { _tag: "NotFound" };
204
+ const alreadyClaimed = { _tag: "AlreadyClaimed" };
205
+ const heartbeatFresh = { _tag: "HeartbeatFresh" };
206
+ const snapshotChanged = { _tag: "SnapshotChanged" };
207
+ const evidenceRequired = { _tag: "EvidenceRequired" };
208
+ const activated = { _tag: "Activated" };
209
+ const claimLost = { _tag: "ClaimLost" };
210
+ const abandoned = { _tag: "Abandoned" };
211
+ const recovered = { _tag: "Recovered" };
212
+ const claimFresh = { _tag: "ClaimFresh" };
213
+ const claimChanged = { _tag: "ClaimChanged" };
214
+ const livenessUnconfirmed = { _tag: "LivenessUnconfirmed" };
215
+ const updated = { _tag: "Updated" };
216
+ const fenceLost = { _tag: "FenceLost" };
217
+ const transitioned = { _tag: "Transitioned" };
218
+ const guardFailed = { _tag: "GuardFailed" };
219
+ /**
220
+ * The staleness cutoff in milliseconds, derived from the one definition rather
221
+ * than restated. `Ownership` imports `RunStore`, so the shared constants live
222
+ * in the `Heartbeat` leaf both can reach.
223
+ */
224
+ const heartbeatStaleAfterMs = Duration.toMillis(heartbeatStaleAfter);
225
+ const heartbeatSkewAllowanceMs = Duration.toMillis(heartbeatSkewAllowance);
226
+ const terminalStatuses = new Set(["completed", "failed", "cancelled"]);
227
+ const DatabaseRunRow = Schema.Struct({
228
+ runId: durableIdentifier,
229
+ status: RunStatus,
230
+ createdAtMs: NonNegativeSafeInt,
231
+ startedAtMs: Schema.NullOr(NonNegativeSafeInt),
232
+ finishedAtMs: Schema.NullOr(NonNegativeSafeInt),
233
+ ownerHostId: Schema.NullOr(durableIdentifier),
234
+ ownerPid: Schema.NullOr(NonNegativeSafeInt),
235
+ ownerNonce: Schema.NullOr(durableIdentifier),
236
+ heartbeatAtMs: Schema.NullOr(NonNegativeSafeInt),
237
+ claimHostId: Schema.NullOr(durableIdentifier),
238
+ claimPid: Schema.NullOr(NonNegativeSafeInt),
239
+ claimNonce: Schema.NullOr(durableIdentifier),
240
+ claimedAtMs: Schema.NullOr(NonNegativeSafeInt),
241
+ parentRunId: Schema.NullOr(durableIdentifier),
242
+ cancelRequestedAtMs: Schema.NullOr(NonNegativeSafeInt),
243
+ lineageId: Schema.NullOr(durableIdentifier),
244
+ roundOrdinal: Schema.NullOr(NonNegativeSafeInt),
245
+ stateJson: Schema.String
246
+ });
247
+ const runStoreError = (method, code, message, cause) => new RunStoreError({
248
+ code,
249
+ method,
250
+ message: `${code}: RunStore.${method}: ${message}`,
251
+ cause
252
+ });
253
+ /**
254
+ * The normalized database failure behind a strategy or journal failure, found
255
+ * by following `cause` chains the way the durable writer's retry
256
+ * classification does. A wrapper that carries no database failure at all —
257
+ * a closed journal, say — has no category to report.
258
+ */
259
+ const databaseFailure = (cause) => {
260
+ let current = cause;
261
+ for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) {
262
+ if (current instanceof DatabaseError)
263
+ return current;
264
+ current = current.cause;
265
+ }
266
+ return undefined;
267
+ };
268
+ const persistenceError = (method, cause) => {
269
+ if (Schema.is(RunStoreError)(cause))
270
+ return cause;
271
+ const database = cause instanceof DatabaseError ? cause : databaseFailure(cause.cause);
272
+ const code = database?.code === "constraint"
273
+ ? "constraint"
274
+ : "persistence_failed";
275
+ return runStoreError(method, code, "database operation failed", {
276
+ category: code,
277
+ // Keep the classification for outer transaction retries, never driver
278
+ // causes that may contain executable state or bound SQL values.
279
+ cause: new DatabaseError({ code: database?.code ?? "unknown" })
280
+ });
281
+ };
282
+ const invalidRunError = (method, causeOrField, detail = "violates the durable contract") => runStoreError(method, "invalid_run", "run input is invalid", typeof causeOrField === "string" ? { field: causeOrField, detail } : causeOrField);
283
+ const stateAdmission = (value) => Boundary.admitJsonText(value, runJsonLimits);
284
+ const isJsonString = (value) => Schema.decodeUnknownResult(UnknownFromJsonString)(value)._tag === "Success";
285
+ const validOwner = (value) => Schema.is(OwnerId)(value) &&
286
+ Boundary.isDurableText(value.hostId) &&
287
+ Boundary.isDurableText(value.nonce) &&
288
+ Number.isSafeInteger(value.pid) &&
289
+ value.pid >= 0;
290
+ /** Caller timestamps are SQLite-safe, non-negative integer millisecond readings. */
291
+ const validTimestamp = (value) => Number.isSafeInteger(value) && value >= 0;
292
+ const inertRecord = (input, allowed) => {
293
+ if (typeof input !== "object" || input === null)
294
+ return undefined;
295
+ try {
296
+ const prototype = Object.getPrototypeOf(input);
297
+ if (prototype !== Object.prototype && prototype !== null)
298
+ return undefined;
299
+ const fields = Object.create(null);
300
+ for (const key of Reflect.ownKeys(input)) {
301
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
302
+ if (descriptor === undefined || !descriptor.enumerable)
303
+ continue;
304
+ if (!("value" in descriptor) || (allowed !== undefined && (typeof key !== "string" || !allowed.has(key)))) {
305
+ return undefined;
306
+ }
307
+ if (typeof key === "string")
308
+ fields[key] = descriptor.value;
309
+ }
310
+ return fields;
311
+ }
312
+ catch {
313
+ return undefined;
314
+ }
315
+ };
316
+ const snapshotOwner = (method, field, input) => Effect.suspend(() => {
317
+ const fields = inertRecord(input, new Set(["hostId", "pid", "nonce"]));
318
+ if (fields === undefined) {
319
+ return Effect.fail(invalidRunError(method, field, "must be an inert owner record"));
320
+ }
321
+ const owner = Object.freeze({
322
+ hostId: fields.hostId,
323
+ pid: fields.pid,
324
+ nonce: fields.nonce
325
+ });
326
+ return validOwner(owner)
327
+ ? Effect.succeed(owner)
328
+ : Effect.fail(invalidRunError(method, field, "must be a valid owner identity"));
329
+ });
330
+ const snapshotRunId = (method, input) => typeof input === "string" && wellFormedIdentifier(input) && Boundary.isDurableText(input)
331
+ ? Effect.succeed(input)
332
+ : Effect.fail(invalidRunError(method, "runId", "must be well-formed durable text"));
333
+ const snapshotTimestamp = (method, field, input) => typeof input === "number" && validTimestamp(input)
334
+ ? Effect.succeed(input)
335
+ : Effect.fail(invalidRunError(method, field, "must be a non-negative safe integer"));
336
+ /**
337
+ * Admits a caller's lease reading. Once admitted the reading is authoritative:
338
+ * it is the cutoff the lease predicate compares against and the stamp the row
339
+ * keeps. The store refuses only a reading that runs ahead of its own `Clock`
340
+ * by more than `heartbeatSkewAllowance`, because no composition produces one
341
+ * honestly (the caller and the store share a process) and it is the one lever
342
+ * that steals a fresh owner or pins a lease past the cutoff. A reading behind
343
+ * the clock is admitted: it makes every staleness judgment more conservative,
344
+ * and `heartbeat`'s monotonic write absorbs it.
345
+ */
346
+ const snapshotLeaseReading = (method, field, input, cause) => Effect.gen(function* () {
347
+ const nowMs = yield* snapshotTimestamp(method, field, input);
348
+ const clockMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
349
+ if (nowMs > clockMs + heartbeatSkewAllowanceMs) {
350
+ return yield* Effect.fail(invalidRunError(method, {
351
+ ...cause,
352
+ field,
353
+ nowMs,
354
+ clockMs,
355
+ detail: "runs ahead of the store clock by more than the heartbeat skew allowance"
356
+ }));
357
+ }
358
+ return nowMs;
359
+ });
360
+ const snapshotState = (method, input, cause) => {
361
+ const admitted = stateAdmission(input);
362
+ return admitted.ok
363
+ ? Effect.succeed(admitted.value)
364
+ : Effect.fail(invalidRunError(method, cause, admitted.complaint));
365
+ };
366
+ const snapshotCreateOptions = (input) => Effect.suspend(() => {
367
+ const value = input ?? {};
368
+ const fields = inertRecord(value, new Set(["parentRunId", "lineageId", "roundOrdinal"]));
369
+ if (fields === undefined) {
370
+ return Effect.fail(invalidRunError("create", "options", "must be an inert data record"));
371
+ }
372
+ const rawParent = fields.parentRunId;
373
+ const rawLineage = fields.lineageId;
374
+ const rawRound = fields.roundOrdinal;
375
+ const parentRunId = rawParent === undefined ? null : rawParent;
376
+ const lineageId = rawLineage === undefined ? null : rawLineage;
377
+ const roundOrdinal = rawRound === undefined ? null : rawRound;
378
+ if ((parentRunId !== null &&
379
+ (typeof parentRunId !== "string" ||
380
+ !wellFormedIdentifier(parentRunId) ||
381
+ !Boundary.isDurableText(parentRunId))) ||
382
+ (lineageId !== null &&
383
+ (typeof lineageId !== "string" ||
384
+ !wellFormedIdentifier(lineageId) ||
385
+ !Boundary.isDurableText(lineageId))) ||
386
+ (roundOrdinal !== null && (typeof roundOrdinal !== "number" || !validTimestamp(roundOrdinal))) ||
387
+ (lineageId === null) !== (roundOrdinal === null))
388
+ return Effect.fail(invalidRunError("create", "options", "contains invalid ancestry or lineage"));
389
+ return Effect.succeed(Object.freeze({ parentRunId, lineageId, roundOrdinal }));
390
+ });
391
+ /**
392
+ * Structural admission for a value typed as a required-field record. `RunRow`
393
+ * extends `RunSnapshot`, so a row from `get` must pass wherever an expected
394
+ * snapshot is typed: extra own data fields are ignored, while a non-plain
395
+ * prototype, any enumerable accessor, or a missing or inherited required field
396
+ * is still refused. The required fields come from one guarded own-descriptor snapshot.
397
+ */
398
+ const inertFields = (input, required) => {
399
+ const fields = inertRecord(input);
400
+ return fields !== undefined && required.every((key) => Object.hasOwn(fields, key)) ? fields : undefined;
401
+ };
402
+ const snapshotExpected = (method, input) => Effect.gen(function* () {
403
+ const fields = inertFields(input, ["status", "owner", "heartbeatAtMs"]);
404
+ if (fields === undefined) {
405
+ return yield* Effect.fail(invalidRunError(method, "expected", "must be an inert snapshot record"));
406
+ }
407
+ const status = fields.status;
408
+ const rawOwner = fields.owner;
409
+ const heartbeatAtMs = fields.heartbeatAtMs;
410
+ if (!Schema.is(RunStatus)(status) ||
411
+ (heartbeatAtMs !== null && (typeof heartbeatAtMs !== "number" || !validTimestamp(heartbeatAtMs)))) {
412
+ return yield* Effect.fail(invalidRunError(method, "expected", "contains an invalid status or heartbeat"));
413
+ }
414
+ const owner = rawOwner === null ? null : yield* snapshotOwner(method, "expected.owner", rawOwner);
415
+ if (status === "running") {
416
+ if (owner === null || heartbeatAtMs === null) {
417
+ return yield* Effect.fail(invalidRunError(method, "expected", "violates ownership invariants"));
418
+ }
419
+ return Object.freeze({ status, owner, heartbeatAtMs });
420
+ }
421
+ else {
422
+ if (owner !== null || heartbeatAtMs !== null) {
423
+ return yield* Effect.fail(invalidRunError(method, "expected", "violates ownership invariants"));
424
+ }
425
+ return Object.freeze({ status, owner, heartbeatAtMs });
426
+ }
427
+ });
428
+ const snapshotEvidence = (method, input) => Effect.gen(function* () {
429
+ const fields = inertRecord(input, new Set(["expectedOwner", "checkedAtMs", "kind"]));
430
+ if (fields === undefined) {
431
+ return yield* Effect.fail(invalidRunError(method, "evidence", "must be an inert evidence record"));
432
+ }
433
+ const expectedOwner = yield* snapshotOwner(method, "evidence.expectedOwner", fields.expectedOwner);
434
+ const checkedAtMs = yield* snapshotTimestamp(method, "evidence.checkedAtMs", fields.checkedAtMs);
435
+ const kind = fields.kind;
436
+ if (kind !== "same-host-pid-dead" &&
437
+ kind !== "cross-host-unreachable-stale" &&
438
+ kind !== "lease-expired")
439
+ return yield* Effect.fail(invalidRunError(method, "evidence.kind"));
440
+ return Object.freeze({ expectedOwner, checkedAtMs, kind });
441
+ });
442
+ const snapshotGuard = (input) => Effect.suspend(() => {
443
+ if (input === undefined)
444
+ return Effect.succeed(undefined);
445
+ const fields = inertRecord(input, new Set(["cancelRequested"]));
446
+ if (fields === undefined) {
447
+ return Effect.fail(invalidRunError("transitionOwned", "guard", "must be an inert exact guard"));
448
+ }
449
+ const cancelRequested = fields.cancelRequested;
450
+ if (cancelRequested !== undefined && cancelRequested !== "absent" && cancelRequested !== "present") {
451
+ return Effect.fail(invalidRunError("transitionOwned", "guard.cancelRequested"));
452
+ }
453
+ return Effect.succeed(Object.freeze(cancelRequested === undefined ? {} : { cancelRequested }));
454
+ });
455
+ const ownerFromColumns = (hostId, pid, nonce) => {
456
+ if (hostId === null && pid === null && nonce === null)
457
+ return null;
458
+ if (hostId !== null && pid !== null && nonce !== null) {
459
+ // DatabaseRunRow already admitted all three columns through the stricter
460
+ // durable identifier and safe-integer schemas.
461
+ return Object.freeze({ hostId, pid, nonce });
462
+ }
463
+ return undefined;
464
+ };
465
+ const rowMatchesClaim = (row, claimant, claimedAtMs) => row.claimHostId === claimant.hostId &&
466
+ row.claimPid === claimant.pid &&
467
+ row.claimNonce === claimant.nonce &&
468
+ row.claimedAtMs === claimedAtMs;
469
+ /**
470
+ * The exact predicate the ownership compare-and-swap used to compile into its
471
+ * UPDATE, evaluated in the serialized write transaction instead: the row must
472
+ * still match the caller's expected snapshot.
473
+ */
474
+ const rowMatchesSnapshot = (row, expected) => row.status === expected.status &&
475
+ row.ownerHostId === (expected.owner?.hostId ?? null) &&
476
+ row.ownerPid === (expected.owner?.pid ?? null) &&
477
+ row.ownerNonce === (expected.owner?.nonce ?? null) &&
478
+ row.heartbeatAtMs === expected.heartbeatAtMs;
479
+ const rowOwnedBy = (row, owner) => row.status === "running" &&
480
+ row.ownerHostId === owner.hostId &&
481
+ row.ownerPid === owner.pid &&
482
+ row.ownerNonce === owner.nonce;
483
+ const claimColumnsFree = (row) => row.claimHostId === null && row.claimPid === null && row.claimNonce === null && row.claimedAtMs === null;
484
+ const decodeRunRow = (method, runId, input) => Schema.decodeUnknownEffect(DatabaseRunRow)(input).pipe(Effect.mapError(() => runStoreError(method, "decode_failed", "could not decode flows_runs row", { runId, stage: "row-schema" })), Effect.flatMap((row) => {
485
+ const owner = ownerFromColumns(row.ownerHostId, row.ownerPid, row.ownerNonce);
486
+ const claim = ownerFromColumns(row.claimHostId, row.claimPid, row.claimNonce);
487
+ const admittedState = stateAdmission(row.stateJson);
488
+ const stateJsonValid = isJsonString(row.stateJson);
489
+ const invalidOwner = owner === undefined ||
490
+ (owner === null && row.heartbeatAtMs !== null) ||
491
+ (owner !== null && row.heartbeatAtMs === null) ||
492
+ (row.status === "running" ? owner === null : owner !== null);
493
+ const invalidClaim = claim === undefined ||
494
+ (claim === null && row.claimedAtMs !== null) ||
495
+ (claim !== null && row.claimedAtMs === null);
496
+ if (invalidOwner || invalidClaim || !admittedState.ok) {
497
+ // The cause is published to logs, spans, and telemetry. Executable
498
+ // state may carry credentials, so report only the invariants read here.
499
+ return Effect.fail(runStoreError(method, "decode_failed", "flows_runs row violates durable invariants", {
500
+ runId,
501
+ status: row.status,
502
+ heartbeatAtMs: row.heartbeatAtMs,
503
+ claimedAtMs: row.claimedAtMs,
504
+ hasClaimColumns: row.claimHostId !== null || row.claimPid !== null || row.claimNonce !== null,
505
+ hasOwnerColumns: row.ownerHostId !== null || row.ownerPid !== null || row.ownerNonce !== null,
506
+ stateJsonValid
507
+ }));
508
+ }
509
+ return Effect.succeed(Object.freeze({
510
+ runId: row.runId,
511
+ status: row.status,
512
+ createdAtMs: row.createdAtMs,
513
+ startedAtMs: row.startedAtMs,
514
+ finishedAtMs: row.finishedAtMs,
515
+ owner,
516
+ heartbeatAtMs: row.heartbeatAtMs,
517
+ claim,
518
+ claimedAtMs: row.claimedAtMs,
519
+ parentRunId: row.parentRunId,
520
+ cancelRequestedAtMs: row.cancelRequestedAtMs,
521
+ lineageId: row.lineageId,
522
+ roundOrdinal: row.roundOrdinal,
523
+ stateJson: row.stateJson
524
+ }));
525
+ }));
526
+ /**
527
+ * Rows that belong to the logical run containing `runId`: every round that
528
+ * names the resolved lineage, plus the root itself when its own lineage
529
+ * columns are null or name that lineage. A same-named run in an independent
530
+ * lineage is excluded. Shared by every lineage read and write so membership
531
+ * cannot drift between them.
532
+ */
533
+ const lineageMembership = (sql, runId) => sql `(
534
+ lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
535
+ OR (
536
+ run_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
537
+ AND (
538
+ lineage_id IS NULL
539
+ OR lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
540
+ )
541
+ )
542
+ )`;
543
+ /**
544
+ * Names the outcome of a guarded cancellation UPDATE that matched nothing.
545
+ *
546
+ * `rows` are the lineage members in round order; an absent set is `NotFound`.
547
+ * Status is read before the request column, so a settled run reports how it
548
+ * ended even when its own closing request is still on the row. A live
549
+ * unrequested row cannot survive the UPDATE inside one serialized writer
550
+ * transaction, so it is a persistence invariant failure rather than a retry.
551
+ */
552
+ const classifyCancellationMiss = (method, runId, rows) => Effect.gen(function* () {
553
+ let outcome = notFound;
554
+ for (const row of rows) {
555
+ const status = yield* Schema.decodeUnknownEffect(RunStatus)(row.status).pipe(Effect.mapError((cause) => runStoreError(method, "decode_failed", "could not decode flows_runs status", cause)));
556
+ if (isTerminalRunStatus(status)) {
557
+ // A terminal predecessor never hides a live round's request.
558
+ if (outcome._tag !== "AlreadyRequested")
559
+ outcome = { _tag: "Terminal", status };
560
+ continue;
561
+ }
562
+ if (row.requestedAtMs === null) {
563
+ return yield* Effect.fail(runStoreError(method, "persistence_failed", "guarded cancellation update missed a live unrequested row", {
564
+ runId,
565
+ stage: "write-invariant"
566
+ }));
567
+ }
568
+ outcome = { _tag: "AlreadyRequested", requestedAtMs: Number(row.requestedAtMs) };
569
+ }
570
+ return outcome;
571
+ });
572
+ const selectRun = (sql, runId, mode = "single") => sql `
573
+ SELECT
574
+ run_id AS "runId",
575
+ status AS "status",
576
+ created_at_ms AS "createdAtMs",
577
+ started_at_ms AS "startedAtMs",
578
+ finished_at_ms AS "finishedAtMs",
579
+ owner_host_id AS "ownerHostId",
580
+ owner_pid AS "ownerPid",
581
+ owner_nonce AS "ownerNonce",
582
+ heartbeat_at_ms AS "heartbeatAtMs",
583
+ claim_host_id AS "claimHostId",
584
+ claim_pid AS "claimPid",
585
+ claim_nonce AS "claimNonce",
586
+ claimed_at_ms AS "claimedAtMs",
587
+ parent_run_id AS "parentRunId",
588
+ cancel_requested_at_ms AS "cancelRequestedAtMs",
589
+ lineage_id AS "lineageId",
590
+ round_ordinal AS "roundOrdinal",
591
+ state_json AS "stateJson"
592
+ FROM flows_runs
593
+ WHERE ${mode === "latest"
594
+ ? sql `run_id = COALESCE(
595
+ (SELECT run_id FROM flows_runs
596
+ WHERE lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
597
+ ORDER BY round_ordinal DESC LIMIT 1),
598
+ (SELECT run_id FROM flows_runs WHERE run_id = ${runId})
599
+ )`
600
+ : mode === "lineage"
601
+ ? lineageMembership(sql, runId)
602
+ : sql `run_id = ${runId}`}
603
+ ORDER BY COALESCE(round_ordinal, 0), run_id
604
+ `;
605
+ const classifyClaimLoss = (row, nowMs) => {
606
+ if (row === undefined)
607
+ return notFound;
608
+ if (row.claimHostId !== null)
609
+ return alreadyClaimed;
610
+ if (row.status === "running" &&
611
+ row.heartbeatAtMs !== null &&
612
+ row.heartbeatAtMs >= nowMs - heartbeatStaleAfterMs) {
613
+ return heartbeatFresh;
614
+ }
615
+ return snapshotChanged;
616
+ };
617
+ const evidenceMatches = (expected, claimant, checkedAtMs, evidence) => expected.status === "running" &&
618
+ expected.owner !== null &&
619
+ matchesEvidence(expected.owner, claimant, checkedAtMs, evidence);
620
+ /**
621
+ * Constructs the production `RunStore` implementation.
622
+ *
623
+ * `state_json` is executable state: it is decoded and re-entered on every
624
+ * resume, so it is persisted and returned byte-for-byte. Nothing rewrites it
625
+ * on the way through — a redactor here would silently change what the flow
626
+ * re-reads (issue #72). `Schema.Redacted` hides inspection but allows schema
627
+ * JSON encoding by default. Use `Schema.Redacted(Schema.String, {
628
+ * disallowJsonEncode: true })` to refuse JSON encoding, or exclude credentials
629
+ * from the persistence schema and store a secret reference resolved from a
630
+ * secret provider on resume. Publication hygiene belongs on journal-event
631
+ * and export surfaces.
632
+ *
633
+ * `state_json` and `flows_attempts.outcome_json` therefore hold unredacted
634
+ * run inputs and results, so the run's SQLite file (`.flows/*.db`) must be
635
+ * handled as a secret store.
636
+ *
637
+ * @since 0.1.0
638
+ * @category constructors
639
+ */
640
+ export const make = Effect.gen(function* () {
641
+ const sql = yield* Effect.service(SqlClient.SqlClient);
642
+ const writer = yield* DurableWriter;
643
+ const consensus = yield* Consensus;
644
+ const write = (method, effect) => writer.write(effect).pipe(Effect.mapError((cause) => persistenceError(method, cause)));
645
+ /** Appends an ownership transition to the run's companion stream (see {@link OwnershipTransition}). */
646
+ const recordTransition = (runId, transition, owner, grantedAtMs) => Companion.append(runId, ownershipEventType(transition), {
647
+ owner: { hostId: owner.hostId, pid: owner.pid, nonce: owner.nonce },
648
+ grantedAtMs
649
+ });
650
+ // A bare SELECT needs no write transaction and no replay; only the error
651
+ // vocabulary stays shared with `write`.
652
+ const read = (method, effect) => effect.pipe(Effect.mapError((cause) => persistenceError(method, fromSqlError(cause))));
653
+ const create = Effect.fn("RunStore.create")((runIdInput, stateInput, optionsInput) => Effect.gen(function* () {
654
+ const runId = yield* snapshotRunId("create", runIdInput);
655
+ if (isCompanionRunId(runId)) {
656
+ return yield* Effect.fail(invalidRunError("create", "runId", "names a reserved journal companion stream"));
657
+ }
658
+ const { lineageId, parentRunId, roundOrdinal } = yield* snapshotCreateOptions(optionsInput);
659
+ // This cause is published to logs, spans, and telemetry. Executable
660
+ // state may carry credentials, so include its shape but never its text.
661
+ const stateJson = yield* snapshotState("create", stateInput, {
662
+ runId,
663
+ parentRunId,
664
+ lineageId,
665
+ roundOrdinal,
666
+ stateJsonLength: typeof stateInput === "string" ? stateInput.length : null,
667
+ stateJsonValid: isJsonString(stateInput)
668
+ });
669
+ yield* Effect.annotateCurrentSpan({ runId });
670
+ const createdAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
671
+ yield* write("create", sql `
672
+ INSERT INTO flows_runs (
673
+ run_id,
674
+ status,
675
+ created_at_ms,
676
+ started_at_ms,
677
+ finished_at_ms,
678
+ owner_host_id,
679
+ owner_pid,
680
+ owner_nonce,
681
+ heartbeat_at_ms,
682
+ claim_host_id,
683
+ claim_pid,
684
+ claim_nonce,
685
+ claimed_at_ms,
686
+ parent_run_id,
687
+ lineage_id,
688
+ round_ordinal,
689
+ state_json
690
+ ) VALUES (
691
+ ${runId},
692
+ 'pending',
693
+ ${createdAtMs},
694
+ NULL,
695
+ NULL,
696
+ NULL,
697
+ NULL,
698
+ NULL,
699
+ NULL,
700
+ NULL,
701
+ NULL,
702
+ NULL,
703
+ NULL,
704
+ ${parentRunId},
705
+ ${lineageId},
706
+ ${roundOrdinal},
707
+ ${stateJson}
708
+ )
709
+ `.pipe(Effect.asVoid));
710
+ }).pipe(observeExit));
711
+ const get = Effect.fn("RunStore.get")((runIdInput) => Effect.gen(function* () {
712
+ const runId = yield* snapshotRunId("get", runIdInput);
713
+ yield* Effect.annotateCurrentSpan({ runId });
714
+ const rows = yield* read("get", selectRun(sql, runId));
715
+ return rows[0] === undefined
716
+ ? yield* Effect.fail(runStoreError("get", "not_found_row", `run ${runId} was not found`, { runId }))
717
+ : yield* decodeRunRow("get", runId, rows[0]);
718
+ }).pipe(observeExit));
719
+ const lineage = Effect.fn("RunStore.lineage")((runIdInput) => Effect.gen(function* () {
720
+ const runId = yield* snapshotRunId("lineage", runIdInput);
721
+ yield* Effect.annotateCurrentSpan({ runId });
722
+ const rows = yield* read("lineage", selectRun(sql, runId, "lineage"));
723
+ return yield* Effect.forEach(rows, (row) => decodeRunRow("lineage", row.runId, row));
724
+ }).pipe(observeExit));
725
+ const latestRound = Effect.fn("RunStore.latestRound")((runIdInput) => Effect.gen(function* () {
726
+ const runId = yield* snapshotRunId("latestRound", runIdInput);
727
+ yield* Effect.annotateCurrentSpan({ runId });
728
+ const rows = yield* read("latestRound", selectRun(sql, runId, "latest"));
729
+ return rows[0] === undefined
730
+ ? yield* Effect.fail(runStoreError("latestRound", "not_found_row", `run ${runId} was not found`, { runId }))
731
+ : yield* decodeRunRow("latestRound", rows[0].runId, rows[0]);
732
+ }).pipe(observeExit));
733
+ const requestCancel = Effect.fn("RunStore.requestCancel")((runIdInput, nowMsInput) => Effect.gen(function* () {
734
+ const runId = yield* snapshotRunId("requestCancel", runIdInput);
735
+ const nowMs = yield* snapshotTimestamp("requestCancel", "nowMs", nowMsInput);
736
+ yield* Effect.annotateCurrentSpan({ runId });
737
+ return yield* write("requestCancel", Effect.gen(function* () {
738
+ // The status predicate is part of the compare-and-swap rather than
739
+ // a read-then-write check: a run that settles between a caller's
740
+ // read and this UPDATE must lose the write, not race it. The writer
741
+ // serializes the whole transaction, so a miss is classified by one
742
+ // read of the same columns and never retried.
743
+ const rows = yield* sql `
744
+ UPDATE flows_runs
745
+ SET cancel_requested_at_ms = ${nowMs}
746
+ WHERE run_id = ${runId}
747
+ AND cancel_requested_at_ms IS NULL
748
+ AND status NOT IN ('completed', 'failed', 'cancelled')
749
+ RETURNING cancel_requested_at_ms AS "requestedAtMs"
750
+ `;
751
+ if (rows[0] !== undefined) {
752
+ return { _tag: "CancelRequested", requestedAtMs: Number(rows[0].requestedAtMs) };
753
+ }
754
+ const current = yield* sql `
755
+ SELECT cancel_requested_at_ms AS "requestedAtMs", status AS "status"
756
+ FROM flows_runs WHERE run_id = ${runId}
757
+ `;
758
+ return yield* classifyCancellationMiss("requestCancel", runId, current);
759
+ }));
760
+ }).pipe(observeOutcome()));
761
+ const requestCancelLineage = Effect.fn("RunStore.requestCancelLineage")((runIdInput, nowMsInput) => Effect.gen(function* () {
762
+ const runId = yield* snapshotRunId("requestCancelLineage", runIdInput);
763
+ const nowMs = yield* snapshotTimestamp("requestCancelLineage", "nowMs", nowMsInput);
764
+ yield* Effect.annotateCurrentSpan({ runId });
765
+ return yield* write("requestCancelLineage", Effect.gen(function* () {
766
+ // One set-based guarded UPDATE over the lineage: cost follows the
767
+ // members' metadata, never their state payloads, and a settled
768
+ // history of any length adds no statements.
769
+ const rows = yield* sql `
770
+ UPDATE flows_runs
771
+ SET cancel_requested_at_ms = ${nowMs}
772
+ WHERE ${lineageMembership(sql, runId)}
773
+ AND cancel_requested_at_ms IS NULL
774
+ AND status NOT IN ('completed', 'failed', 'cancelled')
775
+ RETURNING cancel_requested_at_ms AS "requestedAtMs"
776
+ `;
777
+ if (rows[0] !== undefined) {
778
+ return { _tag: "CancelRequested", requestedAtMs: Number(rows[0].requestedAtMs) };
779
+ }
780
+ // A previous request on any live round dominates a terminal
781
+ // predecessor; otherwise the latest round's ending is reported.
782
+ const members = yield* sql `
783
+ SELECT cancel_requested_at_ms AS "requestedAtMs", status AS "status"
784
+ FROM flows_runs
785
+ WHERE ${lineageMembership(sql, runId)}
786
+ ORDER BY COALESCE(round_ordinal, 0), run_id
787
+ `;
788
+ return yield* classifyCancellationMiss("requestCancelLineage", runId, members);
789
+ }));
790
+ }).pipe(observeOutcome()));
791
+ const claim = Effect.fn("RunStore.claim")((runIdInput, expectedInput, claimantInput, nowMsInput) => Effect.gen(function* () {
792
+ const runId = yield* snapshotRunId("claim", runIdInput);
793
+ const expected = yield* snapshotExpected("claim", expectedInput);
794
+ const claimant = yield* snapshotOwner("claim", "claimant", claimantInput);
795
+ const nowMs = yield* snapshotLeaseReading("claim", "nowMs", nowMsInput, { runId });
796
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
797
+ return yield* write("claim", Effect.gen(function* () {
798
+ // `claim` never admits a running run, so it needs no staleness
799
+ // test: `pending` and `suspended` exclude it. `claimAndOwn` is the
800
+ // method that genuinely needs one, because it does admit `running`.
801
+ // The snapshot predicate is evaluated inside the serialized write
802
+ // transaction, so it is exactly the compare-and-swap it replaces.
803
+ const row = (yield* selectRun(sql, runId))[0];
804
+ const admissible = row !== undefined &&
805
+ (row.status === "pending" || row.status === "suspended") &&
806
+ rowMatchesSnapshot(row, expected) &&
807
+ claimColumnsFree(row);
808
+ if (!admissible)
809
+ return classifyClaimLoss(row, nowMs);
810
+ const grant = yield* consensus.claim(runId, claimant, nowMs);
811
+ if (grant._tag === "Rejected") {
812
+ // The strategy is authoritative: the row admitted what the lease
813
+ // refused, and the loss is classified from the row exactly as a
814
+ // failed compare-and-swap always was.
815
+ return classifyClaimLoss(row, nowMs);
816
+ }
817
+ yield* sql `
818
+ UPDATE flows_runs
819
+ SET
820
+ claim_host_id = ${claimant.hostId},
821
+ claim_pid = ${claimant.pid},
822
+ claim_nonce = ${claimant.nonce},
823
+ claimed_at_ms = ${grant.grantedAtMs}
824
+ WHERE run_id = ${runId}
825
+ `;
826
+ yield* recordTransition(runId, "claimed", claimant, grant.grantedAtMs);
827
+ return claimed(grant.grantedAtMs);
828
+ }));
829
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.claim[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "claim" }))));
830
+ const claimAndOwn = Effect.fn("RunStore.claimAndOwn")((runIdInput, expectedInput, ownerInput, nowMsInput, evidenceInput) => Effect.gen(function* () {
831
+ const runId = yield* snapshotRunId("claimAndOwn", runIdInput);
832
+ const expected = yield* snapshotExpected("claimAndOwn", expectedInput);
833
+ const owner = yield* snapshotOwner("claimAndOwn", "owner", ownerInput);
834
+ const nowMs = yield* snapshotLeaseReading("claimAndOwn", "nowMs", nowMsInput, { runId });
835
+ const evidence = evidenceInput === undefined
836
+ ? undefined
837
+ : yield* snapshotEvidence("claimAndOwn", evidenceInput);
838
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId });
839
+ return yield* Effect.suspend(() => {
840
+ if (expected.status === "running" &&
841
+ !sameOwner(expected.owner, owner) &&
842
+ !(evidence !== undefined && evidenceMatches(expected, owner, nowMs, evidence))) {
843
+ return read("claimAndOwn", selectRun(sql, runId)).pipe(Effect.map((current) => {
844
+ const row = current[0];
845
+ // Classify the lease before upgrading an unchanged stale
846
+ // snapshot. Evidence cannot displace a fresh heartbeat, so
847
+ // naming evidence first would advise a retry the SQL must refuse.
848
+ const loss = classifyClaimLoss(row, nowMs);
849
+ return loss === snapshotChanged && row !== undefined && rowMatchesSnapshot(row, expected)
850
+ ? evidenceRequired
851
+ : loss;
852
+ }));
853
+ }
854
+ return write("claimAndOwn", Effect.gen(function* () {
855
+ const row = (yield* selectRun(sql, runId))[0];
856
+ const admissible = row !== undefined &&
857
+ (row.status === "pending" || row.status === "suspended" || row.status === "running") &&
858
+ rowMatchesSnapshot(row, expected) &&
859
+ claimColumnsFree(row) &&
860
+ (row.status !== "running" ||
861
+ row.heartbeatAtMs === null ||
862
+ row.heartbeatAtMs < nowMs - heartbeatStaleAfterMs);
863
+ if (!admissible)
864
+ return classifyClaimLoss(row, nowMs);
865
+ const expectedOwner = expected.owner;
866
+ let grant;
867
+ let transition = "claimed";
868
+ if (expectedOwner === null) {
869
+ grant = yield* consensus.claim(runId, owner, nowMs);
870
+ }
871
+ else if (sameOwner(expectedOwner, owner)) {
872
+ // Re-owning one's own stale run: the stale lease is released
873
+ // and re-granted under a fresh generation (R4).
874
+ yield* consensus.release(runId, owner);
875
+ yield* recordTransition(runId, "released", owner, null);
876
+ grant = yield* consensus.claim(runId, owner, nowMs);
877
+ }
878
+ else {
879
+ // Admission above proved the evidence matches, so the steal is
880
+ // the strategy's evidence-gated claim.
881
+ grant = yield* consensus.steal(runId, owner, nowMs, evidence);
882
+ transition = "stolen";
883
+ }
884
+ if (grant._tag === "Rejected")
885
+ return classifyClaimLoss(row, nowMs);
886
+ const activation = yield* consensus.activate(runId, owner, grant.grantedAtMs, nowMs);
887
+ if (activation._tag === "Lost") {
888
+ // The strategy revoked the grant it just made — its lease moved
889
+ // on under the transaction — so the fresh grant is released and
890
+ // the loss classified from the row.
891
+ yield* consensus.release(runId, owner);
892
+ return classifyClaimLoss(row, nowMs);
893
+ }
894
+ yield* recordTransition(runId, transition, owner, grant.grantedAtMs);
895
+ yield* recordTransition(runId, "activated", owner, grant.grantedAtMs);
896
+ yield* sql `
897
+ UPDATE flows_runs
898
+ SET
899
+ status = 'running',
900
+ started_at_ms = COALESCE(started_at_ms, ${nowMs}),
901
+ finished_at_ms = NULL,
902
+ owner_host_id = ${owner.hostId},
903
+ owner_pid = ${owner.pid},
904
+ owner_nonce = ${owner.nonce},
905
+ heartbeat_at_ms = ${nowMs}
906
+ WHERE run_id = ${runId}
907
+ `;
908
+ return activated;
909
+ }));
910
+ });
911
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.claimAndOwn[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "claim_and_own" }))));
912
+ const activate = Effect.fn("RunStore.activate")((runIdInput, claimantInput, claimedAtMsInput, expectedInput) => Effect.gen(function* () {
913
+ const runId = yield* snapshotRunId("activate", runIdInput);
914
+ const claimant = yield* snapshotOwner("activate", "claimant", claimantInput);
915
+ const claimedAtMs = yield* snapshotTimestamp("activate", "claimedAtMs", claimedAtMsInput);
916
+ const expected = yield* snapshotExpected("activate", expectedInput);
917
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
918
+ const activatedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
919
+ return yield* write("activate", Effect.gen(function* () {
920
+ const row = (yield* selectRun(sql, runId))[0];
921
+ if (row === undefined || !rowMatchesClaim(row, claimant, claimedAtMs))
922
+ return claimLost;
923
+ const clearClaim = sql `
924
+ UPDATE flows_runs
925
+ SET
926
+ claim_host_id = NULL,
927
+ claim_pid = NULL,
928
+ claim_nonce = NULL,
929
+ claimed_at_ms = NULL
930
+ WHERE run_id = ${runId}
931
+ AND claim_host_id = ${claimant.hostId}
932
+ AND claim_pid = ${claimant.pid}
933
+ AND claim_nonce = ${claimant.nonce}
934
+ AND claimed_at_ms = ${claimedAtMs}
935
+ `;
936
+ if (!rowMatchesSnapshot(row, expected)) {
937
+ // The claim is intact but the guarded snapshot moved on: clear
938
+ // the claim, exactly as the compare-and-swap always did, and
939
+ // release the strategy's matching claim so the two stay aligned.
940
+ yield* consensus.release(runId, claimant);
941
+ yield* recordTransition(runId, "released", claimant, claimedAtMs);
942
+ yield* clearClaim;
943
+ return snapshotChanged;
944
+ }
945
+ const activation = yield* consensus.activate(runId, claimant, claimedAtMs, activatedAtMs);
946
+ if (activation._tag === "Lost") {
947
+ // The strategy no longer holds this claim though the row still
948
+ // shows it; the strategy is authoritative, so the stale claim
949
+ // columns are cleared and the claim reported lost.
950
+ yield* clearClaim;
951
+ return claimLost;
952
+ }
953
+ yield* sql `
954
+ UPDATE flows_runs
955
+ SET
956
+ status = 'running',
957
+ started_at_ms = COALESCE(started_at_ms, ${activatedAtMs}),
958
+ finished_at_ms = NULL,
959
+ owner_host_id = ${claimant.hostId},
960
+ owner_pid = ${claimant.pid},
961
+ owner_nonce = ${claimant.nonce},
962
+ heartbeat_at_ms = ${activatedAtMs},
963
+ claim_host_id = NULL,
964
+ claim_pid = NULL,
965
+ claim_nonce = NULL,
966
+ claimed_at_ms = NULL
967
+ WHERE run_id = ${runId}
968
+ `;
969
+ yield* recordTransition(runId, "activated", claimant, claimedAtMs);
970
+ return activated;
971
+ }));
972
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.activate[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "activate" }))));
973
+ const abandonClaim = Effect.fn("RunStore.abandonClaim")((runIdInput, claimantInput, claimedAtMsInput) => Effect.gen(function* () {
974
+ const runId = yield* snapshotRunId("abandonClaim", runIdInput);
975
+ const claimant = yield* snapshotOwner("abandonClaim", "claimant", claimantInput);
976
+ const claimedAtMs = yield* snapshotTimestamp("abandonClaim", "claimedAtMs", claimedAtMsInput);
977
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
978
+ return yield* write("abandonClaim", Effect.gen(function* () {
979
+ const rows = yield* sql `
980
+ UPDATE flows_runs
981
+ SET
982
+ claim_host_id = NULL,
983
+ claim_pid = NULL,
984
+ claim_nonce = NULL,
985
+ claimed_at_ms = NULL
986
+ WHERE run_id = ${runId}
987
+ AND claim_host_id = ${claimant.hostId}
988
+ AND claim_pid = ${claimant.pid}
989
+ AND claim_nonce = ${claimant.nonce}
990
+ AND claimed_at_ms = ${claimedAtMs}
991
+ RETURNING run_id AS "runId"
992
+ `;
993
+ if (rows.length === 0)
994
+ return claimLost;
995
+ yield* consensus.release(runId, claimant);
996
+ yield* recordTransition(runId, "released", claimant, claimedAtMs);
997
+ return abandoned;
998
+ }));
999
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.abandonClaim[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "abandon_claim" }))));
1000
+ const recoverClaim = Effect.fn("RunStore.recoverClaim")((runIdInput, staleClaimantInput, claimedAtMsInput, observerInput, nowMsInput, evidenceInput) => Effect.gen(function* () {
1001
+ const runId = yield* snapshotRunId("recoverClaim", runIdInput);
1002
+ const staleClaimant = yield* snapshotOwner("recoverClaim", "staleClaimant", staleClaimantInput);
1003
+ const claimedAtMs = yield* snapshotTimestamp("recoverClaim", "claimedAtMs", claimedAtMsInput);
1004
+ const observer = yield* snapshotOwner("recoverClaim", "observer", observerInput);
1005
+ const nowMs = yield* snapshotLeaseReading("recoverClaim", "nowMs", nowMsInput, { runId, claimedAtMs });
1006
+ const evidence = yield* snapshotEvidence("recoverClaim", evidenceInput);
1007
+ yield* Effect.annotateCurrentSpan({ runId, observerHostId: observer.hostId });
1008
+ return yield* Effect.suspend(() => {
1009
+ if (!matchesEvidence(staleClaimant, observer, nowMs, evidence)) {
1010
+ return Effect.succeed(livenessUnconfirmed);
1011
+ }
1012
+ return write("recoverClaim", Effect.gen(function* () {
1013
+ const row = (yield* selectRun(sql, runId))[0];
1014
+ if (row === undefined)
1015
+ return notFound;
1016
+ if (!rowMatchesClaim(row, staleClaimant, claimedAtMs))
1017
+ return claimChanged;
1018
+ if (claimedAtMs >= nowMs - heartbeatStaleAfterMs)
1019
+ return claimFresh;
1020
+ const outcome = yield* consensus.recover(runId, staleClaimant, claimedAtMs, observer, nowMs, evidence);
1021
+ if (outcome._tag === "Rejected") {
1022
+ // The strategy refused what the row admitted: as the strategy
1023
+ // knows it, the claim has moved on.
1024
+ return claimChanged;
1025
+ }
1026
+ yield* sql `
1027
+ UPDATE flows_runs
1028
+ SET
1029
+ claim_host_id = NULL,
1030
+ claim_pid = NULL,
1031
+ claim_nonce = NULL,
1032
+ claimed_at_ms = NULL
1033
+ WHERE run_id = ${runId}
1034
+ `;
1035
+ yield* recordTransition(runId, "expired", staleClaimant, claimedAtMs);
1036
+ return recovered;
1037
+ }));
1038
+ });
1039
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.recoverClaim[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "recover_claim" }))));
1040
+ const renewLease = (method) => Effect.fn(`RunStore.${method}`)((runIdInput, ownerInput, nowMsInput) => Effect.gen(function* () {
1041
+ const runId = yield* snapshotRunId(method, runIdInput);
1042
+ const owner = yield* snapshotOwner(method, "owner", ownerInput);
1043
+ const nowMs = yield* snapshotLeaseReading(method, "nowMs", nowMsInput, { runId });
1044
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId });
1045
+ return yield* write(method, Effect.gen(function* () {
1046
+ // The pulse drives the strategy's lease renewal; the row mirrors
1047
+ // the stamp the strategy recorded. The lease timestamp is
1048
+ // monotonic: a heartbeat that arrives late — delayed past a newer
1049
+ // one from the same owner — never moves it backwards and never
1050
+ // makes a live run look stale to `claimAndOwn`/`steal`'s cutoff. A
1051
+ // late clock reading still reports `Updated`: the fence held, and
1052
+ // the write proves liveness regardless of which caller clock
1053
+ // reading it carried. Prior art: Temporal's shard `rangeID` only
1054
+ // ever advances (`reference/temporal/service/history/shard/context_impl.go`,
1055
+ // `renewRangeLocked`). Heartbeats never enter the journal.
1056
+ const outcome = yield* consensus[method](runId, owner, nowMs);
1057
+ if (outcome._tag === "Lost") {
1058
+ const current = yield* selectRun(sql, runId);
1059
+ return current.length === 0 ? notFound : fenceLost;
1060
+ }
1061
+ // The mirror write is verified: a renewed lease over a row this
1062
+ // owner no longer holds is a lease/row disagreement, and a success
1063
+ // that wrote nothing would hide it. RETURNING keeps the old row
1064
+ // answer — `FenceLost` when the row moved on, `NotFound` when it
1065
+ // is gone.
1066
+ const rows = yield* sql `
1067
+ UPDATE flows_runs
1068
+ SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${outcome.heartbeatAtMs})
1069
+ WHERE run_id = ${runId}
1070
+ AND status = 'running'
1071
+ AND owner_host_id = ${owner.hostId}
1072
+ AND owner_pid = ${owner.pid}
1073
+ AND owner_nonce = ${owner.nonce}
1074
+ RETURNING run_id AS "runId"
1075
+ `;
1076
+ if (rows.length > 0)
1077
+ return updated;
1078
+ const current = yield* selectRun(sql, runId);
1079
+ return current.length === 0 ? notFound : fenceLost;
1080
+ }));
1081
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.heartbeat[outcome._tag], RunStoreMetrics.heartbeats)));
1082
+ const heartbeat = renewLease("heartbeat");
1083
+ const reconfirm = renewLease("reconfirm");
1084
+ const transitionOwned = Effect.fn("RunStore.transitionOwned")((runIdInput, ownerInput, toStatusInput, stateInput, guardInput) => Effect.gen(function* () {
1085
+ const runId = yield* snapshotRunId("transitionOwned", runIdInput);
1086
+ const owner = yield* snapshotOwner("transitionOwned", "owner", ownerInput);
1087
+ if (!Schema.is(RunStatus)(toStatusInput) || toStatusInput === "pending") {
1088
+ return yield* Effect.fail(invalidRunError("transitionOwned", "toStatus"));
1089
+ }
1090
+ const toStatus = toStatusInput;
1091
+ const state = stateInput === undefined
1092
+ ? null
1093
+ : yield* snapshotState("transitionOwned", stateInput, {
1094
+ runId,
1095
+ stateJsonLength: typeof stateInput === "string" ? stateInput.length : null,
1096
+ stateJsonValid: isJsonString(stateInput)
1097
+ });
1098
+ const guard = yield* snapshotGuard(guardInput);
1099
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId, to: toStatus });
1100
+ const transitionedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
1101
+ const outcome = yield* write("transitionOwned", Effect.gen(function* () {
1102
+ // The fence check is the strategy's guard (R3); the row check keeps
1103
+ // the mirror honest. Both run in the serialized write transaction,
1104
+ // so a concurrent cancellation request or reclaim can never slip
1105
+ // between check and write.
1106
+ const held = yield* consensus.guard(runId, owner).pipe(Effect.as(true), Effect.catch((cause) => cause.code === "fence_lost" ? Effect.succeed(false) : Effect.fail(cause)));
1107
+ const row = (yield* selectRun(sql, runId))[0];
1108
+ if (row === undefined)
1109
+ return notFound;
1110
+ if (!held || !rowOwnedBy(row, owner))
1111
+ return fenceLost;
1112
+ const guardBlocked = (guard?.cancelRequested === "absent" && row.cancelRequestedAtMs !== null) ||
1113
+ (guard?.cancelRequested === "present" && row.cancelRequestedAtMs === null);
1114
+ if (guardBlocked)
1115
+ return guardFailed;
1116
+ if (toStatus === "running") {
1117
+ yield* sql `
1118
+ UPDATE flows_runs
1119
+ SET
1120
+ status = 'running',
1121
+ finished_at_ms = NULL,
1122
+ state_json = COALESCE(${state}, state_json)
1123
+ WHERE run_id = ${runId}
1124
+ `;
1125
+ return transitioned;
1126
+ }
1127
+ yield* sql `
1128
+ UPDATE flows_runs
1129
+ SET
1130
+ status = ${toStatus},
1131
+ finished_at_ms = ${terminalStatuses.has(toStatus) ? transitionedAtMs : null},
1132
+ owner_host_id = NULL,
1133
+ owner_pid = NULL,
1134
+ owner_nonce = NULL,
1135
+ heartbeat_at_ms = NULL,
1136
+ claim_host_id = NULL,
1137
+ claim_pid = NULL,
1138
+ claim_nonce = NULL,
1139
+ claimed_at_ms = NULL,
1140
+ state_json = COALESCE(${state}, state_json)
1141
+ WHERE run_id = ${runId}
1142
+ `;
1143
+ yield* consensus.release(runId, owner);
1144
+ yield* recordTransition(runId, "released", owner, null);
1145
+ if (terminalStatuses.has(toStatus)) {
1146
+ yield* afterCommit(Metric.update(ObservabilityMetric.runThroughput, 1), sql);
1147
+ }
1148
+ return transitioned;
1149
+ }));
1150
+ return outcome;
1151
+ }).pipe(observeOutcome((outcome) => Metric.withAttributes(RunStoreMetrics.transition[outcome._tag], {
1152
+ to: toStatusInput
1153
+ }), Metric.withAttributes(RunStoreMetrics.transitions, { to: toStatusInput }))));
1154
+ const steal = Effect.fn("RunStore.steal")((runIdInput, expectedInput, claimantInput, nowMsInput, evidenceInput) => Effect.gen(function* () {
1155
+ const runId = yield* snapshotRunId("steal", runIdInput);
1156
+ const expected = yield* snapshotExpected("steal", expectedInput);
1157
+ const claimant = yield* snapshotOwner("steal", "claimant", claimantInput);
1158
+ const nowMs = yield* snapshotLeaseReading("steal", "nowMs", nowMsInput, { runId });
1159
+ const evidence = yield* snapshotEvidence("steal", evidenceInput);
1160
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId });
1161
+ return yield* Effect.suspend(() => {
1162
+ if (!evidenceMatches(expected, claimant, nowMs, evidence)) {
1163
+ return Effect.succeed(livenessUnconfirmed);
1164
+ }
1165
+ return write("steal", Effect.gen(function* () {
1166
+ const row = (yield* selectRun(sql, runId))[0];
1167
+ const admissible = row !== undefined &&
1168
+ rowMatchesSnapshot(row, expected) &&
1169
+ claimColumnsFree(row) &&
1170
+ row.heartbeatAtMs !== null &&
1171
+ row.heartbeatAtMs < nowMs - heartbeatStaleAfterMs;
1172
+ if (!admissible)
1173
+ return classifyClaimLoss(row, nowMs);
1174
+ const grant = yield* consensus.steal(runId, claimant, nowMs, evidence);
1175
+ if (grant._tag === "Rejected") {
1176
+ // The strategy refused what the row admitted — for example its
1177
+ // lease still records a fresh heartbeat — and is authoritative.
1178
+ return classifyClaimLoss(row, nowMs);
1179
+ }
1180
+ yield* sql `
1181
+ UPDATE flows_runs
1182
+ SET
1183
+ claim_host_id = ${claimant.hostId},
1184
+ claim_pid = ${claimant.pid},
1185
+ claim_nonce = ${claimant.nonce},
1186
+ claimed_at_ms = ${grant.grantedAtMs}
1187
+ WHERE run_id = ${runId}
1188
+ `;
1189
+ yield* recordTransition(runId, "stolen", claimant, grant.grantedAtMs);
1190
+ return claimed(grant.grantedAtMs);
1191
+ }));
1192
+ });
1193
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.steal[outcome._tag], Metric.withAttributes(RunStoreMetrics.claims, { op: "steal" }))));
1194
+ const acknowledgeCancel = Effect.fn("RunStore.acknowledgeCancel")((id, ownerInput, time) => Effect.gen(function* () {
1195
+ const runId = yield* snapshotRunId("acknowledgeCancel", id);
1196
+ const owner = yield* snapshotOwner("acknowledgeCancel", "owner", ownerInput);
1197
+ const observedAtMs = yield* snapshotTimestamp("acknowledgeCancel", "nowMs", time);
1198
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId });
1199
+ const acknowledgement = JSON.stringify({ observedAtMs, owner });
1200
+ return yield* write("acknowledgeCancel", sql `
1201
+ UPDATE flows_runs
1202
+ SET cancel_acknowledgement_json = COALESCE(cancel_acknowledgement_json, ${acknowledgement})
1203
+ WHERE run_id = ${runId} AND status = 'running' AND cancel_requested_at_ms IS NOT NULL
1204
+ AND owner_host_id = ${owner.hostId} AND owner_pid = ${owner.pid} AND owner_nonce = ${owner.nonce}
1205
+ RETURNING run_id
1206
+ `.pipe(Effect.map((rows) => rows.length > 0)));
1207
+ }).pipe(observeExit));
1208
+ return RunStore.of({
1209
+ create,
1210
+ get,
1211
+ lineage,
1212
+ latestRound,
1213
+ requestCancel,
1214
+ acknowledgeCancel,
1215
+ requestCancelLineage,
1216
+ claim,
1217
+ claimAndOwn,
1218
+ activate,
1219
+ abandonClaim,
1220
+ recoverClaim,
1221
+ heartbeat,
1222
+ reconfirm,
1223
+ transitionOwned,
1224
+ steal
1225
+ });
1226
+ });
1227
+ /**
1228
+ * Constructs a stub `RunStore` whose direct operations fail and whose
1229
+ * compare-and-swap operations report typed losses until overridden.
1230
+ *
1231
+ * @since 0.1.0
1232
+ * @category constructors
1233
+ */
1234
+ export const makeNoop = (overrides = {}) => {
1235
+ const unavailable = (method) => Effect.fail(runStoreError(method, "persistence_failed", "no run store in this environment", method));
1236
+ return RunStore.of({
1237
+ create: Effect.fn("RunStore.create")(() => unavailable("create")),
1238
+ get: Effect.fn("RunStore.get")(() => unavailable("get")),
1239
+ lineage: Effect.fn("RunStore.lineage")(() => unavailable("lineage")),
1240
+ latestRound: Effect.fn("RunStore.latestRound")(() => unavailable("latestRound")),
1241
+ requestCancel: Effect.fn("RunStore.requestCancel")(() => Effect.succeed(notFound)),
1242
+ acknowledgeCancel: Effect.fn("RunStore.acknowledgeCancel")(() => Effect.succeed(false)),
1243
+ requestCancelLineage: Effect.fn("RunStore.requestCancelLineage")(() => unavailable("requestCancelLineage")),
1244
+ claim: Effect.fn("RunStore.claim")(() => Effect.succeed(notFound)),
1245
+ claimAndOwn: Effect.fn("RunStore.claimAndOwn")(() => Effect.succeed(notFound)),
1246
+ activate: Effect.fn("RunStore.activate")(() => Effect.succeed(claimLost)),
1247
+ abandonClaim: Effect.fn("RunStore.abandonClaim")(() => Effect.succeed(claimLost)),
1248
+ recoverClaim: Effect.fn("RunStore.recoverClaim")(() => Effect.succeed(notFound)),
1249
+ heartbeat: Effect.fn("RunStore.heartbeat")(() => Effect.succeed(notFound)),
1250
+ reconfirm: Effect.fn("RunStore.reconfirm")(() => Effect.succeed(notFound)),
1251
+ transitionOwned: Effect.fn("RunStore.transitionOwned")(() => Effect.succeed(notFound)),
1252
+ steal: Effect.fn("RunStore.steal")(() => Effect.succeed(notFound)),
1253
+ ...overrides
1254
+ });
1255
+ };
1256
+ /**
1257
+ * Provides a stub `RunStore`.
1258
+ *
1259
+ * @since 0.1.0
1260
+ * @category layers
1261
+ */
1262
+ export const layerNoop = (overrides = {}) => Layer.succeed(RunStore)(makeNoop(overrides));
1263
+ /**
1264
+ * Provides the database-backed `RunStore` over an explicitly injected
1265
+ * consensus strategy.
1266
+ *
1267
+ * Arbitration — claims, activation, steals, heartbeats, and fence checks — is
1268
+ * delegated to the `Consensus` service in context; the run row mirrors the
1269
+ * outcome in the same transaction. Provide the SAME strategy instance the
1270
+ * journal fences through (`SqlJournal.layerWith`), because
1271
+ * `Consensus.layerLocal` keeps its leases per layer build.
1272
+ *
1273
+ * @since 1.0.0
1274
+ * @category layers
1275
+ */
1276
+ export const layerWith = Layer.effect(RunStore, make);
1277
+ /**
1278
+ * Provides the database-backed `RunStore` arbitrated by the database-backed
1279
+ * strategy, `SqlConsensus`, over the same client — the strategy
1280
+ * `SqlJournal.layer` fences through. Use {@link layerWith} to inject a
1281
+ * different strategy.
1282
+ *
1283
+ * @since 0.1.0
1284
+ * @category layers
1285
+ */
1286
+ export const layer = layerWith.pipe(Layer.provide(SqlConsensus.layer));
1287
+ //# sourceMappingURL=RunStore.js.map