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

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,2063 @@
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
+
34
+ import * as Dialect from "@smthrs/database/Dialect"
35
+ import { afterCommit, DatabaseError, DurableWriter, fromSqlError } from "@smthrs/database/DurableWriter"
36
+ import {
37
+ type ClaimOutcome as LeaseClaimOutcome,
38
+ Consensus,
39
+ type ConsensusError,
40
+ type LivenessEvidence,
41
+ matchesEvidence,
42
+ sameOwner
43
+ } from "@smthrs/journal/Consensus"
44
+ import type { JournalError } from "@smthrs/journal/Journal"
45
+ import { isCompanionRunId } from "@smthrs/journal/JournalEvent"
46
+ import { OwnerId } from "@smthrs/journal/OwnerId"
47
+ import * as SqlConsensus from "@smthrs/journal/SqlConsensus"
48
+ import * as ObservabilityMetric from "@smthrs/observability/Metric"
49
+ import { Clock, Context, Duration, Effect, Layer, Metric, Schema } from "effect"
50
+ import * as SqlClient from "effect/unstable/sql/SqlClient"
51
+ import type * as SqlError from "effect/unstable/sql/SqlError"
52
+ import { heartbeatSkewAllowance, heartbeatStaleAfter } from "./Heartbeat.ts"
53
+ import * as Boundary from "./internal/Boundary.ts"
54
+ import * as Companion from "./internal/Companion.ts"
55
+ import { observeExit, observeOutcome } from "./internal/SpanOutcome.ts"
56
+ import * as RunStoreMetrics from "./RunStoreMetrics.ts"
57
+
58
+ /** JSON text carrying an arbitrary decoded value. */
59
+ const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown)
60
+
61
+ /**
62
+ * A UTF-16 surrogate without its partner. The journal uses the same ES2022
63
+ * compatible check because `String.prototype.isWellFormed` needs ES2024.
64
+ */
65
+ const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
66
+
67
+ /** A non-empty identifier whose UTF-16 encoding survives SQLite unchanged. */
68
+ const wellFormedIdentifier = (value: string): boolean => value.length > 0 && !loneSurrogate.test(value)
69
+
70
+ const NonNegativeSafeInt = Schema.Int.check(
71
+ Schema.isGreaterThanOrEqualTo(0),
72
+ Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)
73
+ )
74
+
75
+ const durableIdentifier = Schema.NonEmptyString.check(
76
+ Schema.isMaxLength(Boundary.maximumIdentifierLength),
77
+ Schema.makeFilter(
78
+ (value: string) => wellFormedIdentifier(value) && Boundary.isDurableText(value),
79
+ { title: "durableIdentifier" }
80
+ )
81
+ )
82
+
83
+ /**
84
+ * Maximum nesting admitted for executable run state.
85
+ *
86
+ * @since 1.0.0-rc.0
87
+ * @category constants
88
+ */
89
+ export const maximumRunJsonDepth = 128
90
+
91
+ /**
92
+ * Maximum values and members admitted for executable run state.
93
+ *
94
+ * @since 1.0.0-rc.0
95
+ * @category constants
96
+ */
97
+ export const maximumRunJsonNodes = 100_000
98
+
99
+ const runJsonLimits: Boundary.JsonLimits = {
100
+ maxDepth: maximumRunJsonDepth,
101
+ maxMembers: maximumRunJsonNodes,
102
+ maxNodes: maximumRunJsonNodes
103
+ }
104
+
105
+ /**
106
+ * Stable run states understood by the durability layer.
107
+ *
108
+ * @since 0.1.0
109
+ * @category models
110
+ */
111
+ export const RunStatus = Schema.Literals([
112
+ "pending",
113
+ "running",
114
+ "suspended",
115
+ "completed",
116
+ "failed",
117
+ "cancelled"
118
+ ])
119
+
120
+ /**
121
+ * A stable run state.
122
+ *
123
+ * @since 0.1.0
124
+ * @category models
125
+ */
126
+ export type RunStatus = typeof RunStatus.Type
127
+
128
+ /**
129
+ * The run states a run never leaves.
130
+ *
131
+ * Named because two rules are written against exactly this set: a settled run
132
+ * refuses new cancellation intent ({@link RequestCancelOutcome}), and a
133
+ * settled row is never claimed, activated, or swept.
134
+ *
135
+ * @since 0.1.0
136
+ * @category models
137
+ */
138
+ export const TerminalRunStatus = Schema.Literals(["completed", "failed", "cancelled"])
139
+
140
+ /**
141
+ * The type of {@link TerminalRunStatus}.
142
+ *
143
+ * @since 0.1.0
144
+ * @category models
145
+ */
146
+ export type TerminalRunStatus = typeof TerminalRunStatus.Type
147
+
148
+ /**
149
+ * Whether a status is one a run never leaves.
150
+ *
151
+ * @since 0.1.0
152
+ * @category models
153
+ */
154
+ export const isTerminalRunStatus = (status: RunStatus): status is TerminalRunStatus =>
155
+ status === "completed" || status === "failed" || status === "cancelled"
156
+
157
+ /**
158
+ * Stable failure codes surfaced by `RunStore`.
159
+ *
160
+ * @since 0.1.0
161
+ * @category errors
162
+ */
163
+ export const RunStoreErrorCode = Schema.Literals([
164
+ "invalid_run",
165
+ "not_found_row",
166
+ "constraint",
167
+ "decode_failed",
168
+ "persistence_failed"
169
+ ])
170
+
171
+ /**
172
+ * A stable `RunStore` failure code.
173
+ *
174
+ * @since 0.1.0
175
+ * @category errors
176
+ */
177
+ export type RunStoreErrorCode = typeof RunStoreErrorCode.Type
178
+
179
+ /**
180
+ * A normalized run persistence failure.
181
+ *
182
+ * Compare-and-swap competition is represented by successful outcome values,
183
+ * never by this error channel.
184
+ *
185
+ * The identity string equals the defining module path, which is how every
186
+ * tagged identity in this package is named.
187
+ *
188
+ * @since 0.1.0
189
+ * @category errors
190
+ */
191
+ export class RunStoreError extends Schema.TaggedError<RunStoreError>()("@smthrs/run-store/RunStoreError", {
192
+ code: RunStoreErrorCode,
193
+ method: Schema.String,
194
+ message: Schema.String,
195
+ cause: Schema.Unknown
196
+ }) {}
197
+
198
+ /**
199
+ * The exact persisted fields guarded by a claim and its later activation.
200
+ *
201
+ * @since 0.1.0
202
+ * @category models
203
+ */
204
+ export interface RunSnapshot {
205
+ readonly status: RunStatus
206
+ readonly owner: OwnerId | null
207
+ readonly heartbeatAtMs: number | null
208
+ }
209
+
210
+ /**
211
+ * A decoded row in `flows_runs`.
212
+ *
213
+ * @since 0.1.0
214
+ * @category models
215
+ */
216
+ export interface RunRow extends RunSnapshot {
217
+ readonly runId: string
218
+ readonly createdAtMs: number
219
+ readonly startedAtMs: number | null
220
+ readonly finishedAtMs: number | null
221
+ readonly claim: OwnerId | null
222
+ readonly claimedAtMs: number | null
223
+ readonly parentRunId: string | null
224
+ readonly cancelRequestedAtMs: number | null
225
+ /**
226
+ * The trampoline lineage this run is a round of, and which round it is.
227
+ * `null` on every run that is not part of a lineage, which reads as round 0
228
+ * of a lineage of one.
229
+ *
230
+ * Optional on the interface so a caller that builds a `RunRow` by hand —
231
+ * a time-travel fixture, a recovery double — keeps compiling: the pair was
232
+ * added by an append-only migration, and every reader must already tolerate
233
+ * a row written before it.
234
+ */
235
+ readonly lineageId?: string | null | undefined
236
+ readonly roundOrdinal?: number | null | undefined
237
+ readonly stateJson: string
238
+ }
239
+
240
+ /**
241
+ * Metadata recorded when a run row is created.
242
+ *
243
+ * `parentRunId` is the ancestry edge a fork, rewind, child, or later
244
+ * trampoline round carries. It is a column rather than a `state_json` field
245
+ * because ancestry is walked in SQL.
246
+ *
247
+ * `lineageId` and `roundOrdinal` are the trampoline pair: which lineage a run
248
+ * is a round of, and which round. Both absent — the ordinary case — means the
249
+ * run is a lineage of one, and is read back as round 0 of itself.
250
+ *
251
+ * @since 0.1.0
252
+ * @category models
253
+ */
254
+ export interface CreateOptions {
255
+ readonly parentRunId?: string | undefined
256
+ readonly lineageId?: string | undefined
257
+ readonly roundOrdinal?: number | undefined
258
+ }
259
+
260
+ /**
261
+ * An extra compare-and-swap predicate over first-class run metadata.
262
+ *
263
+ * A guard is the seam for lifecycle rules a harness must enforce atomically —
264
+ * `{ cancelRequested: "absent" }` is the "do not finalize a run someone asked
265
+ * to cancel" rule, expressed as SQL rather than as a read-then-write race.
266
+ *
267
+ * @since 0.1.0
268
+ * @category models
269
+ */
270
+ export const TransitionGuard = Schema.Struct({
271
+ cancelRequested: Schema.optional(Schema.Literals(["absent", "present"] as const))
272
+ })
273
+
274
+ /**
275
+ * An extra compare-and-swap predicate over first-class run metadata.
276
+ *
277
+ * @since 0.1.0
278
+ * @category models
279
+ */
280
+ export type TransitionGuard = typeof TransitionGuard.Type
281
+
282
+ /**
283
+ * Result of recording a cancellation request.
284
+ *
285
+ * The request is deliberately unfenced: any observer may ask, and the owner
286
+ * decides. `requestedAtMs` is the winning request, so a repeat request reports
287
+ * the original time rather than overwriting it.
288
+ *
289
+ * @since 0.1.0
290
+ * @category models
291
+ */
292
+ export type RequestCancelOutcome =
293
+ | { readonly _tag: "CancelRequested"; readonly requestedAtMs: number }
294
+ | { readonly _tag: "AlreadyRequested"; readonly requestedAtMs: number }
295
+ | { readonly _tag: "NotFound" }
296
+ /**
297
+ * The run had already settled, so nothing was recorded. A terminal run has
298
+ * no owner and no drive to observe a request, so writing one would leave
299
+ * intent nothing ever acts on — and a reader that takes the column as live
300
+ * intent (`RunDriver.inheritParentCancellation`) would cancel children a
301
+ * `completed` parent had finished with. The status says which ending the
302
+ * request lost to.
303
+ */
304
+ | { readonly _tag: "Terminal"; readonly status: TerminalRunStatus }
305
+
306
+ /**
307
+ * Result of acquiring claim columns for a later activation.
308
+ *
309
+ * @since 0.1.0
310
+ * @category models
311
+ */
312
+ export type ClaimOutcome =
313
+ | { readonly _tag: "Claimed"; readonly claimedAtMs: number }
314
+ | { readonly _tag: "NotFound" }
315
+ | { readonly _tag: "AlreadyClaimed" }
316
+ | { readonly _tag: "HeartbeatFresh" }
317
+ | { readonly _tag: "SnapshotChanged" }
318
+
319
+ /**
320
+ * Result of stealing an exact run snapshot after liveness evidence is checked.
321
+ *
322
+ * `LivenessUnconfirmed` means the supplied evidence did not match the
323
+ * snapshot's owner, the host relation its `kind` requires, or `nowMs`, so the
324
+ * steal was refused before any compare-and-swap ran. The other tags have the
325
+ * same meaning as {@link ClaimOutcome} after the comparison runs.
326
+ *
327
+ * @since 1.0.0-rc.0
328
+ * @category models
329
+ */
330
+ export type StealOutcome = ClaimOutcome | { readonly _tag: "LivenessUnconfirmed" }
331
+
332
+ /**
333
+ * Result of claiming and activating ownership in one compare-and-swap.
334
+ *
335
+ * @since 0.1.0
336
+ * @category models
337
+ */
338
+ export type ClaimAndOwnOutcome =
339
+ | { readonly _tag: "Activated" }
340
+ | { readonly _tag: "NotFound" }
341
+ | { readonly _tag: "AlreadyClaimed" }
342
+ | { readonly _tag: "HeartbeatFresh" }
343
+ | { readonly _tag: "SnapshotChanged" }
344
+ /**
345
+ * The caller's exact snapshot is still current, its different owner has a
346
+ * stale heartbeat, and no matching `LivenessEvidence` was supplied, so no
347
+ * compare-and-swap ran. Re-reading and retrying cannot make progress; supply
348
+ * evidence, or use `steal`. A current fresh heartbeat instead reports
349
+ * `HeartbeatFresh`, because evidence cannot satisfy the stale-lease predicate.
350
+ */
351
+ | { readonly _tag: "EvidenceRequired" }
352
+
353
+ type ClaimLossOutcome = Exclude<ClaimOutcome, { readonly _tag: "Claimed" }>
354
+
355
+ /**
356
+ * Result of activating a held claim.
357
+ *
358
+ * @since 0.1.0
359
+ * @category models
360
+ */
361
+ export type ActivateOutcome =
362
+ | { readonly _tag: "Activated" }
363
+ | { readonly _tag: "ClaimLost" }
364
+ | { readonly _tag: "SnapshotChanged" }
365
+
366
+ /**
367
+ * Result of clearing a held claim.
368
+ *
369
+ * @since 0.1.0
370
+ * @category models
371
+ */
372
+ export type AbandonClaimOutcome =
373
+ | { readonly _tag: "Abandoned" }
374
+ | { readonly _tag: "ClaimLost" }
375
+
376
+ /**
377
+ * Result of clearing an exact stale claim after its claimant was proven dead.
378
+ *
379
+ * @since 0.1.0
380
+ * @category models
381
+ */
382
+ export type RecoverClaimOutcome =
383
+ | { readonly _tag: "Recovered" }
384
+ | { readonly _tag: "NotFound" }
385
+ | { readonly _tag: "ClaimFresh" }
386
+ | { readonly _tag: "ClaimChanged" }
387
+ | { readonly _tag: "LivenessUnconfirmed" }
388
+
389
+ /**
390
+ * Result of a fenced ownership heartbeat.
391
+ *
392
+ * @since 0.1.0
393
+ * @category models
394
+ */
395
+ export type HeartbeatOutcome =
396
+ | { readonly _tag: "Updated" }
397
+ | { readonly _tag: "FenceLost" }
398
+ | { readonly _tag: "NotFound" }
399
+
400
+ /**
401
+ * Result of a fenced owned transition.
402
+ *
403
+ * @since 0.1.0
404
+ * @category models
405
+ */
406
+ export type TransitionOutcome =
407
+ | { readonly _tag: "Transitioned" }
408
+ | { readonly _tag: "FenceLost" }
409
+ | { readonly _tag: "NotFound" }
410
+ | { readonly _tag: "GuardFailed" }
411
+
412
+ /**
413
+ * An ownership transition the consensus strategy granted on a run.
414
+ *
415
+ * `claimed` and `stolen` reserve the run for a claimant, `activated` promotes
416
+ * a claim to ownership, `released` gives a claim or ownership back, and
417
+ * `expired` clears a dead claimant's stale claim. Each is appended as a
418
+ * `flows.consensus.<transition>` fact, with payload `{ runId, owner,
419
+ * grantedAtMs }`, to the run's companion stream
420
+ * `JournalEvent.companionRunId("run-store", runId)` in the transaction that
421
+ * made it; a rolled-back transition leaves no fact. Heartbeats are not
422
+ * transitions and never enter the journal.
423
+ *
424
+ * The facts live beside the run's own stream rather than in it, so a consumer
425
+ * that reads the run's stream by position never sees one, and a rewind or
426
+ * compaction of that stream never truncates one. They are recorded when the
427
+ * caller runs with a `Journal` in its context.
428
+ *
429
+ * @since 1.0.0
430
+ * @category models
431
+ */
432
+ export const OwnershipTransition = Schema.Literals(["claimed", "activated", "released", "stolen", "expired"])
433
+
434
+ /**
435
+ * The type of {@link OwnershipTransition}.
436
+ *
437
+ * @since 1.0.0
438
+ * @category models
439
+ */
440
+ export type OwnershipTransition = typeof OwnershipTransition.Type
441
+
442
+ /**
443
+ * The name of the run store's journal companion stream: every fact the store
444
+ * records about a run goes to `JournalEvent.companionRunId(companionStream,
445
+ * runId)`.
446
+ *
447
+ * @since 1.0.0
448
+ * @category constants
449
+ */
450
+ export const companionStream: string = Companion.stream
451
+
452
+ /**
453
+ * The journal event type an ownership transition is appended under.
454
+ *
455
+ * @since 1.0.0
456
+ * @category constants
457
+ */
458
+ export const ownershipEventType = (transition: OwnershipTransition): string => `flows.consensus.${transition}`
459
+
460
+ /**
461
+ * Fenced persistence operations for durable runs.
462
+ *
463
+ * @since 0.1.0
464
+ * @category models
465
+ */
466
+ export interface Service {
467
+ readonly create: (
468
+ runId: string,
469
+ stateJson: string,
470
+ options?: CreateOptions | undefined
471
+ ) => Effect.Effect<void, RunStoreError>
472
+ readonly get: (runId: string) => Effect.Effect<RunRow, RunStoreError>
473
+ /**
474
+ * Reads every persisted round of this run's trampoline lineage, in ordinal
475
+ * order, from any member execution ID. An older root with null lineage
476
+ * columns is included as round zero. Missing IDs return an empty array.
477
+ * Fork ancestry alone does not join lineages. This is a snapshot read;
478
+ * callers making lifecycle decisions must hold their shared write transaction.
479
+ */
480
+ readonly lineage: (runId: string) => Effect.Effect<ReadonlyArray<RunRow>, RunStoreError>
481
+ /** Reads only the highest persisted round; fails with not_found_row for an unknown ID. */
482
+ readonly latestRound: (runId: string) => Effect.Effect<RunRow, RunStoreError>
483
+ /** Records an unfenced cancellation request that later guarded transitions observe. */
484
+ readonly requestCancel: (runId: string, nowMs: number) => Effect.Effect<RequestCancelOutcome, RunStoreError>
485
+ /**
486
+ * Records the first owner observation of durable intent. Returns false if
487
+ * intent is absent or the running owner fence no longer matches.
488
+ */
489
+ readonly acknowledgeCancel: (runId: string, owner: OwnerId, nowMs: number) => Effect.Effect<boolean, RunStoreError>
490
+ /**
491
+ * Resolves and requests every non-terminal round in one write transaction.
492
+ * Terminal is returned only when the entire logical run has settled; its
493
+ * status is the last round's status. Child-DAG cascading belongs to the
494
+ * engine. Ordinary requestCancel remains the explicitly round-scoped port.
495
+ */
496
+ readonly requestCancelLineage: (runId: string, nowMs: number) => Effect.Effect<RequestCancelOutcome, RunStoreError>
497
+ /**
498
+ * Claims an exact pending or suspended snapshot for a later `activate`.
499
+ * `nowMs` becomes `claimed_at_ms`, so it must not run ahead of the Effect
500
+ * `Clock` by more than `heartbeatSkewAllowance`; a reading that does fails
501
+ * with `invalid_run`.
502
+ */
503
+ readonly claim: (
504
+ runId: string,
505
+ expected: RunSnapshot,
506
+ claimant: OwnerId,
507
+ nowMs: number
508
+ ) => Effect.Effect<ClaimOutcome, RunStoreError>
509
+ /**
510
+ * Claims and activates an exact snapshot atomically under the supplied owner.
511
+ * Replacing a different running owner also requires matching liveness evidence.
512
+ * A composition builds that `LivenessEvidence` with a `LivenessProbe`.
513
+ * `evidence.checkedAtMs` must equal `nowMs` exactly, so evidence probed at T
514
+ * is refused when this operation is called at T+1. `nowMs` is the lease
515
+ * cutoff and the new heartbeat, so it must not run ahead of the Effect
516
+ * `Clock` by more than `heartbeatSkewAllowance`; a reading that does fails
517
+ * with `invalid_run`.
518
+ */
519
+ readonly claimAndOwn: (
520
+ runId: string,
521
+ expected: RunSnapshot,
522
+ owner: OwnerId,
523
+ nowMs: number,
524
+ evidence?: LivenessEvidence | undefined
525
+ ) => Effect.Effect<ClaimAndOwnOutcome, RunStoreError>
526
+ readonly activate: (
527
+ runId: string,
528
+ claimant: OwnerId,
529
+ claimedAtMs: number,
530
+ expected: RunSnapshot
531
+ ) => Effect.Effect<ActivateOutcome, RunStoreError>
532
+ readonly abandonClaim: (
533
+ runId: string,
534
+ claimant: OwnerId,
535
+ claimedAtMs: number
536
+ ) => Effect.Effect<AbandonClaimOutcome, RunStoreError>
537
+ /**
538
+ * Recovers an exact stale claim after matching its claimant and liveness
539
+ * evidence. A composition builds `LivenessEvidence` with a `LivenessProbe`.
540
+ * `evidence.checkedAtMs` must equal `nowMs` exactly, so evidence probed at T
541
+ * is refused when this operation is called at T+1. `nowMs` is the staleness
542
+ * cutoff, so it must not run ahead of the Effect `Clock` by more than
543
+ * `heartbeatSkewAllowance`; a reading that does fails with `invalid_run`.
544
+ * `claimedAtMs` is the fence token compared against the row and carries no
545
+ * such bound.
546
+ */
547
+ readonly recoverClaim: (
548
+ runId: string,
549
+ staleClaimant: OwnerId,
550
+ claimedAtMs: number,
551
+ observer: OwnerId,
552
+ nowMs: number,
553
+ evidence: LivenessEvidence
554
+ ) => Effect.Effect<RecoverClaimOutcome, RunStoreError>
555
+ /**
556
+ * Renews the owner's lease. The write is monotonic, so a reading behind the
557
+ * persisted heartbeat still reports `Updated` without moving it back. `nowMs`
558
+ * must not run ahead of the Effect `Clock` by more than
559
+ * `heartbeatSkewAllowance`; a reading that does fails with `invalid_run`
560
+ * instead of pinning the lease past the cutoff.
561
+ */
562
+ readonly heartbeat: (
563
+ runId: string,
564
+ owner: OwnerId,
565
+ nowMs: number
566
+ ) => Effect.Effect<HeartbeatOutcome, RunStoreError>
567
+ /**
568
+ * Reconfirms an exclusively owned lease after a missed heartbeat budget.
569
+ * Uses the same validation and transactional mirror as `heartbeat`, but
570
+ * refuses a pending takeover claim even before the claimant activates.
571
+ */
572
+ readonly reconfirm: (
573
+ runId: string,
574
+ owner: OwnerId,
575
+ nowMs: number
576
+ ) => Effect.Effect<HeartbeatOutcome, RunStoreError>
577
+ readonly transitionOwned: (
578
+ runId: string,
579
+ owner: OwnerId,
580
+ toStatus: RunStatus,
581
+ stateJson?: string | undefined,
582
+ guard?: TransitionGuard | undefined
583
+ ) => Effect.Effect<TransitionOutcome, RunStoreError>
584
+ /**
585
+ * Steals an exact run snapshot after matching liveness evidence for its
586
+ * recorded owner. A composition builds `LivenessEvidence` with a
587
+ * `LivenessProbe`. `evidence.checkedAtMs` must equal `nowMs` exactly, so
588
+ * evidence probed at T is refused when this operation is called at T+1.
589
+ * `LivenessUnconfirmed` means the evidence did not match and no
590
+ * compare-and-swap ran. `nowMs` is the staleness cutoff, so it must not run
591
+ * ahead of the Effect `Clock` by more than `heartbeatSkewAllowance`; a
592
+ * reading that does fails with `invalid_run` before any comparison.
593
+ */
594
+ readonly steal: (
595
+ runId: string,
596
+ expected: RunSnapshot,
597
+ claimant: OwnerId,
598
+ nowMs: number,
599
+ evidence: LivenessEvidence
600
+ ) => Effect.Effect<StealOutcome, RunStoreError>
601
+ }
602
+
603
+ /**
604
+ * Service tag for fenced run persistence.
605
+ *
606
+ * The identity string equals the defining module path, which is how every
607
+ * service in this package is named.
608
+ *
609
+ * @since 0.1.0
610
+ * @category services
611
+ */
612
+ export class RunStore extends Context.Service<RunStore, Service>()("@smthrs/run-store/RunStore") {}
613
+
614
+ const claimed = (claimedAtMs: number): ClaimOutcome => ({ _tag: "Claimed", claimedAtMs })
615
+ const notFound = { _tag: "NotFound" } as const
616
+ const alreadyClaimed = { _tag: "AlreadyClaimed" } as const
617
+ const heartbeatFresh = { _tag: "HeartbeatFresh" } as const
618
+ const snapshotChanged = { _tag: "SnapshotChanged" } as const
619
+ const evidenceRequired = { _tag: "EvidenceRequired" } as const
620
+ const activated = { _tag: "Activated" } as const
621
+ const claimLost = { _tag: "ClaimLost" } as const
622
+ const abandoned = { _tag: "Abandoned" } as const
623
+ const recovered = { _tag: "Recovered" } as const
624
+ const claimFresh = { _tag: "ClaimFresh" } as const
625
+ const claimChanged = { _tag: "ClaimChanged" } as const
626
+ const livenessUnconfirmed = { _tag: "LivenessUnconfirmed" } as const
627
+ const updated = { _tag: "Updated" } as const
628
+ const fenceLost = { _tag: "FenceLost" } as const
629
+ const transitioned = { _tag: "Transitioned" } as const
630
+ const guardFailed = { _tag: "GuardFailed" } as const
631
+
632
+ /**
633
+ * The staleness cutoff in milliseconds, derived from the one definition rather
634
+ * than restated. `Ownership` imports `RunStore`, so the shared constants live
635
+ * in the `Heartbeat` leaf both can reach.
636
+ */
637
+ const heartbeatStaleAfterMs = Duration.toMillis(heartbeatStaleAfter)
638
+ const heartbeatSkewAllowanceMs = Duration.toMillis(heartbeatSkewAllowance)
639
+ const terminalStatuses: ReadonlySet<RunStatus> = new Set(["completed", "failed", "cancelled"])
640
+
641
+ const DatabaseRunRow = Schema.Struct({
642
+ runId: durableIdentifier,
643
+ status: RunStatus,
644
+ createdAtMs: NonNegativeSafeInt,
645
+ startedAtMs: Schema.NullOr(NonNegativeSafeInt),
646
+ finishedAtMs: Schema.NullOr(NonNegativeSafeInt),
647
+ ownerHostId: Schema.NullOr(durableIdentifier),
648
+ ownerPid: Schema.NullOr(NonNegativeSafeInt),
649
+ ownerNonce: Schema.NullOr(durableIdentifier),
650
+ heartbeatAtMs: Schema.NullOr(NonNegativeSafeInt),
651
+ claimHostId: Schema.NullOr(durableIdentifier),
652
+ claimPid: Schema.NullOr(NonNegativeSafeInt),
653
+ claimNonce: Schema.NullOr(durableIdentifier),
654
+ claimedAtMs: Schema.NullOr(NonNegativeSafeInt),
655
+ parentRunId: Schema.NullOr(durableIdentifier),
656
+ cancelRequestedAtMs: Schema.NullOr(NonNegativeSafeInt),
657
+ lineageId: Schema.NullOr(durableIdentifier),
658
+ roundOrdinal: Schema.NullOr(NonNegativeSafeInt),
659
+ stateJson: Schema.String
660
+ })
661
+
662
+ type DatabaseRunRow = typeof DatabaseRunRow.Type
663
+
664
+ const runStoreError = (
665
+ method: string,
666
+ code: RunStoreErrorCode,
667
+ message: string,
668
+ cause: unknown
669
+ ): RunStoreError =>
670
+ new RunStoreError({
671
+ code,
672
+ method,
673
+ message: `${code}: RunStore.${method}: ${message}`,
674
+ cause
675
+ })
676
+
677
+ /**
678
+ * The normalized database failure behind a strategy or journal failure, found
679
+ * by following `cause` chains the way the durable writer's retry
680
+ * classification does. A wrapper that carries no database failure at all —
681
+ * a closed journal, say — has no category to report.
682
+ */
683
+ const databaseFailure = (cause: unknown): DatabaseError | undefined => {
684
+ let current: unknown = cause
685
+ for (let depth = 0; depth < 8 && typeof current === "object" && current !== null; depth++) {
686
+ if (current instanceof DatabaseError) return current
687
+ current = (current as { readonly cause?: unknown }).cause
688
+ }
689
+ return undefined
690
+ }
691
+
692
+ const persistenceError = (
693
+ method: string,
694
+ cause: RunStoreError | DatabaseError | ConsensusError | JournalError
695
+ ): RunStoreError => {
696
+ if (Schema.is(RunStoreError)(cause)) return cause
697
+ const database = cause instanceof DatabaseError ? cause : databaseFailure(cause.cause)
698
+ const code = database?.code === "constraint"
699
+ ? "constraint"
700
+ : "persistence_failed"
701
+ return runStoreError(method, code, "database operation failed", {
702
+ category: code,
703
+ // Keep the classification for outer transaction retries, never driver
704
+ // causes that may contain executable state or bound SQL values.
705
+ cause: new DatabaseError({ code: database?.code ?? "unknown" })
706
+ })
707
+ }
708
+
709
+ const invalidRunError = (
710
+ method: string,
711
+ causeOrField: unknown,
712
+ detail = "violates the durable contract"
713
+ ): RunStoreError =>
714
+ runStoreError(
715
+ method,
716
+ "invalid_run",
717
+ "run input is invalid",
718
+ typeof causeOrField === "string" ? { field: causeOrField, detail } : causeOrField
719
+ )
720
+
721
+ const stateAdmission = (value: unknown) => Boundary.admitJsonText(value, runJsonLimits)
722
+
723
+ const isJsonString = (value: unknown): value is string =>
724
+ Schema.decodeUnknownResult(UnknownFromJsonString)(value)._tag === "Success"
725
+
726
+ const validOwner = (value: unknown): value is OwnerId =>
727
+ Schema.is(OwnerId)(value) &&
728
+ Boundary.isDurableText(value.hostId) &&
729
+ Boundary.isDurableText(value.nonce) &&
730
+ Number.isSafeInteger(value.pid) &&
731
+ value.pid >= 0
732
+
733
+ /** Caller timestamps are SQLite-safe, non-negative integer millisecond readings. */
734
+ const validTimestamp = (value: number): boolean => Number.isSafeInteger(value) && value >= 0
735
+
736
+ const inertRecord = (input: unknown, allowed?: ReadonlySet<string>): Record<string, unknown> | undefined => {
737
+ if (typeof input !== "object" || input === null) return undefined
738
+ try {
739
+ const prototype = Object.getPrototypeOf(input)
740
+ if (prototype !== Object.prototype && prototype !== null) return undefined
741
+ const fields: Record<string, unknown> = Object.create(null)
742
+ for (const key of Reflect.ownKeys(input)) {
743
+ const descriptor = Object.getOwnPropertyDescriptor(input, key)
744
+ if (descriptor === undefined || !descriptor.enumerable) continue
745
+ if (!("value" in descriptor) || (allowed !== undefined && (typeof key !== "string" || !allowed.has(key)))) {
746
+ return undefined
747
+ }
748
+ if (typeof key === "string") fields[key] = descriptor.value
749
+ }
750
+ return fields
751
+ } catch {
752
+ return undefined
753
+ }
754
+ }
755
+
756
+ const snapshotOwner = (method: string, field: string, input: unknown): Effect.Effect<OwnerId, RunStoreError> =>
757
+ Effect.suspend(() => {
758
+ const fields = inertRecord(input, new Set(["hostId", "pid", "nonce"]))
759
+ if (fields === undefined) {
760
+ return Effect.fail(invalidRunError(method, field, "must be an inert owner record"))
761
+ }
762
+ const owner = Object.freeze({
763
+ hostId: fields.hostId,
764
+ pid: fields.pid,
765
+ nonce: fields.nonce
766
+ })
767
+ return validOwner(owner)
768
+ ? Effect.succeed(owner)
769
+ : Effect.fail(invalidRunError(method, field, "must be a valid owner identity"))
770
+ })
771
+
772
+ const snapshotRunId = (method: string, input: unknown): Effect.Effect<string, RunStoreError> =>
773
+ typeof input === "string" && wellFormedIdentifier(input) && Boundary.isDurableText(input)
774
+ ? Effect.succeed(input)
775
+ : Effect.fail(invalidRunError(method, "runId", "must be well-formed durable text"))
776
+
777
+ const snapshotTimestamp = (
778
+ method: string,
779
+ field: string,
780
+ input: unknown
781
+ ): Effect.Effect<number, RunStoreError> =>
782
+ typeof input === "number" && validTimestamp(input)
783
+ ? Effect.succeed(input)
784
+ : Effect.fail(
785
+ invalidRunError(method, field, "must be a non-negative safe integer")
786
+ )
787
+
788
+ /**
789
+ * Admits a caller's lease reading. Once admitted the reading is authoritative:
790
+ * it is the cutoff the lease predicate compares against and the stamp the row
791
+ * keeps. The store refuses only a reading that runs ahead of its own `Clock`
792
+ * by more than `heartbeatSkewAllowance`, because no composition produces one
793
+ * honestly (the caller and the store share a process) and it is the one lever
794
+ * that steals a fresh owner or pins a lease past the cutoff. A reading behind
795
+ * the clock is admitted: it makes every staleness judgment more conservative,
796
+ * and `heartbeat`'s monotonic write absorbs it.
797
+ */
798
+ const snapshotLeaseReading = (
799
+ method: string,
800
+ field: string,
801
+ input: unknown,
802
+ cause: { readonly runId: string; readonly claimedAtMs?: number }
803
+ ): Effect.Effect<number, RunStoreError> =>
804
+ Effect.gen(function*() {
805
+ const nowMs = yield* snapshotTimestamp(method, field, input)
806
+ const clockMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
807
+ if (nowMs > clockMs + heartbeatSkewAllowanceMs) {
808
+ return yield* Effect.fail(
809
+ invalidRunError(method, {
810
+ ...cause,
811
+ field,
812
+ nowMs,
813
+ clockMs,
814
+ detail: "runs ahead of the store clock by more than the heartbeat skew allowance"
815
+ })
816
+ )
817
+ }
818
+ return nowMs
819
+ })
820
+
821
+ const snapshotState = (
822
+ method: string,
823
+ input: unknown,
824
+ cause: unknown
825
+ ): Effect.Effect<string, RunStoreError> => {
826
+ const admitted = stateAdmission(input)
827
+ return admitted.ok
828
+ ? Effect.succeed(admitted.value)
829
+ : Effect.fail(
830
+ invalidRunError(
831
+ method,
832
+ cause,
833
+ admitted.complaint
834
+ )
835
+ )
836
+ }
837
+
838
+ const snapshotCreateOptions = (
839
+ input: unknown
840
+ ): Effect.Effect<
841
+ Readonly<{ parentRunId: string | null; lineageId: string | null; roundOrdinal: number | null }>,
842
+ RunStoreError
843
+ > =>
844
+ Effect.suspend(() => {
845
+ const value = input ?? {}
846
+ const fields = inertRecord(value, new Set(["parentRunId", "lineageId", "roundOrdinal"]))
847
+ if (fields === undefined) {
848
+ return Effect.fail(invalidRunError("create", "options", "must be an inert data record"))
849
+ }
850
+ const rawParent = fields.parentRunId
851
+ const rawLineage = fields.lineageId
852
+ const rawRound = fields.roundOrdinal
853
+ const parentRunId = rawParent === undefined ? null : rawParent
854
+ const lineageId = rawLineage === undefined ? null : rawLineage
855
+ const roundOrdinal = rawRound === undefined ? null : rawRound
856
+ if (
857
+ (parentRunId !== null &&
858
+ (typeof parentRunId !== "string" ||
859
+ !wellFormedIdentifier(parentRunId) ||
860
+ !Boundary.isDurableText(parentRunId))) ||
861
+ (lineageId !== null &&
862
+ (typeof lineageId !== "string" ||
863
+ !wellFormedIdentifier(lineageId) ||
864
+ !Boundary.isDurableText(lineageId))) ||
865
+ (roundOrdinal !== null && (typeof roundOrdinal !== "number" || !validTimestamp(roundOrdinal))) ||
866
+ (lineageId === null) !== (roundOrdinal === null)
867
+ ) return Effect.fail(invalidRunError("create", "options", "contains invalid ancestry or lineage"))
868
+ return Effect.succeed(Object.freeze({ parentRunId, lineageId, roundOrdinal }))
869
+ })
870
+
871
+ /**
872
+ * Structural admission for a value typed as a required-field record. `RunRow`
873
+ * extends `RunSnapshot`, so a row from `get` must pass wherever an expected
874
+ * snapshot is typed: extra own data fields are ignored, while a non-plain
875
+ * prototype, any enumerable accessor, or a missing or inherited required field
876
+ * is still refused. The required fields come from one guarded own-descriptor snapshot.
877
+ */
878
+ const inertFields = (input: unknown, required: ReadonlyArray<string>): Record<string, unknown> | undefined => {
879
+ const fields = inertRecord(input)
880
+ return fields !== undefined && required.every((key) => Object.hasOwn(fields, key)) ? fields : undefined
881
+ }
882
+
883
+ // Admission ties ownership to status; internal consumers never see the
884
+ // impossible combinations permitted by the public input record.
885
+ type RunningSnapshot = {
886
+ readonly status: "running"
887
+ readonly owner: OwnerId
888
+ readonly heartbeatAtMs: number
889
+ }
890
+
891
+ type AdmittedSnapshot = RunningSnapshot | {
892
+ readonly status: Exclude<RunStatus, "running">
893
+ readonly owner: null
894
+ readonly heartbeatAtMs: null
895
+ }
896
+
897
+ const snapshotExpected = (
898
+ method: string,
899
+ input: unknown
900
+ ): Effect.Effect<AdmittedSnapshot, RunStoreError> =>
901
+ Effect.gen(function*() {
902
+ const fields = inertFields(input, ["status", "owner", "heartbeatAtMs"])
903
+ if (fields === undefined) {
904
+ return yield* Effect.fail(invalidRunError(method, "expected", "must be an inert snapshot record"))
905
+ }
906
+ const status = fields.status
907
+ const rawOwner = fields.owner
908
+ const heartbeatAtMs = fields.heartbeatAtMs
909
+ if (
910
+ !Schema.is(RunStatus)(status) ||
911
+ (heartbeatAtMs !== null && (typeof heartbeatAtMs !== "number" || !validTimestamp(heartbeatAtMs)))
912
+ ) {
913
+ return yield* Effect.fail(invalidRunError(method, "expected", "contains an invalid status or heartbeat"))
914
+ }
915
+ const owner = rawOwner === null ? null : yield* snapshotOwner(method, "expected.owner", rawOwner)
916
+ if (status === "running") {
917
+ if (owner === null || heartbeatAtMs === null) {
918
+ return yield* Effect.fail(invalidRunError(method, "expected", "violates ownership invariants"))
919
+ }
920
+ return Object.freeze({ status, owner, heartbeatAtMs })
921
+ } else {
922
+ if (owner !== null || heartbeatAtMs !== null) {
923
+ return yield* Effect.fail(invalidRunError(method, "expected", "violates ownership invariants"))
924
+ }
925
+ return Object.freeze({ status, owner, heartbeatAtMs })
926
+ }
927
+ })
928
+
929
+ const snapshotEvidence = (
930
+ method: string,
931
+ input: unknown
932
+ ): Effect.Effect<LivenessEvidence, RunStoreError> =>
933
+ Effect.gen(function*() {
934
+ const fields = inertRecord(input, new Set(["expectedOwner", "checkedAtMs", "kind"]))
935
+ if (fields === undefined) {
936
+ return yield* Effect.fail(invalidRunError(method, "evidence", "must be an inert evidence record"))
937
+ }
938
+ const expectedOwner = yield* snapshotOwner(method, "evidence.expectedOwner", fields.expectedOwner)
939
+ const checkedAtMs = yield* snapshotTimestamp(method, "evidence.checkedAtMs", fields.checkedAtMs)
940
+ const kind = fields.kind
941
+ if (
942
+ kind !== "same-host-pid-dead" &&
943
+ kind !== "cross-host-unreachable-stale" &&
944
+ kind !== "lease-expired"
945
+ ) return yield* Effect.fail(invalidRunError(method, "evidence.kind"))
946
+ return Object.freeze({ expectedOwner, checkedAtMs, kind })
947
+ })
948
+
949
+ const snapshotGuard = (
950
+ input: unknown
951
+ ): Effect.Effect<TransitionGuard | undefined, RunStoreError> =>
952
+ Effect.suspend(() => {
953
+ if (input === undefined) return Effect.succeed(undefined)
954
+ const fields = inertRecord(input, new Set(["cancelRequested"]))
955
+ if (fields === undefined) {
956
+ return Effect.fail(invalidRunError("transitionOwned", "guard", "must be an inert exact guard"))
957
+ }
958
+ const cancelRequested = fields.cancelRequested
959
+ if (cancelRequested !== undefined && cancelRequested !== "absent" && cancelRequested !== "present") {
960
+ return Effect.fail(invalidRunError("transitionOwned", "guard.cancelRequested"))
961
+ }
962
+ return Effect.succeed(Object.freeze(cancelRequested === undefined ? {} : { cancelRequested }))
963
+ })
964
+
965
+ const ownerFromColumns = (
966
+ hostId: string | null,
967
+ pid: number | null,
968
+ nonce: string | null
969
+ ): OwnerId | null | undefined => {
970
+ if (hostId === null && pid === null && nonce === null) return null
971
+ if (hostId !== null && pid !== null && nonce !== null) {
972
+ // DatabaseRunRow already admitted all three columns through the stricter
973
+ // durable identifier and safe-integer schemas.
974
+ return Object.freeze({ hostId, pid, nonce })
975
+ }
976
+ return undefined
977
+ }
978
+
979
+ const rowMatchesClaim = (row: DatabaseRunRow, claimant: OwnerId, claimedAtMs: number): boolean =>
980
+ row.claimHostId === claimant.hostId &&
981
+ row.claimPid === claimant.pid &&
982
+ row.claimNonce === claimant.nonce &&
983
+ row.claimedAtMs === claimedAtMs
984
+
985
+ /**
986
+ * The exact predicate the ownership compare-and-swap used to compile into its
987
+ * UPDATE, evaluated in the serialized write transaction instead: the row must
988
+ * still match the caller's expected snapshot.
989
+ */
990
+ const rowMatchesSnapshot = (row: DatabaseRunRow, expected: AdmittedSnapshot): boolean =>
991
+ row.status === expected.status &&
992
+ row.ownerHostId === (expected.owner?.hostId ?? null) &&
993
+ row.ownerPid === (expected.owner?.pid ?? null) &&
994
+ row.ownerNonce === (expected.owner?.nonce ?? null) &&
995
+ row.heartbeatAtMs === expected.heartbeatAtMs
996
+
997
+ const rowOwnedBy = (row: DatabaseRunRow, owner: OwnerId): boolean =>
998
+ row.status === "running" &&
999
+ row.ownerHostId === owner.hostId &&
1000
+ row.ownerPid === owner.pid &&
1001
+ row.ownerNonce === owner.nonce
1002
+
1003
+ const claimColumnsFree = (row: DatabaseRunRow): boolean =>
1004
+ row.claimHostId === null && row.claimPid === null && row.claimNonce === null && row.claimedAtMs === null
1005
+
1006
+ const decodeRunRow = (method: string, runId: string, input: unknown): Effect.Effect<RunRow, RunStoreError> =>
1007
+ Schema.decodeUnknownEffect(DatabaseRunRow)(input).pipe(
1008
+ Effect.mapError(() =>
1009
+ runStoreError(method, "decode_failed", "could not decode flows_runs row", { runId, stage: "row-schema" })
1010
+ ),
1011
+ Effect.flatMap((row) => {
1012
+ const owner = ownerFromColumns(row.ownerHostId, row.ownerPid, row.ownerNonce)
1013
+ const claim = ownerFromColumns(row.claimHostId, row.claimPid, row.claimNonce)
1014
+ const admittedState = stateAdmission(row.stateJson)
1015
+ const stateJsonValid = isJsonString(row.stateJson)
1016
+ const invalidOwner = owner === undefined ||
1017
+ (owner === null && row.heartbeatAtMs !== null) ||
1018
+ (owner !== null && row.heartbeatAtMs === null) ||
1019
+ (row.status === "running" ? owner === null : owner !== null)
1020
+ const invalidClaim = claim === undefined ||
1021
+ (claim === null && row.claimedAtMs !== null) ||
1022
+ (claim !== null && row.claimedAtMs === null)
1023
+ if (invalidOwner || invalidClaim || !admittedState.ok) {
1024
+ // The cause is published to logs, spans, and telemetry. Executable
1025
+ // state may carry credentials, so report only the invariants read here.
1026
+ return Effect.fail(
1027
+ runStoreError(method, "decode_failed", "flows_runs row violates durable invariants", {
1028
+ runId,
1029
+ status: row.status,
1030
+ heartbeatAtMs: row.heartbeatAtMs,
1031
+ claimedAtMs: row.claimedAtMs,
1032
+ hasClaimColumns: row.claimHostId !== null || row.claimPid !== null || row.claimNonce !== null,
1033
+ hasOwnerColumns: row.ownerHostId !== null || row.ownerPid !== null || row.ownerNonce !== null,
1034
+ stateJsonValid
1035
+ })
1036
+ )
1037
+ }
1038
+ return Effect.succeed(Object.freeze({
1039
+ runId: row.runId,
1040
+ status: row.status,
1041
+ createdAtMs: row.createdAtMs,
1042
+ startedAtMs: row.startedAtMs,
1043
+ finishedAtMs: row.finishedAtMs,
1044
+ owner,
1045
+ heartbeatAtMs: row.heartbeatAtMs,
1046
+ claim,
1047
+ claimedAtMs: row.claimedAtMs,
1048
+ parentRunId: row.parentRunId,
1049
+ cancelRequestedAtMs: row.cancelRequestedAtMs,
1050
+ lineageId: row.lineageId,
1051
+ roundOrdinal: row.roundOrdinal,
1052
+ stateJson: row.stateJson
1053
+ }))
1054
+ })
1055
+ )
1056
+
1057
+ /**
1058
+ * Rows that belong to the logical run containing `runId`: every round that
1059
+ * names the resolved lineage, plus the root itself when its own lineage
1060
+ * columns are null or name that lineage. A same-named run in an independent
1061
+ * lineage is excluded. Shared by every lineage read and write so membership
1062
+ * cannot drift between them.
1063
+ */
1064
+ const lineageMembership = (sql: SqlClient.SqlClient, runId: string) =>
1065
+ sql`(
1066
+ lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
1067
+ OR (
1068
+ run_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
1069
+ AND (
1070
+ lineage_id IS NULL
1071
+ OR lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
1072
+ )
1073
+ )
1074
+ )`
1075
+
1076
+ /** The status and request columns a cancellation miss is classified from. */
1077
+ interface CancellationRow {
1078
+ readonly requestedAtMs: number | null
1079
+ readonly status: string
1080
+ }
1081
+
1082
+ /**
1083
+ * Names the outcome of a guarded cancellation UPDATE that matched nothing.
1084
+ *
1085
+ * `rows` are the lineage members in round order; an absent set is `NotFound`.
1086
+ * Status is read before the request column, so a settled run reports how it
1087
+ * ended even when its own closing request is still on the row. A live
1088
+ * unrequested row cannot survive the UPDATE inside one serialized writer
1089
+ * transaction, so it is a persistence invariant failure rather than a retry.
1090
+ */
1091
+ const classifyCancellationMiss = (
1092
+ method: string,
1093
+ runId: string,
1094
+ rows: ReadonlyArray<CancellationRow>
1095
+ ): Effect.Effect<RequestCancelOutcome, RunStoreError> =>
1096
+ Effect.gen(function*() {
1097
+ let outcome: RequestCancelOutcome = notFound
1098
+ for (const row of rows) {
1099
+ const status = yield* Schema.decodeUnknownEffect(RunStatus)(row.status).pipe(
1100
+ Effect.mapError((cause) => runStoreError(method, "decode_failed", "could not decode flows_runs status", cause))
1101
+ )
1102
+ if (isTerminalRunStatus(status)) {
1103
+ // A terminal predecessor never hides a live round's request.
1104
+ if (outcome._tag !== "AlreadyRequested") outcome = { _tag: "Terminal", status }
1105
+ continue
1106
+ }
1107
+ if (row.requestedAtMs === null) {
1108
+ return yield* Effect.fail(
1109
+ runStoreError(method, "persistence_failed", "guarded cancellation update missed a live unrequested row", {
1110
+ runId,
1111
+ stage: "write-invariant"
1112
+ })
1113
+ )
1114
+ }
1115
+ outcome = { _tag: "AlreadyRequested", requestedAtMs: Number(row.requestedAtMs) }
1116
+ }
1117
+ return outcome
1118
+ })
1119
+
1120
+ const selectRun = (sql: SqlClient.SqlClient, runId: string, mode: "single" | "lineage" | "latest" = "single") =>
1121
+ sql<DatabaseRunRow>`
1122
+ SELECT
1123
+ run_id AS "runId",
1124
+ status AS "status",
1125
+ created_at_ms AS "createdAtMs",
1126
+ started_at_ms AS "startedAtMs",
1127
+ finished_at_ms AS "finishedAtMs",
1128
+ owner_host_id AS "ownerHostId",
1129
+ owner_pid AS "ownerPid",
1130
+ owner_nonce AS "ownerNonce",
1131
+ heartbeat_at_ms AS "heartbeatAtMs",
1132
+ claim_host_id AS "claimHostId",
1133
+ claim_pid AS "claimPid",
1134
+ claim_nonce AS "claimNonce",
1135
+ claimed_at_ms AS "claimedAtMs",
1136
+ parent_run_id AS "parentRunId",
1137
+ cancel_requested_at_ms AS "cancelRequestedAtMs",
1138
+ lineage_id AS "lineageId",
1139
+ round_ordinal AS "roundOrdinal",
1140
+ state_json AS "stateJson"
1141
+ FROM flows_runs
1142
+ WHERE ${
1143
+ mode === "latest"
1144
+ ? sql`run_id = COALESCE(
1145
+ (SELECT run_id FROM flows_runs
1146
+ WHERE lineage_id = (SELECT COALESCE(lineage_id, run_id) FROM flows_runs WHERE run_id = ${runId})
1147
+ ORDER BY round_ordinal DESC LIMIT 1),
1148
+ (SELECT run_id FROM flows_runs WHERE run_id = ${runId})
1149
+ )`
1150
+ : mode === "lineage"
1151
+ ? lineageMembership(sql, runId)
1152
+ : sql`run_id = ${runId}`
1153
+ }
1154
+ ORDER BY COALESCE(round_ordinal, 0), run_id
1155
+ `
1156
+
1157
+ const classifyClaimLoss = (
1158
+ row: DatabaseRunRow | undefined,
1159
+ nowMs: number
1160
+ ): ClaimLossOutcome => {
1161
+ if (row === undefined) return notFound
1162
+ if (row.claimHostId !== null) return alreadyClaimed
1163
+ if (
1164
+ row.status === "running" &&
1165
+ row.heartbeatAtMs !== null &&
1166
+ row.heartbeatAtMs >= nowMs - heartbeatStaleAfterMs
1167
+ ) {
1168
+ return heartbeatFresh
1169
+ }
1170
+ return snapshotChanged
1171
+ }
1172
+
1173
+ const evidenceMatches = (
1174
+ expected: RunSnapshot,
1175
+ claimant: OwnerId,
1176
+ checkedAtMs: number,
1177
+ evidence: LivenessEvidence
1178
+ ): boolean =>
1179
+ expected.status === "running" &&
1180
+ expected.owner !== null &&
1181
+ matchesEvidence(expected.owner, claimant, checkedAtMs, evidence)
1182
+
1183
+ /**
1184
+ * Constructs the production `RunStore` implementation.
1185
+ *
1186
+ * `state_json` is executable state: it is decoded and re-entered on every
1187
+ * resume, so it is persisted and returned byte-for-byte. Nothing rewrites it
1188
+ * on the way through — a redactor here would silently change what the flow
1189
+ * re-reads (issue #72). `Schema.Redacted` hides inspection but allows schema
1190
+ * JSON encoding by default. Use `Schema.Redacted(Schema.String, {
1191
+ * disallowJsonEncode: true })` to refuse JSON encoding, or exclude credentials
1192
+ * from the persistence schema and store a secret reference resolved from a
1193
+ * secret provider on resume. Publication hygiene belongs on journal-event
1194
+ * and export surfaces.
1195
+ *
1196
+ * `state_json` and `flows_attempts.outcome_json` therefore hold unredacted
1197
+ * run inputs and results, so the run's SQLite file (`.flows/*.db`) must be
1198
+ * handled as a secret store.
1199
+ *
1200
+ * @since 0.1.0
1201
+ * @category constructors
1202
+ */
1203
+ export const make: Effect.Effect<Service, never, Consensus | DurableWriter | SqlClient.SqlClient> = Effect.gen(
1204
+ function*() {
1205
+ const sql = yield* Effect.service(SqlClient.SqlClient)
1206
+ const writer = yield* DurableWriter
1207
+ const consensus = yield* Consensus
1208
+
1209
+ const write = <A, R>(
1210
+ method: string,
1211
+ effect: Effect.Effect<A, SqlError.SqlError | RunStoreError | ConsensusError | JournalError, R>
1212
+ ): Effect.Effect<A, RunStoreError, R> =>
1213
+ writer.write(effect).pipe(Effect.mapError((cause) => persistenceError(method, cause)))
1214
+
1215
+ /** Appends an ownership transition to the run's companion stream (see {@link OwnershipTransition}). */
1216
+ const recordTransition = (
1217
+ runId: string,
1218
+ transition: OwnershipTransition,
1219
+ owner: OwnerId,
1220
+ grantedAtMs: number | null
1221
+ ): Effect.Effect<void, JournalError> =>
1222
+ Companion.append(runId, ownershipEventType(transition), {
1223
+ owner: { hostId: owner.hostId, pid: owner.pid, nonce: owner.nonce },
1224
+ grantedAtMs
1225
+ })
1226
+
1227
+ // A bare SELECT needs no write transaction and no replay; only the error
1228
+ // vocabulary stays shared with `write`.
1229
+ const read = <A, R>(
1230
+ method: string,
1231
+ effect: Effect.Effect<A, SqlError.SqlError, R>
1232
+ ): Effect.Effect<A, RunStoreError, R> =>
1233
+ effect.pipe(Effect.mapError((cause) => persistenceError(method, fromSqlError(cause))))
1234
+
1235
+ const create = Effect.fn("RunStore.create")(
1236
+ (
1237
+ runIdInput: string,
1238
+ stateInput: string,
1239
+ optionsInput?: CreateOptions | undefined
1240
+ ): Effect.Effect<void, RunStoreError> =>
1241
+ Effect.gen(function*() {
1242
+ const runId = yield* snapshotRunId("create", runIdInput)
1243
+ if (isCompanionRunId(runId)) {
1244
+ return yield* Effect.fail(invalidRunError("create", "runId", "names a reserved journal companion stream"))
1245
+ }
1246
+ const { lineageId, parentRunId, roundOrdinal } = yield* snapshotCreateOptions(optionsInput)
1247
+ // This cause is published to logs, spans, and telemetry. Executable
1248
+ // state may carry credentials, so include its shape but never its text.
1249
+ const stateJson = yield* snapshotState("create", stateInput, {
1250
+ runId,
1251
+ parentRunId,
1252
+ lineageId,
1253
+ roundOrdinal,
1254
+ stateJsonLength: typeof stateInput === "string" ? stateInput.length : null,
1255
+ stateJsonValid: isJsonString(stateInput)
1256
+ })
1257
+ yield* Effect.annotateCurrentSpan({ runId })
1258
+ const createdAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
1259
+ yield* write(
1260
+ "create",
1261
+ sql`
1262
+ INSERT INTO flows_runs (
1263
+ run_id,
1264
+ status,
1265
+ created_at_ms,
1266
+ started_at_ms,
1267
+ finished_at_ms,
1268
+ owner_host_id,
1269
+ owner_pid,
1270
+ owner_nonce,
1271
+ heartbeat_at_ms,
1272
+ claim_host_id,
1273
+ claim_pid,
1274
+ claim_nonce,
1275
+ claimed_at_ms,
1276
+ parent_run_id,
1277
+ lineage_id,
1278
+ round_ordinal,
1279
+ state_json
1280
+ ) VALUES (
1281
+ ${runId},
1282
+ 'pending',
1283
+ ${createdAtMs},
1284
+ NULL,
1285
+ NULL,
1286
+ NULL,
1287
+ NULL,
1288
+ NULL,
1289
+ NULL,
1290
+ NULL,
1291
+ NULL,
1292
+ NULL,
1293
+ NULL,
1294
+ ${parentRunId},
1295
+ ${lineageId},
1296
+ ${roundOrdinal},
1297
+ ${stateJson}
1298
+ )
1299
+ `.pipe(Effect.asVoid)
1300
+ )
1301
+ }).pipe(observeExit)
1302
+ )
1303
+
1304
+ const get = Effect.fn("RunStore.get")((runIdInput: string): Effect.Effect<RunRow, RunStoreError> =>
1305
+ Effect.gen(function*() {
1306
+ const runId = yield* snapshotRunId("get", runIdInput)
1307
+ yield* Effect.annotateCurrentSpan({ runId })
1308
+ const rows = yield* read("get", selectRun(sql, runId))
1309
+ return rows[0] === undefined
1310
+ ? yield* Effect.fail(
1311
+ runStoreError("get", "not_found_row", `run ${runId} was not found`, { runId })
1312
+ )
1313
+ : yield* decodeRunRow("get", runId, rows[0])
1314
+ }).pipe(observeExit)
1315
+ )
1316
+
1317
+ const lineage = Effect.fn("RunStore.lineage")((runIdInput: string) =>
1318
+ Effect.gen(function*() {
1319
+ const runId = yield* snapshotRunId("lineage", runIdInput)
1320
+ yield* Effect.annotateCurrentSpan({ runId })
1321
+ const rows = yield* read("lineage", selectRun(sql, runId, "lineage"))
1322
+ return yield* Effect.forEach(rows, (row) => decodeRunRow("lineage", row.runId, row))
1323
+ }).pipe(observeExit)
1324
+ )
1325
+
1326
+ const latestRound = Effect.fn("RunStore.latestRound")((runIdInput: string) =>
1327
+ Effect.gen(function*() {
1328
+ const runId = yield* snapshotRunId("latestRound", runIdInput)
1329
+ yield* Effect.annotateCurrentSpan({ runId })
1330
+ const rows = yield* read("latestRound", selectRun(sql, runId, "latest"))
1331
+ return rows[0] === undefined
1332
+ ? yield* Effect.fail(runStoreError("latestRound", "not_found_row", `run ${runId} was not found`, { runId }))
1333
+ : yield* decodeRunRow("latestRound", rows[0].runId, rows[0])
1334
+ }).pipe(observeExit)
1335
+ )
1336
+
1337
+ const requestCancel = Effect.fn("RunStore.requestCancel")((
1338
+ runIdInput: string,
1339
+ nowMsInput: number
1340
+ ): Effect.Effect<RequestCancelOutcome, RunStoreError> =>
1341
+ Effect.gen(function*() {
1342
+ const runId = yield* snapshotRunId("requestCancel", runIdInput)
1343
+ const nowMs = yield* snapshotTimestamp("requestCancel", "nowMs", nowMsInput)
1344
+ yield* Effect.annotateCurrentSpan({ runId })
1345
+ return yield* write(
1346
+ "requestCancel",
1347
+ Effect.gen(function*() {
1348
+ // The status predicate is part of the compare-and-swap rather than
1349
+ // a read-then-write check: a run that settles between a caller's
1350
+ // read and this UPDATE must lose the write, not race it. The writer
1351
+ // serializes the whole transaction, so a miss is classified by one
1352
+ // read of the same columns and never retried.
1353
+ const rows = yield* sql<{ readonly requestedAtMs: number }>`
1354
+ UPDATE flows_runs
1355
+ SET cancel_requested_at_ms = ${nowMs}
1356
+ WHERE run_id = ${runId}
1357
+ AND cancel_requested_at_ms IS NULL
1358
+ AND status NOT IN ('completed', 'failed', 'cancelled')
1359
+ RETURNING cancel_requested_at_ms AS "requestedAtMs"
1360
+ `
1361
+ if (rows[0] !== undefined) {
1362
+ return { _tag: "CancelRequested", requestedAtMs: Number(rows[0].requestedAtMs) } as const
1363
+ }
1364
+ const current = yield* sql<CancellationRow>`
1365
+ SELECT cancel_requested_at_ms AS "requestedAtMs", status AS "status"
1366
+ FROM flows_runs WHERE run_id = ${runId}
1367
+ `
1368
+ return yield* classifyCancellationMiss("requestCancel", runId, current)
1369
+ })
1370
+ )
1371
+ }).pipe(observeOutcome<RequestCancelOutcome>())
1372
+ )
1373
+
1374
+ const requestCancelLineage = Effect.fn("RunStore.requestCancelLineage")((runIdInput: string, nowMsInput: number) =>
1375
+ Effect.gen(function*() {
1376
+ const runId = yield* snapshotRunId("requestCancelLineage", runIdInput)
1377
+ const nowMs = yield* snapshotTimestamp("requestCancelLineage", "nowMs", nowMsInput)
1378
+ yield* Effect.annotateCurrentSpan({ runId })
1379
+ return yield* write(
1380
+ "requestCancelLineage",
1381
+ Effect.gen(function*() {
1382
+ // One set-based guarded UPDATE over the lineage: cost follows the
1383
+ // members' metadata, never their state payloads, and a settled
1384
+ // history of any length adds no statements.
1385
+ const rows = yield* sql<{ readonly requestedAtMs: number }>`
1386
+ UPDATE flows_runs
1387
+ SET cancel_requested_at_ms = ${nowMs}
1388
+ WHERE ${lineageMembership(sql, runId)}
1389
+ AND cancel_requested_at_ms IS NULL
1390
+ AND status NOT IN ('completed', 'failed', 'cancelled')
1391
+ RETURNING cancel_requested_at_ms AS "requestedAtMs"
1392
+ `
1393
+ if (rows[0] !== undefined) {
1394
+ return { _tag: "CancelRequested", requestedAtMs: Number(rows[0].requestedAtMs) } as const
1395
+ }
1396
+ // A previous request on any live round dominates a terminal
1397
+ // predecessor; otherwise the latest round's ending is reported.
1398
+ const members = yield* sql<CancellationRow>`
1399
+ SELECT cancel_requested_at_ms AS "requestedAtMs", status AS "status"
1400
+ FROM flows_runs
1401
+ WHERE ${lineageMembership(sql, runId)}
1402
+ ORDER BY COALESCE(round_ordinal, 0), run_id
1403
+ `
1404
+ return yield* classifyCancellationMiss("requestCancelLineage", runId, members)
1405
+ })
1406
+ )
1407
+ }).pipe(observeOutcome<RequestCancelOutcome>())
1408
+ )
1409
+
1410
+ const claim = Effect.fn("RunStore.claim")((
1411
+ runIdInput: string,
1412
+ expectedInput: RunSnapshot,
1413
+ claimantInput: OwnerId,
1414
+ nowMsInput: number
1415
+ ): Effect.Effect<ClaimOutcome, RunStoreError> =>
1416
+ Effect.gen(function*() {
1417
+ const runId = yield* snapshotRunId("claim", runIdInput)
1418
+ const expected = yield* snapshotExpected("claim", expectedInput)
1419
+ const claimant = yield* snapshotOwner("claim", "claimant", claimantInput)
1420
+ const nowMs = yield* snapshotLeaseReading(
1421
+ "claim",
1422
+ "nowMs",
1423
+ nowMsInput,
1424
+ { runId }
1425
+ )
1426
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId })
1427
+ return yield* write(
1428
+ "claim",
1429
+ Effect.gen(function*() {
1430
+ // `claim` never admits a running run, so it needs no staleness
1431
+ // test: `pending` and `suspended` exclude it. `claimAndOwn` is the
1432
+ // method that genuinely needs one, because it does admit `running`.
1433
+ // The snapshot predicate is evaluated inside the serialized write
1434
+ // transaction, so it is exactly the compare-and-swap it replaces.
1435
+ const row = (yield* selectRun(sql, runId))[0]
1436
+ const admissible = row !== undefined &&
1437
+ (row.status === "pending" || row.status === "suspended") &&
1438
+ rowMatchesSnapshot(row, expected) &&
1439
+ claimColumnsFree(row)
1440
+ if (!admissible) return classifyClaimLoss(row, nowMs)
1441
+ const grant = yield* consensus.claim(runId, claimant, nowMs)
1442
+ if (grant._tag === "Rejected") {
1443
+ // The strategy is authoritative: the row admitted what the lease
1444
+ // refused, and the loss is classified from the row exactly as a
1445
+ // failed compare-and-swap always was.
1446
+ return classifyClaimLoss(row, nowMs)
1447
+ }
1448
+ yield* sql`
1449
+ UPDATE flows_runs
1450
+ SET
1451
+ claim_host_id = ${claimant.hostId},
1452
+ claim_pid = ${claimant.pid},
1453
+ claim_nonce = ${claimant.nonce},
1454
+ claimed_at_ms = ${grant.grantedAtMs}
1455
+ WHERE run_id = ${runId}
1456
+ `
1457
+ yield* recordTransition(runId, "claimed", claimant, grant.grantedAtMs)
1458
+ return claimed(grant.grantedAtMs)
1459
+ })
1460
+ )
1461
+ }).pipe(observeOutcome(
1462
+ (outcome) => RunStoreMetrics.claim[outcome._tag],
1463
+ Metric.withAttributes(RunStoreMetrics.claims, { op: "claim" })
1464
+ ))
1465
+ )
1466
+
1467
+ const claimAndOwn = Effect.fn("RunStore.claimAndOwn")((
1468
+ runIdInput: string,
1469
+ expectedInput: RunSnapshot,
1470
+ ownerInput: OwnerId,
1471
+ nowMsInput: number,
1472
+ evidenceInput?: LivenessEvidence | undefined
1473
+ ): Effect.Effect<ClaimAndOwnOutcome, RunStoreError> =>
1474
+ Effect.gen(function*() {
1475
+ const runId = yield* snapshotRunId("claimAndOwn", runIdInput)
1476
+ const expected = yield* snapshotExpected("claimAndOwn", expectedInput)
1477
+ const owner = yield* snapshotOwner("claimAndOwn", "owner", ownerInput)
1478
+ const nowMs = yield* snapshotLeaseReading(
1479
+ "claimAndOwn",
1480
+ "nowMs",
1481
+ nowMsInput,
1482
+ { runId }
1483
+ )
1484
+ const evidence = evidenceInput === undefined
1485
+ ? undefined
1486
+ : yield* snapshotEvidence("claimAndOwn", evidenceInput)
1487
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId })
1488
+ return yield* Effect.suspend((): Effect.Effect<ClaimAndOwnOutcome, RunStoreError> => {
1489
+ if (
1490
+ expected.status === "running" &&
1491
+ !sameOwner(expected.owner, owner) &&
1492
+ !(evidence !== undefined && evidenceMatches(expected, owner, nowMs, evidence))
1493
+ ) {
1494
+ return read("claimAndOwn", selectRun(sql, runId)).pipe(
1495
+ Effect.map((current) => {
1496
+ const row = current[0]
1497
+ // Classify the lease before upgrading an unchanged stale
1498
+ // snapshot. Evidence cannot displace a fresh heartbeat, so
1499
+ // naming evidence first would advise a retry the SQL must refuse.
1500
+ const loss = classifyClaimLoss(row, nowMs)
1501
+ return loss === snapshotChanged && row !== undefined && rowMatchesSnapshot(row, expected)
1502
+ ? evidenceRequired
1503
+ : loss
1504
+ })
1505
+ )
1506
+ }
1507
+
1508
+ return write(
1509
+ "claimAndOwn",
1510
+ Effect.gen(function*() {
1511
+ const row = (yield* selectRun(sql, runId))[0]
1512
+ const admissible = row !== undefined &&
1513
+ (row.status === "pending" || row.status === "suspended" || row.status === "running") &&
1514
+ rowMatchesSnapshot(row, expected) &&
1515
+ claimColumnsFree(row) &&
1516
+ (row.status !== "running" ||
1517
+ row.heartbeatAtMs === null ||
1518
+ row.heartbeatAtMs < nowMs - heartbeatStaleAfterMs)
1519
+ if (!admissible) return classifyClaimLoss(row, nowMs)
1520
+ const expectedOwner = expected.owner
1521
+ let grant: LeaseClaimOutcome
1522
+ let transition: OwnershipTransition = "claimed"
1523
+ if (expectedOwner === null) {
1524
+ grant = yield* consensus.claim(runId, owner, nowMs)
1525
+ } else if (sameOwner(expectedOwner, owner)) {
1526
+ // Re-owning one's own stale run: the stale lease is released
1527
+ // and re-granted under a fresh generation (R4).
1528
+ yield* consensus.release(runId, owner)
1529
+ yield* recordTransition(runId, "released", owner, null)
1530
+ grant = yield* consensus.claim(runId, owner, nowMs)
1531
+ } else {
1532
+ // Admission above proved the evidence matches, so the steal is
1533
+ // the strategy's evidence-gated claim.
1534
+ grant = yield* consensus.steal(runId, owner, nowMs, evidence!)
1535
+ transition = "stolen"
1536
+ }
1537
+ if (grant._tag === "Rejected") return classifyClaimLoss(row, nowMs)
1538
+ const activation = yield* consensus.activate(runId, owner, grant.grantedAtMs, nowMs)
1539
+ if (activation._tag === "Lost") {
1540
+ // The strategy revoked the grant it just made — its lease moved
1541
+ // on under the transaction — so the fresh grant is released and
1542
+ // the loss classified from the row.
1543
+ yield* consensus.release(runId, owner)
1544
+ return classifyClaimLoss(row, nowMs)
1545
+ }
1546
+ yield* recordTransition(runId, transition, owner, grant.grantedAtMs)
1547
+ yield* recordTransition(runId, "activated", owner, grant.grantedAtMs)
1548
+ yield* sql`
1549
+ UPDATE flows_runs
1550
+ SET
1551
+ status = 'running',
1552
+ started_at_ms = COALESCE(started_at_ms, ${nowMs}),
1553
+ finished_at_ms = NULL,
1554
+ owner_host_id = ${owner.hostId},
1555
+ owner_pid = ${owner.pid},
1556
+ owner_nonce = ${owner.nonce},
1557
+ heartbeat_at_ms = ${nowMs}
1558
+ WHERE run_id = ${runId}
1559
+ `
1560
+ return activated
1561
+ })
1562
+ )
1563
+ })
1564
+ }).pipe(observeOutcome(
1565
+ (outcome) => RunStoreMetrics.claimAndOwn[outcome._tag],
1566
+ Metric.withAttributes(RunStoreMetrics.claims, { op: "claim_and_own" })
1567
+ ))
1568
+ )
1569
+
1570
+ const activate = Effect.fn("RunStore.activate")((
1571
+ runIdInput: string,
1572
+ claimantInput: OwnerId,
1573
+ claimedAtMsInput: number,
1574
+ expectedInput: RunSnapshot
1575
+ ): Effect.Effect<ActivateOutcome, RunStoreError> =>
1576
+ Effect.gen(function*() {
1577
+ const runId = yield* snapshotRunId("activate", runIdInput)
1578
+ const claimant = yield* snapshotOwner("activate", "claimant", claimantInput)
1579
+ const claimedAtMs = yield* snapshotTimestamp(
1580
+ "activate",
1581
+ "claimedAtMs",
1582
+ claimedAtMsInput
1583
+ )
1584
+ const expected = yield* snapshotExpected("activate", expectedInput)
1585
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId })
1586
+ const activatedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
1587
+ return yield* write(
1588
+ "activate",
1589
+ Effect.gen(function*() {
1590
+ const row = (yield* selectRun(sql, runId))[0]
1591
+ if (row === undefined || !rowMatchesClaim(row, claimant, claimedAtMs)) return claimLost
1592
+
1593
+ const clearClaim = sql`
1594
+ UPDATE flows_runs
1595
+ SET
1596
+ claim_host_id = NULL,
1597
+ claim_pid = NULL,
1598
+ claim_nonce = NULL,
1599
+ claimed_at_ms = NULL
1600
+ WHERE run_id = ${runId}
1601
+ AND claim_host_id = ${claimant.hostId}
1602
+ AND claim_pid = ${claimant.pid}
1603
+ AND claim_nonce = ${claimant.nonce}
1604
+ AND claimed_at_ms = ${claimedAtMs}
1605
+ `
1606
+ if (!rowMatchesSnapshot(row, expected)) {
1607
+ // The claim is intact but the guarded snapshot moved on: clear
1608
+ // the claim, exactly as the compare-and-swap always did, and
1609
+ // release the strategy's matching claim so the two stay aligned.
1610
+ yield* consensus.release(runId, claimant)
1611
+ yield* recordTransition(runId, "released", claimant, claimedAtMs)
1612
+ yield* clearClaim
1613
+ return snapshotChanged
1614
+ }
1615
+ const activation = yield* consensus.activate(runId, claimant, claimedAtMs, activatedAtMs)
1616
+ if (activation._tag === "Lost") {
1617
+ // The strategy no longer holds this claim though the row still
1618
+ // shows it; the strategy is authoritative, so the stale claim
1619
+ // columns are cleared and the claim reported lost.
1620
+ yield* clearClaim
1621
+ return claimLost
1622
+ }
1623
+ yield* sql`
1624
+ UPDATE flows_runs
1625
+ SET
1626
+ status = 'running',
1627
+ started_at_ms = COALESCE(started_at_ms, ${activatedAtMs}),
1628
+ finished_at_ms = NULL,
1629
+ owner_host_id = ${claimant.hostId},
1630
+ owner_pid = ${claimant.pid},
1631
+ owner_nonce = ${claimant.nonce},
1632
+ heartbeat_at_ms = ${activatedAtMs},
1633
+ claim_host_id = NULL,
1634
+ claim_pid = NULL,
1635
+ claim_nonce = NULL,
1636
+ claimed_at_ms = NULL
1637
+ WHERE run_id = ${runId}
1638
+ `
1639
+ yield* recordTransition(runId, "activated", claimant, claimedAtMs)
1640
+ return activated
1641
+ })
1642
+ )
1643
+ }).pipe(observeOutcome(
1644
+ (outcome) => RunStoreMetrics.activate[outcome._tag],
1645
+ Metric.withAttributes(RunStoreMetrics.claims, { op: "activate" })
1646
+ ))
1647
+ )
1648
+
1649
+ const abandonClaim = Effect.fn("RunStore.abandonClaim")((
1650
+ runIdInput: string,
1651
+ claimantInput: OwnerId,
1652
+ claimedAtMsInput: number
1653
+ ): Effect.Effect<AbandonClaimOutcome, RunStoreError> =>
1654
+ Effect.gen(function*() {
1655
+ const runId = yield* snapshotRunId("abandonClaim", runIdInput)
1656
+ const claimant = yield* snapshotOwner("abandonClaim", "claimant", claimantInput)
1657
+ const claimedAtMs = yield* snapshotTimestamp("abandonClaim", "claimedAtMs", claimedAtMsInput)
1658
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId })
1659
+ return yield* write(
1660
+ "abandonClaim",
1661
+ Effect.gen(function*() {
1662
+ const rows = yield* sql<{ readonly runId: string }>`
1663
+ UPDATE flows_runs
1664
+ SET
1665
+ claim_host_id = NULL,
1666
+ claim_pid = NULL,
1667
+ claim_nonce = NULL,
1668
+ claimed_at_ms = NULL
1669
+ WHERE run_id = ${runId}
1670
+ AND claim_host_id = ${claimant.hostId}
1671
+ AND claim_pid = ${claimant.pid}
1672
+ AND claim_nonce = ${claimant.nonce}
1673
+ AND claimed_at_ms = ${claimedAtMs}
1674
+ RETURNING run_id AS "runId"
1675
+ `
1676
+ if (rows.length === 0) return claimLost
1677
+ yield* consensus.release(runId, claimant)
1678
+ yield* recordTransition(runId, "released", claimant, claimedAtMs)
1679
+ return abandoned
1680
+ })
1681
+ )
1682
+ }).pipe(observeOutcome(
1683
+ (outcome) => RunStoreMetrics.abandonClaim[outcome._tag],
1684
+ Metric.withAttributes(RunStoreMetrics.claims, { op: "abandon_claim" })
1685
+ ))
1686
+ )
1687
+
1688
+ const recoverClaim = Effect.fn("RunStore.recoverClaim")((
1689
+ runIdInput: string,
1690
+ staleClaimantInput: OwnerId,
1691
+ claimedAtMsInput: number,
1692
+ observerInput: OwnerId,
1693
+ nowMsInput: number,
1694
+ evidenceInput: LivenessEvidence
1695
+ ): Effect.Effect<RecoverClaimOutcome, RunStoreError> =>
1696
+ Effect.gen(function*() {
1697
+ const runId = yield* snapshotRunId("recoverClaim", runIdInput)
1698
+ const staleClaimant = yield* snapshotOwner("recoverClaim", "staleClaimant", staleClaimantInput)
1699
+ const claimedAtMs = yield* snapshotTimestamp(
1700
+ "recoverClaim",
1701
+ "claimedAtMs",
1702
+ claimedAtMsInput
1703
+ )
1704
+ const observer = yield* snapshotOwner("recoverClaim", "observer", observerInput)
1705
+ const nowMs = yield* snapshotLeaseReading("recoverClaim", "nowMs", nowMsInput, { runId, claimedAtMs })
1706
+ const evidence = yield* snapshotEvidence("recoverClaim", evidenceInput)
1707
+ yield* Effect.annotateCurrentSpan({ runId, observerHostId: observer.hostId })
1708
+ return yield* Effect.suspend((): Effect.Effect<RecoverClaimOutcome, RunStoreError> => {
1709
+ if (!matchesEvidence(staleClaimant, observer, nowMs, evidence)) {
1710
+ return Effect.succeed(livenessUnconfirmed)
1711
+ }
1712
+ return write(
1713
+ "recoverClaim",
1714
+ Effect.gen(function*() {
1715
+ const row = (yield* selectRun(sql, runId))[0]
1716
+ if (row === undefined) return notFound
1717
+ if (!rowMatchesClaim(row, staleClaimant, claimedAtMs)) return claimChanged
1718
+ if (claimedAtMs >= nowMs - heartbeatStaleAfterMs) return claimFresh
1719
+ const outcome = yield* consensus.recover(runId, staleClaimant, claimedAtMs, observer, nowMs, evidence)
1720
+ if (outcome._tag === "Rejected") {
1721
+ // The strategy refused what the row admitted: as the strategy
1722
+ // knows it, the claim has moved on.
1723
+ return claimChanged
1724
+ }
1725
+ yield* sql`
1726
+ UPDATE flows_runs
1727
+ SET
1728
+ claim_host_id = NULL,
1729
+ claim_pid = NULL,
1730
+ claim_nonce = NULL,
1731
+ claimed_at_ms = NULL
1732
+ WHERE run_id = ${runId}
1733
+ `
1734
+ yield* recordTransition(runId, "expired", staleClaimant, claimedAtMs)
1735
+ return recovered
1736
+ })
1737
+ )
1738
+ })
1739
+ }).pipe(observeOutcome(
1740
+ (outcome) => RunStoreMetrics.recoverClaim[outcome._tag],
1741
+ Metric.withAttributes(RunStoreMetrics.claims, { op: "recover_claim" })
1742
+ ))
1743
+ )
1744
+
1745
+ const renewLease = (method: "heartbeat" | "reconfirm") =>
1746
+ Effect.fn(`RunStore.${method}`)((
1747
+ runIdInput: string,
1748
+ ownerInput: OwnerId,
1749
+ nowMsInput: number
1750
+ ): Effect.Effect<HeartbeatOutcome, RunStoreError> =>
1751
+ Effect.gen(function*() {
1752
+ const runId = yield* snapshotRunId(method, runIdInput)
1753
+ const owner = yield* snapshotOwner(method, "owner", ownerInput)
1754
+ const nowMs = yield* snapshotLeaseReading(
1755
+ method,
1756
+ "nowMs",
1757
+ nowMsInput,
1758
+ { runId }
1759
+ )
1760
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId })
1761
+ return yield* write(
1762
+ method,
1763
+ Effect.gen(function*() {
1764
+ // The pulse drives the strategy's lease renewal; the row mirrors
1765
+ // the stamp the strategy recorded. The lease timestamp is
1766
+ // monotonic: a heartbeat that arrives late — delayed past a newer
1767
+ // one from the same owner — never moves it backwards and never
1768
+ // makes a live run look stale to `claimAndOwn`/`steal`'s cutoff. A
1769
+ // late clock reading still reports `Updated`: the fence held, and
1770
+ // the write proves liveness regardless of which caller clock
1771
+ // reading it carried. Prior art: Temporal's shard `rangeID` only
1772
+ // ever advances (`reference/temporal/service/history/shard/context_impl.go`,
1773
+ // `renewRangeLocked`). Heartbeats never enter the journal.
1774
+ const outcome = yield* consensus[method](runId, owner, nowMs)
1775
+ if (outcome._tag === "Lost") {
1776
+ const current = yield* selectRun(sql, runId)
1777
+ return current.length === 0 ? notFound : fenceLost
1778
+ }
1779
+ // The mirror write is verified: a renewed lease over a row this
1780
+ // owner no longer holds is a lease/row disagreement, and a success
1781
+ // that wrote nothing would hide it. RETURNING keeps the old row
1782
+ // answer — `FenceLost` when the row moved on, `NotFound` when it
1783
+ // is gone.
1784
+ const rows = yield* sql<{ readonly runId: string }>`
1785
+ UPDATE flows_runs
1786
+ SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${outcome.heartbeatAtMs})
1787
+ WHERE run_id = ${runId}
1788
+ AND status = 'running'
1789
+ AND owner_host_id = ${owner.hostId}
1790
+ AND owner_pid = ${owner.pid}
1791
+ AND owner_nonce = ${owner.nonce}
1792
+ RETURNING run_id AS "runId"
1793
+ `
1794
+ if (rows.length > 0) return updated
1795
+ const current = yield* selectRun(sql, runId)
1796
+ return current.length === 0 ? notFound : fenceLost
1797
+ })
1798
+ )
1799
+ }).pipe(observeOutcome((outcome) => RunStoreMetrics.heartbeat[outcome._tag], RunStoreMetrics.heartbeats))
1800
+ )
1801
+
1802
+ const heartbeat = renewLease("heartbeat")
1803
+ const reconfirm = renewLease("reconfirm")
1804
+
1805
+ const transitionOwned = Effect.fn("RunStore.transitionOwned")((
1806
+ runIdInput: string,
1807
+ ownerInput: OwnerId,
1808
+ toStatusInput: RunStatus,
1809
+ stateInput?: string | undefined,
1810
+ guardInput?: TransitionGuard | undefined
1811
+ ): Effect.Effect<TransitionOutcome, RunStoreError> =>
1812
+ Effect.gen(function*() {
1813
+ const runId = yield* snapshotRunId("transitionOwned", runIdInput)
1814
+ const owner = yield* snapshotOwner("transitionOwned", "owner", ownerInput)
1815
+ if (!Schema.is(RunStatus)(toStatusInput) || toStatusInput === "pending") {
1816
+ return yield* Effect.fail(invalidRunError("transitionOwned", "toStatus"))
1817
+ }
1818
+ const toStatus = toStatusInput
1819
+ const state = stateInput === undefined
1820
+ ? null
1821
+ : yield* snapshotState("transitionOwned", stateInput, {
1822
+ runId,
1823
+ stateJsonLength: typeof stateInput === "string" ? stateInput.length : null,
1824
+ stateJsonValid: isJsonString(stateInput)
1825
+ })
1826
+ const guard = yield* snapshotGuard(guardInput)
1827
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId, to: toStatus })
1828
+ const transitionedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
1829
+ const outcome = yield* write(
1830
+ "transitionOwned",
1831
+ Effect.gen(function*() {
1832
+ // The fence check is the strategy's guard (R3); the row check keeps
1833
+ // the mirror honest. Both run in the serialized write transaction,
1834
+ // so a concurrent cancellation request or reclaim can never slip
1835
+ // between check and write.
1836
+ const held = yield* consensus.guard(runId, owner).pipe(
1837
+ Effect.as(true),
1838
+ Effect.catch((cause) => cause.code === "fence_lost" ? Effect.succeed(false) : Effect.fail(cause))
1839
+ )
1840
+ const row = (yield* selectRun(sql, runId))[0]
1841
+ if (row === undefined) return notFound
1842
+ if (!held || !rowOwnedBy(row, owner)) return fenceLost
1843
+ const guardBlocked = (guard?.cancelRequested === "absent" && row.cancelRequestedAtMs !== null) ||
1844
+ (guard?.cancelRequested === "present" && row.cancelRequestedAtMs === null)
1845
+ if (guardBlocked) return guardFailed
1846
+ if (toStatus === "running") {
1847
+ yield* sql`
1848
+ UPDATE flows_runs
1849
+ SET
1850
+ status = 'running',
1851
+ finished_at_ms = NULL,
1852
+ state_json = COALESCE(${state}, state_json)
1853
+ WHERE run_id = ${runId}
1854
+ `
1855
+ return transitioned
1856
+ }
1857
+ yield* sql`
1858
+ UPDATE flows_runs
1859
+ SET
1860
+ status = ${toStatus},
1861
+ finished_at_ms = ${terminalStatuses.has(toStatus) ? transitionedAtMs : null},
1862
+ owner_host_id = NULL,
1863
+ owner_pid = NULL,
1864
+ owner_nonce = NULL,
1865
+ heartbeat_at_ms = NULL,
1866
+ claim_host_id = NULL,
1867
+ claim_pid = NULL,
1868
+ claim_nonce = NULL,
1869
+ claimed_at_ms = NULL,
1870
+ state_json = COALESCE(${state}, state_json)
1871
+ WHERE run_id = ${runId}
1872
+ `
1873
+ yield* consensus.release(runId, owner)
1874
+ yield* recordTransition(runId, "released", owner, null)
1875
+ if (terminalStatuses.has(toStatus)) {
1876
+ yield* afterCommit(Metric.update(ObservabilityMetric.runThroughput, 1), sql)
1877
+ }
1878
+ return transitioned
1879
+ })
1880
+ )
1881
+ return outcome
1882
+ }).pipe(
1883
+ observeOutcome((outcome) =>
1884
+ Metric.withAttributes(RunStoreMetrics.transition[outcome._tag], {
1885
+ to: toStatusInput
1886
+ }), Metric.withAttributes(RunStoreMetrics.transitions, { to: toStatusInput }))
1887
+ )
1888
+ )
1889
+
1890
+ const steal = Effect.fn("RunStore.steal")((
1891
+ runIdInput: string,
1892
+ expectedInput: RunSnapshot,
1893
+ claimantInput: OwnerId,
1894
+ nowMsInput: number,
1895
+ evidenceInput: LivenessEvidence
1896
+ ): Effect.Effect<StealOutcome, RunStoreError> =>
1897
+ Effect.gen(function*() {
1898
+ const runId = yield* snapshotRunId("steal", runIdInput)
1899
+ const expected = yield* snapshotExpected("steal", expectedInput)
1900
+ const claimant = yield* snapshotOwner("steal", "claimant", claimantInput)
1901
+ const nowMs = yield* snapshotLeaseReading(
1902
+ "steal",
1903
+ "nowMs",
1904
+ nowMsInput,
1905
+ { runId }
1906
+ )
1907
+ const evidence = yield* snapshotEvidence("steal", evidenceInput)
1908
+ yield* Effect.annotateCurrentSpan({ runId, claimantHostId: claimant.hostId })
1909
+ return yield* Effect.suspend((): Effect.Effect<StealOutcome, RunStoreError> => {
1910
+ if (!evidenceMatches(expected, claimant, nowMs, evidence)) {
1911
+ return Effect.succeed(livenessUnconfirmed)
1912
+ }
1913
+ return write(
1914
+ "steal",
1915
+ Effect.gen(function*() {
1916
+ const row = (yield* selectRun(sql, runId))[0]
1917
+ const admissible = row !== undefined &&
1918
+ rowMatchesSnapshot(row, expected) &&
1919
+ claimColumnsFree(row) &&
1920
+ row.heartbeatAtMs !== null &&
1921
+ row.heartbeatAtMs < nowMs - heartbeatStaleAfterMs
1922
+ if (!admissible) return classifyClaimLoss(row, nowMs)
1923
+ const grant = yield* consensus.steal(runId, claimant, nowMs, evidence)
1924
+ if (grant._tag === "Rejected") {
1925
+ // The strategy refused what the row admitted — for example its
1926
+ // lease still records a fresh heartbeat — and is authoritative.
1927
+ return classifyClaimLoss(row, nowMs)
1928
+ }
1929
+ yield* sql`
1930
+ UPDATE flows_runs
1931
+ SET
1932
+ claim_host_id = ${claimant.hostId},
1933
+ claim_pid = ${claimant.pid},
1934
+ claim_nonce = ${claimant.nonce},
1935
+ claimed_at_ms = ${grant.grantedAtMs}
1936
+ WHERE run_id = ${runId}
1937
+ `
1938
+ yield* recordTransition(runId, "stolen", claimant, grant.grantedAtMs)
1939
+ return claimed(grant.grantedAtMs)
1940
+ })
1941
+ )
1942
+ })
1943
+ }).pipe(observeOutcome(
1944
+ (outcome) => RunStoreMetrics.steal[outcome._tag],
1945
+ Metric.withAttributes(RunStoreMetrics.claims, { op: "steal" })
1946
+ ))
1947
+ )
1948
+
1949
+ const acknowledgeCancel: Service["acknowledgeCancel"] = Effect.fn("RunStore.acknowledgeCancel")((
1950
+ id,
1951
+ ownerInput,
1952
+ time
1953
+ ) =>
1954
+ Effect.gen(function*() {
1955
+ const runId = yield* snapshotRunId("acknowledgeCancel", id)
1956
+ const owner = yield* snapshotOwner("acknowledgeCancel", "owner", ownerInput)
1957
+ const observedAtMs = yield* snapshotTimestamp("acknowledgeCancel", "nowMs", time)
1958
+ yield* Effect.annotateCurrentSpan({ runId, ownerHostId: owner.hostId })
1959
+ const acknowledgement = JSON.stringify({ observedAtMs, owner })
1960
+ return yield* write(
1961
+ "acknowledgeCancel",
1962
+ sql`
1963
+ UPDATE flows_runs
1964
+ SET cancel_acknowledgement_json = COALESCE(cancel_acknowledgement_json, ${acknowledgement})
1965
+ WHERE run_id = ${runId} AND status = 'running' AND cancel_requested_at_ms IS NOT NULL
1966
+ AND owner_host_id = ${owner.hostId} AND owner_pid = ${owner.pid} AND owner_nonce = ${owner.nonce}
1967
+ RETURNING run_id
1968
+ `.pipe(Effect.map((rows) => rows.length > 0))
1969
+ )
1970
+ }).pipe(observeExit)
1971
+ )
1972
+
1973
+ return RunStore.of({
1974
+ create,
1975
+ get,
1976
+ lineage,
1977
+ latestRound,
1978
+ requestCancel,
1979
+ acknowledgeCancel,
1980
+ requestCancelLineage,
1981
+ claim,
1982
+ claimAndOwn,
1983
+ activate,
1984
+ abandonClaim,
1985
+ recoverClaim,
1986
+ heartbeat,
1987
+ reconfirm,
1988
+ transitionOwned,
1989
+ steal
1990
+ })
1991
+ }
1992
+ )
1993
+
1994
+ /**
1995
+ * Constructs a stub `RunStore` whose direct operations fail and whose
1996
+ * compare-and-swap operations report typed losses until overridden.
1997
+ *
1998
+ * @since 0.1.0
1999
+ * @category constructors
2000
+ */
2001
+ export const makeNoop = (overrides: Partial<Service> = {}): Service => {
2002
+ const unavailable = (method: string) =>
2003
+ Effect.fail(runStoreError(method, "persistence_failed", "no run store in this environment", method))
2004
+ return RunStore.of({
2005
+ create: Effect.fn("RunStore.create")(() => unavailable("create")),
2006
+ get: Effect.fn("RunStore.get")(() => unavailable("get")),
2007
+ lineage: Effect.fn("RunStore.lineage")(() => unavailable("lineage")),
2008
+ latestRound: Effect.fn("RunStore.latestRound")(() => unavailable("latestRound")),
2009
+ requestCancel: Effect.fn("RunStore.requestCancel")(() => Effect.succeed(notFound)),
2010
+ acknowledgeCancel: Effect.fn("RunStore.acknowledgeCancel")(() => Effect.succeed(false)),
2011
+ requestCancelLineage: Effect.fn("RunStore.requestCancelLineage")(() => unavailable("requestCancelLineage")),
2012
+ claim: Effect.fn("RunStore.claim")(() => Effect.succeed(notFound)),
2013
+ claimAndOwn: Effect.fn("RunStore.claimAndOwn")(() => Effect.succeed(notFound)),
2014
+ activate: Effect.fn("RunStore.activate")(() => Effect.succeed(claimLost)),
2015
+ abandonClaim: Effect.fn("RunStore.abandonClaim")(() => Effect.succeed(claimLost)),
2016
+ recoverClaim: Effect.fn("RunStore.recoverClaim")(() => Effect.succeed(notFound)),
2017
+ heartbeat: Effect.fn("RunStore.heartbeat")(() => Effect.succeed(notFound)),
2018
+ reconfirm: Effect.fn("RunStore.reconfirm")(() => Effect.succeed(notFound)),
2019
+ transitionOwned: Effect.fn("RunStore.transitionOwned")(() => Effect.succeed(notFound)),
2020
+ steal: Effect.fn("RunStore.steal")(() => Effect.succeed(notFound)),
2021
+ ...overrides
2022
+ })
2023
+ }
2024
+
2025
+ /**
2026
+ * Provides a stub `RunStore`.
2027
+ *
2028
+ * @since 0.1.0
2029
+ * @category layers
2030
+ */
2031
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<RunStore> =>
2032
+ Layer.succeed(RunStore)(makeNoop(overrides))
2033
+
2034
+ /**
2035
+ * Provides the database-backed `RunStore` over an explicitly injected
2036
+ * consensus strategy.
2037
+ *
2038
+ * Arbitration — claims, activation, steals, heartbeats, and fence checks — is
2039
+ * delegated to the `Consensus` service in context; the run row mirrors the
2040
+ * outcome in the same transaction. Provide the SAME strategy instance the
2041
+ * journal fences through (`SqlJournal.layerWith`), because
2042
+ * `Consensus.layerLocal` keeps its leases per layer build.
2043
+ *
2044
+ * @since 1.0.0
2045
+ * @category layers
2046
+ */
2047
+ export const layerWith: Layer.Layer<RunStore, never, Consensus | DurableWriter | SqlClient.SqlClient> = Layer.effect(
2048
+ RunStore,
2049
+ make
2050
+ )
2051
+
2052
+ /**
2053
+ * Provides the database-backed `RunStore` arbitrated by the database-backed
2054
+ * strategy, `SqlConsensus`, over the same client — the strategy
2055
+ * `SqlJournal.layer` fences through. Use {@link layerWith} to inject a
2056
+ * different strategy.
2057
+ *
2058
+ * @since 0.1.0
2059
+ * @category layers
2060
+ */
2061
+ export const layer: Layer.Layer<RunStore, never, DurableWriter | SqlClient.SqlClient> = layerWith.pipe(
2062
+ Layer.provide(SqlConsensus.layer)
2063
+ )