@namzu/sdk 20.4.0 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/dist/bridge/sse/mapper.d.ts +19 -0
  3. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  4. package/dist/bridge/sse/mapper.js +9 -1
  5. package/dist/bridge/sse/mapper.js.map +1 -1
  6. package/dist/contracts/schemas.d.ts +10 -10
  7. package/dist/manager/run/persistence.d.ts +26 -0
  8. package/dist/manager/run/persistence.d.ts.map +1 -1
  9. package/dist/manager/run/persistence.js +40 -0
  10. package/dist/manager/run/persistence.js.map +1 -1
  11. package/dist/public-runtime.d.ts +4 -0
  12. package/dist/public-runtime.d.ts.map +1 -1
  13. package/dist/public-runtime.js +16 -0
  14. package/dist/public-runtime.js.map +1 -1
  15. package/dist/run/drain.d.ts +194 -0
  16. package/dist/run/drain.d.ts.map +1 -0
  17. package/dist/run/drain.js +228 -0
  18. package/dist/run/drain.js.map +1 -0
  19. package/dist/run/index.d.ts +2 -0
  20. package/dist/run/index.d.ts.map +1 -1
  21. package/dist/run/index.js +1 -0
  22. package/dist/run/index.js.map +1 -1
  23. package/dist/runtime/query/events.d.ts +12 -0
  24. package/dist/runtime/query/events.d.ts.map +1 -1
  25. package/dist/runtime/query/events.js +61 -3
  26. package/dist/runtime/query/events.js.map +1 -1
  27. package/dist/runtime/query/index.d.ts +36 -0
  28. package/dist/runtime/query/index.d.ts.map +1 -1
  29. package/dist/runtime/query/index.js +32 -0
  30. package/dist/runtime/query/index.js.map +1 -1
  31. package/dist/runtime/query/resume-run.d.ts +25 -0
  32. package/dist/runtime/query/resume-run.d.ts.map +1 -1
  33. package/dist/runtime/query/resume-run.js +15 -3
  34. package/dist/runtime/query/resume-run.js.map +1 -1
  35. package/dist/store/index.d.ts +1 -1
  36. package/dist/store/index.d.ts.map +1 -1
  37. package/dist/store/index.js +1 -1
  38. package/dist/store/index.js.map +1 -1
  39. package/dist/store/run/conformance.d.ts +159 -0
  40. package/dist/store/run/conformance.d.ts.map +1 -0
  41. package/dist/store/run/conformance.js +451 -0
  42. package/dist/store/run/conformance.js.map +1 -0
  43. package/dist/store/run/disk.d.ts +30 -2
  44. package/dist/store/run/disk.d.ts.map +1 -1
  45. package/dist/store/run/disk.js +101 -0
  46. package/dist/store/run/disk.js.map +1 -1
  47. package/dist/store/run/memory.d.ts +5 -4
  48. package/dist/store/run/memory.d.ts.map +1 -1
  49. package/dist/store/run/memory.js +30 -1
  50. package/dist/store/run/memory.js.map +1 -1
  51. package/dist/types/doctor/check.d.ts +28 -2
  52. package/dist/types/doctor/check.d.ts.map +1 -1
  53. package/dist/types/run/event-cursor.d.ts +90 -0
  54. package/dist/types/run/event-cursor.d.ts.map +1 -0
  55. package/dist/types/run/event-cursor.js +47 -0
  56. package/dist/types/run/event-cursor.js.map +1 -0
  57. package/dist/types/run/events.d.ts +71 -1
  58. package/dist/types/run/events.d.ts.map +1 -1
  59. package/dist/types/run/events.js.map +1 -1
  60. package/dist/types/run/index.d.ts +1 -0
  61. package/dist/types/run/index.d.ts.map +1 -1
  62. package/dist/types/run/index.js +1 -0
  63. package/dist/types/run/index.js.map +1 -1
  64. package/dist/types/run/store.d.ts +41 -1
  65. package/dist/types/run/store.d.ts.map +1 -1
  66. package/package.json +6 -1
  67. package/src/bridge/sse/mapper.ts +28 -1
  68. package/src/manager/run/persistence.ts +43 -0
  69. package/src/public-runtime.ts +22 -0
  70. package/src/run/drain.ts +393 -0
  71. package/src/run/index.ts +3 -0
  72. package/src/runtime/query/events.ts +68 -4
  73. package/src/runtime/query/index.ts +87 -0
  74. package/src/runtime/query/resume-run.ts +67 -12
  75. package/src/store/index.ts +1 -1
  76. package/src/store/run/conformance.ts +705 -0
  77. package/src/store/run/disk.ts +106 -2
  78. package/src/store/run/memory.ts +35 -5
  79. package/src/types/doctor/check.ts +28 -2
  80. package/src/types/run/event-cursor.ts +118 -0
  81. package/src/types/run/events.ts +80 -3
  82. package/src/types/run/index.ts +1 -0
  83. package/src/types/run/store.ts +43 -1
@@ -0,0 +1,705 @@
1
+ /**
2
+ * The checkpoint-store contract, as a suite a host can run against its own
3
+ * backend.
4
+ *
5
+ * ## Why this ships rather than staying a test
6
+ *
7
+ * {@link import('./checkpoint-memory.js').InMemoryCheckpointStore} says in its
8
+ * own doc comment that it is "the reference a host reads when writing a
9
+ * backend of its own". That claim was unbacked, and the cost of it was not
10
+ * hypothetical: the two shipped stores disagreed at the enforcement point —
11
+ * the in-memory one accepted a write from a holder that had been superseded
12
+ * and then released around, the disk one refused it — and the class documented
13
+ * as the reference was the one carrying the defect. Reading a reference cannot
14
+ * tell you that. Running it can.
15
+ *
16
+ * The rules below are the ones a claim depends on and no type can state:
17
+ * exclusivity, expiry, that a superseded write is refused, and that a listing
18
+ * answers for the tenant it was asked about and no other. Those are exactly
19
+ * the points at which the two built-in implementations already diverged.
20
+ *
21
+ * ## Why it takes its runner as an argument
22
+ *
23
+ * `describe`, `it` and `expect` come in through
24
+ * {@link CheckpointStoreConformanceOptions}. The suite therefore imports no
25
+ * test framework, `@namzu/sdk` gains no test dependency from publishing it,
26
+ * and a host on a different runner can still run it by handing over three
27
+ * functions of the shapes below.
28
+ *
29
+ * It also buys the one property that separates this file from decoration: a
30
+ * caller can pass a *recording* `describe`/`it` and run the whole contract as
31
+ * ordinary code, which is how `conformance-fails-a-wrong-store.test.ts`
32
+ * establishes that a deliberately broken store fails it.
33
+ *
34
+ * ## Consuming it
35
+ *
36
+ * See `docs/sdk/runtime/checkpoint-store-conformance.md`. In short:
37
+ *
38
+ * ```typescript
39
+ * import { describe, expect, it } from 'your-runner'
40
+ * import { defineCheckpointStoreConformance } from '@namzu/sdk/testing'
41
+ *
42
+ * defineCheckpointStoreConformance({
43
+ * describe, it, expect,
44
+ * label: 'my-backend',
45
+ * contractVersion: 1,
46
+ * capabilities: { claims: true, listing: true, multiTenant: true },
47
+ * makeStore: (binding) => ({ store: new MyStore(binding) }),
48
+ * })
49
+ * ```
50
+ *
51
+ * ## The assertions are public API
52
+ *
53
+ * Once a host wires this in, every assertion below is something their build
54
+ * fails on. Adding one is therefore a breaking change for them even though it
55
+ * adds no export — so a new rule arrives behind a raised
56
+ * {@link CHECKPOINT_STORE_CONTRACT_VERSION} and a `major`, and the version
57
+ * check is what turns "your backend silently satisfies an older, smaller
58
+ * contract" into a named failure.
59
+ */
60
+
61
+ import type { CostInfo } from '../../types/common/index.js'
62
+ import type { IterationCheckpoint } from '../../types/hitl/index.js'
63
+ import type { CheckpointId, ProjectId, RunId, SessionId, TenantId } from '../../types/ids/index.js'
64
+ import type { CheckpointStore } from '../../types/run/checkpoint-store.js'
65
+ import { claimRun, listDurableRuns, releaseRun } from './listing.js'
66
+
67
+ /**
68
+ * Which revision of the contract the assertions in this file express.
69
+ *
70
+ * A host DECLARES the version it wrote its backend against, as a literal in
71
+ * its own test file, and the suite's first case compares the two. Re-exporting
72
+ * this constant into that slot defeats the check — the point is that the
73
+ * number is frozen in the host's source at the moment they wrote the backend,
74
+ * so upgrading `@namzu/sdk` past a contract revision fails with a sentence
75
+ * naming both numbers instead of a scatter of assertion failures whose common
76
+ * cause is not obvious.
77
+ *
78
+ * Raised only together with a `major`, and only when an assertion is ADDED or
79
+ * TIGHTENED. Adding a case behind a new optional capability flag does not
80
+ * raise it: a host that does not declare the capability never runs the case.
81
+ */
82
+ export const CHECKPOINT_STORE_CONTRACT_VERSION = 1
83
+
84
+ /**
85
+ * What a backend can do, so the suite runs the cases it is answerable for and
86
+ * no others.
87
+ *
88
+ * Every field is required rather than defaulted. A capability that defaults to
89
+ * `false` lets a host skip a whole section by forgetting a key, and a suite you
90
+ * can opt out of by omission is a suite that reports a pass it did not
91
+ * establish.
92
+ */
93
+ export interface CheckpointStoreCapabilities {
94
+ /**
95
+ * The store implements `claimRun` / `releaseRun` and enforces the fence at
96
+ * `writeCheckpoint`. `false` skips every claim case — appropriate only for
97
+ * a single-writer backend, which is a deployment shape, not a shortcut.
98
+ */
99
+ readonly claims: boolean
100
+ /** The store implements `listDurableRuns`. */
101
+ readonly listing: boolean
102
+ /**
103
+ * One instance can hold more than one tenant's runs.
104
+ *
105
+ * `false` for a store whose addressing fixes the tenant — the built-in disk
106
+ * layout has no tenant segment in it, so a second tenant is a second store.
107
+ * Such a backend still answers the isolation case that CAN be put to it: a
108
+ * listing for a tenant it does not hold returns nothing.
109
+ */
110
+ readonly multiTenant: boolean
111
+ }
112
+
113
+ /** The attribution the suite addresses its runs under. */
114
+ export interface CheckpointStoreBinding {
115
+ readonly tenantId: TenantId
116
+ readonly projectId: ProjectId
117
+ readonly sessionId: SessionId
118
+ }
119
+
120
+ /** A store to test, plus whatever teardown producing it required. */
121
+ export interface CheckpointStoreHandle {
122
+ readonly store: CheckpointStore
123
+ /** Called after each case, pass or fail. */
124
+ dispose?(): void | Promise<void>
125
+ }
126
+
127
+ /**
128
+ * Build a store bound to `binding`. Called once per case, so no case can be
129
+ * affected by another's writes — the suite never assumes a shared instance and
130
+ * never cleans one.
131
+ */
132
+ export type MakeCheckpointStore = (
133
+ binding: CheckpointStoreBinding,
134
+ ) => CheckpointStoreHandle | Promise<CheckpointStoreHandle>
135
+
136
+ /** The assertions the suite uses, and nothing more. */
137
+ export interface ConformanceAssertion {
138
+ toBe(expected: unknown): void
139
+ toEqual(expected: unknown): void
140
+ toBeGreaterThan(expected: number): void
141
+ toMatch(expected: RegExp): void
142
+ }
143
+
144
+ /** Shape of the `expect` a runner supplies. */
145
+ export type ConformanceExpect = (actual: unknown) => ConformanceAssertion
146
+ /** Shape of the `it` a runner supplies. */
147
+ export type ConformanceIt = (name: string, body: () => Promise<void>) => unknown
148
+ /** Shape of the `describe` a runner supplies. */
149
+ export type ConformanceDescribe = (name: string, body: () => void) => unknown
150
+
151
+ /** Everything {@link defineCheckpointStoreConformance} needs. */
152
+ export interface CheckpointStoreConformanceOptions {
153
+ readonly describe: ConformanceDescribe
154
+ readonly it: ConformanceIt
155
+ readonly expect: ConformanceExpect
156
+ /**
157
+ * The contract revision this backend was written against, written as a
158
+ * literal. See {@link CHECKPOINT_STORE_CONTRACT_VERSION}.
159
+ */
160
+ readonly contractVersion: number
161
+ readonly capabilities: CheckpointStoreCapabilities
162
+ readonly makeStore: MakeCheckpointStore
163
+ /** Names the backend in test output. Defaults to `checkpoint store`. */
164
+ readonly label?: string
165
+ }
166
+
167
+ /** The tenant/project/session every case addresses unless it needs a second. */
168
+ const BINDING: CheckpointStoreBinding = {
169
+ tenantId: 'tnt_conformance' as TenantId,
170
+ projectId: 'prj_conformance' as ProjectId,
171
+ sessionId: 'ses_conformance' as SessionId,
172
+ }
173
+
174
+ /** A tenant no backend under test holds runs for. */
175
+ const FOREIGN_TENANT = 'tnt_conformance_other' as TenantId
176
+
177
+ /** Fixed instant, so every expiry in a case is judged against one clock. */
178
+ const NOW = 5_000_000
179
+
180
+ const NO_COST: CostInfo = {
181
+ inputCostPer1M: 0,
182
+ outputCostPer1M: 0,
183
+ totalCost: 0,
184
+ cacheDiscount: 0,
185
+ }
186
+
187
+ let checkpointSeq = 0
188
+
189
+ function checkpoint(runId: string): IterationCheckpoint {
190
+ checkpointSeq += 1
191
+ return {
192
+ id: `cp_conformance_${checkpointSeq}` as CheckpointId,
193
+ runId: runId as RunId,
194
+ iteration: 1,
195
+ messages: [],
196
+ tokenUsage: {
197
+ promptTokens: 1,
198
+ completionTokens: 1,
199
+ totalTokens: 2,
200
+ cachedTokens: 0,
201
+ cacheWriteTokens: 0,
202
+ },
203
+ costInfo: NO_COST,
204
+ guardState: { iterationCount: 1, elapsedMs: 1 },
205
+ createdAt: NOW,
206
+ }
207
+ }
208
+
209
+ function runScope(binding: CheckpointStoreBinding, runId = 'run_conformance_a') {
210
+ return { ...binding, runId: runId as RunId }
211
+ }
212
+
213
+ /**
214
+ * Assert that `call` rejected with a message matching `pattern`.
215
+ *
216
+ * Written against `toMatch` rather than a runner's `rejects` helper so the
217
+ * suite needs no matcher beyond the four on {@link ConformanceAssertion}. A
218
+ * call that RESOLVES yields a sentence saying so, which is the diagnostic that
219
+ * matters: the failure this whole file exists for is a write that was accepted
220
+ * when it should have been refused.
221
+ */
222
+ async function expectRejection(
223
+ expect: ConformanceExpect,
224
+ call: () => Promise<unknown>,
225
+ pattern: RegExp,
226
+ ): Promise<void> {
227
+ let message = '<the call resolved; no rejection was raised>'
228
+ try {
229
+ await call()
230
+ } catch (error) {
231
+ message = error instanceof Error ? error.message : String(error)
232
+ }
233
+ expect(message).toMatch(pattern)
234
+ }
235
+
236
+ /**
237
+ * Register the checkpoint-store contract against one backend.
238
+ *
239
+ * Call it once per backend. It registers cases through the supplied `describe`
240
+ * and `it`; it does not run them.
241
+ */
242
+ export function defineCheckpointStoreConformance(options: CheckpointStoreConformanceOptions): void {
243
+ const { describe, it, expect, capabilities, makeStore } = options
244
+ const label = options.label ?? 'checkpoint store'
245
+
246
+ /**
247
+ * Run `body` against a store built for this case alone.
248
+ *
249
+ * `finally`, so a failing assertion still disposes — a suite that leaks a
250
+ * temp directory per failure makes a red run expensive to iterate on, and
251
+ * an expensive red run is one people stop running.
252
+ */
253
+ const withStore = (body: (store: CheckpointStore) => Promise<void>) => async () => {
254
+ const handle = await makeStore(BINDING)
255
+ try {
256
+ await body(handle.store)
257
+ } finally {
258
+ await handle.dispose?.()
259
+ }
260
+ }
261
+
262
+ describe(`${label} conformance`, () => {
263
+ it('declares the contract revision this suite implements', async () => {
264
+ // First case on purpose. When it fails, every case below it is
265
+ // answering a different contract than the backend was written for,
266
+ // and reading their failures as defects would send a host chasing
267
+ // bugs that are really a version skew.
268
+ //
269
+ // Both numbers ride in the COMPARED VALUES rather than in a
270
+ // sentence, because a runner truncates a long actual when it builds
271
+ // the failure message — measured, on the first draft of this, at 37
272
+ // characters, which cut off the very numbers the case exists to
273
+ // report. The guidance that would not fit lives on
274
+ // `CHECKPOINT_STORE_CONTRACT_VERSION`, which is where a reader of
275
+ // this failure is being sent anyway.
276
+ expect(`checkpoint-store contract v${options.contractVersion}`).toBe(
277
+ `checkpoint-store contract v${CHECKPOINT_STORE_CONTRACT_VERSION}`,
278
+ )
279
+ })
280
+
281
+ if (capabilities.claims) {
282
+ describe('claim exclusivity', () => {
283
+ it(
284
+ 'gives the run to the first taker and refuses the second',
285
+ withStore(async (store) => {
286
+ const first = await claimRun(store, runScope(BINDING), {
287
+ holder: 'w1',
288
+ ttlMs: 60_000,
289
+ now: NOW,
290
+ })
291
+ const second = await claimRun(store, runScope(BINDING), {
292
+ holder: 'w2',
293
+ ttlMs: 60_000,
294
+ now: NOW,
295
+ })
296
+
297
+ expect(first?.holder).toBe('w1')
298
+ // `null`, not a throw: two readers on one queue is the
299
+ // ordinary case, and an exception would make the normal
300
+ // outcome look like a fault.
301
+ expect(second).toBe(null)
302
+ }),
303
+ )
304
+
305
+ it(
306
+ 'advances the fence on renewal, so a stalled twin cannot write',
307
+ withStore(async (store) => {
308
+ const first = await claimRun(store, runScope(BINDING), {
309
+ holder: 'w1',
310
+ ttlMs: 1_000,
311
+ now: NOW,
312
+ })
313
+ const renewed = await claimRun(store, runScope(BINDING), {
314
+ holder: 'w1',
315
+ ttlMs: 60_000,
316
+ now: NOW + 500,
317
+ })
318
+
319
+ // Renewal and reclamation are one operation and both
320
+ // advance. A renewal that kept the fence would leave any
321
+ // duplicate of the holder — a retried job, a
322
+ // double-scheduled pod — able to write with the number it
323
+ // captured before.
324
+ expect(renewed?.fence).toBeGreaterThan(first?.fence as number)
325
+ }),
326
+ )
327
+
328
+ it(
329
+ 'refuses a lease with no duration',
330
+ withStore(async (store) => {
331
+ // A lease that expires immediately is a lease every worker
332
+ // can take at once, which is the condition the call exists
333
+ // to prevent.
334
+ await expectRejection(
335
+ expect,
336
+ () => claimRun(store, runScope(BINDING), { holder: 'w1', ttlMs: 0, now: NOW }),
337
+ /positive number of milliseconds/,
338
+ )
339
+ }),
340
+ )
341
+ })
342
+
343
+ describe('claim expiry', () => {
344
+ it(
345
+ 'lets a later worker take a run whose holder went away',
346
+ withStore(async (store) => {
347
+ const first = await claimRun(store, runScope(BINDING), {
348
+ holder: 'w1',
349
+ ttlMs: 1_000,
350
+ now: NOW,
351
+ })
352
+ const second = await claimRun(store, runScope(BINDING), {
353
+ holder: 'w2',
354
+ ttlMs: 60_000,
355
+ now: NOW + 2_000,
356
+ })
357
+
358
+ // A lock held by a dead process is held forever. The expiry
359
+ // is the whole difference between a lease and a wedged run.
360
+ expect(second?.holder).toBe('w2')
361
+ expect(second?.fence).toBeGreaterThan(first?.fence as number)
362
+ }),
363
+ )
364
+
365
+ it(
366
+ 'returns the run to the queue when its holder releases',
367
+ withStore(async (store) => {
368
+ const claim = await claimRun(store, runScope(BINDING), {
369
+ holder: 'w1',
370
+ ttlMs: 60_000,
371
+ now: NOW,
372
+ })
373
+ await releaseRun(store, runScope(BINDING), claim?.fence as number)
374
+
375
+ const next = await claimRun(store, runScope(BINDING), {
376
+ holder: 'w2',
377
+ ttlMs: 60_000,
378
+ now: NOW + 1,
379
+ })
380
+ expect(next?.holder).toBe('w2')
381
+ }),
382
+ )
383
+
384
+ it(
385
+ 'releases only on the fence that currently holds it',
386
+ withStore(async (store) => {
387
+ const first = await claimRun(store, runScope(BINDING), {
388
+ holder: 'w1',
389
+ ttlMs: 1_000,
390
+ now: NOW,
391
+ })
392
+ await claimRun(store, runScope(BINDING), {
393
+ holder: 'w2',
394
+ ttlMs: 60_000,
395
+ now: NOW + 2_000,
396
+ })
397
+
398
+ // A worker that stalled past its lease must not be able to
399
+ // hand away a run somebody else is now holding.
400
+ await releaseRun(store, runScope(BINDING), first?.fence as number)
401
+
402
+ const third = await claimRun(store, runScope(BINDING), {
403
+ holder: 'w3',
404
+ ttlMs: 60_000,
405
+ now: NOW + 3_000,
406
+ })
407
+ expect(third).toBe(null)
408
+ }),
409
+ )
410
+ })
411
+
412
+ describe('fenced-out writes', () => {
413
+ it(
414
+ 'lets the current holder keep writing',
415
+ withStore(async (store) => {
416
+ const claim = await claimRun(store, runScope(BINDING), {
417
+ holder: 'w1',
418
+ ttlMs: 60_000,
419
+ now: NOW,
420
+ })
421
+ await store.writeCheckpoint(
422
+ runScope(BINDING),
423
+ checkpoint('run_conformance_a'),
424
+ claim?.fence,
425
+ )
426
+ const written = await store.listCheckpoints(runScope(BINDING))
427
+ expect(written.length).toBe(1)
428
+ }),
429
+ )
430
+
431
+ it(
432
+ 'still accepts an unfenced write on a claimed run',
433
+ withStore(async (store) => {
434
+ await claimRun(store, runScope(BINDING), {
435
+ holder: 'w1',
436
+ ttlMs: 60_000,
437
+ now: NOW,
438
+ })
439
+ // A host that adopts claims on one worker must not break the
440
+ // workers that have not adopted them yet. Refusing here would
441
+ // make the capability impossible to roll out incrementally.
442
+ await store.writeCheckpoint(runScope(BINDING), checkpoint('run_conformance_a'))
443
+ const written = await store.listCheckpoints(runScope(BINDING))
444
+ expect(written.length).toBe(1)
445
+ }),
446
+ )
447
+
448
+ it(
449
+ 'fences the stalled holder out at the moment it writes',
450
+ withStore(async (store) => {
451
+ const first = await claimRun(store, runScope(BINDING), {
452
+ holder: 'w1',
453
+ ttlMs: 1_000,
454
+ now: NOW,
455
+ })
456
+ await claimRun(store, runScope(BINDING), {
457
+ holder: 'w2',
458
+ ttlMs: 60_000,
459
+ now: NOW + 2_000,
460
+ })
461
+
462
+ // `w1` believes it still holds the run. It cannot know
463
+ // otherwise: a long pause, a suspended container and a
464
+ // partition all look from the inside like time not passing.
465
+ // The write is the only place it can be told, and this is
466
+ // that place.
467
+ await expectRejection(
468
+ expect,
469
+ () =>
470
+ store.writeCheckpoint(
471
+ runScope(BINDING),
472
+ checkpoint('run_conformance_a'),
473
+ first?.fence,
474
+ ),
475
+ /no longer holds it/,
476
+ )
477
+ }),
478
+ )
479
+
480
+ it(
481
+ 'still refuses a superseded fence after the new holder releases',
482
+ withStore(async (store) => {
483
+ // The silent one, and the case on which the two built-in
484
+ // stores actually disagreed. Not a duplicate write — a LOST
485
+ // one.
486
+ //
487
+ // w1 stalls at its fence. w2 reclaims, does the work, and
488
+ // releases cleanly, which is the documented
489
+ // `finally { releaseRun() }`. w1 then wakes believing it
490
+ // still holds the run and writes. If that write is accepted,
491
+ // its checkpoint carries a fresh `createdAt`, sorts newest,
492
+ // and the next resume restores w1's stale history — so w2's
493
+ // completed work vanishes with no error anywhere.
494
+ const stale = await claimRun(store, runScope(BINDING), {
495
+ holder: 'w1',
496
+ ttlMs: 1_000,
497
+ now: NOW,
498
+ })
499
+ const live = await claimRun(store, runScope(BINDING), {
500
+ holder: 'w2',
501
+ ttlMs: 60_000,
502
+ now: NOW + 2_000,
503
+ })
504
+ await releaseRun(store, runScope(BINDING), live?.fence as number)
505
+
506
+ await expectRejection(
507
+ expect,
508
+ () =>
509
+ store.writeCheckpoint(
510
+ runScope(BINDING),
511
+ checkpoint('run_conformance_a'),
512
+ stale?.fence,
513
+ ),
514
+ /no longer holds it/,
515
+ )
516
+ }),
517
+ )
518
+
519
+ it(
520
+ 'refuses the fence its own holder just released',
521
+ withStore(async (store) => {
522
+ // The same hole one step shorter, and the one that pins the
523
+ // release itself rather than a reclaim before it. A holder
524
+ // that gives a run back has given up the right to write to
525
+ // it; nothing else took the run in between, so only the
526
+ // release can be what refuses this.
527
+ const claim = await claimRun(store, runScope(BINDING), {
528
+ holder: 'w1',
529
+ ttlMs: 60_000,
530
+ now: NOW,
531
+ })
532
+ await releaseRun(store, runScope(BINDING), claim?.fence as number)
533
+
534
+ await expectRejection(
535
+ expect,
536
+ () =>
537
+ store.writeCheckpoint(
538
+ runScope(BINDING),
539
+ checkpoint('run_conformance_a'),
540
+ claim?.fence,
541
+ ),
542
+ /no longer holds it/,
543
+ )
544
+ }),
545
+ )
546
+ })
547
+ }
548
+
549
+ if (capabilities.listing) {
550
+ describe('listing scope isolation', () => {
551
+ it(
552
+ 'answers nothing for a tenant it does not hold',
553
+ withStore(async (store) => {
554
+ await store.writeCheckpoint(
555
+ runScope(BINDING, 'run_conformance_a'),
556
+ checkpoint('run_conformance_a'),
557
+ )
558
+
559
+ const foreign = await listDurableRuns(store, { tenantId: FOREIGN_TENANT })
560
+ // A listing is SCOPED, not addressed: another tenant is a
561
+ // question this store has no rows for. Leaking one row here
562
+ // is the whole of a cross-tenant read.
563
+ expect(foreign.entries.map((e) => e.runId)).toEqual([])
564
+ }),
565
+ )
566
+
567
+ it(
568
+ 'answers nothing for a project it does not hold',
569
+ withStore(async (store) => {
570
+ await store.writeCheckpoint(
571
+ runScope(BINDING, 'run_conformance_a'),
572
+ checkpoint('run_conformance_a'),
573
+ )
574
+
575
+ const foreign = await listDurableRuns(store, {
576
+ tenantId: BINDING.tenantId,
577
+ projectId: 'prj_conformance_other' as ProjectId,
578
+ })
579
+ expect(foreign.entries.map((e) => e.runId)).toEqual([])
580
+ }),
581
+ )
582
+
583
+ it(
584
+ 'refuses a listing scope with a hole in it',
585
+ withStore(async (store) => {
586
+ // `{ tenantId, sessionId }` reads as "that session under
587
+ // whichever project holds it". A flat backend can answer it
588
+ // and a hierarchical one cannot, so answering differently per
589
+ // backend is the one thing this contract exists to prevent.
590
+ await expectRejection(
591
+ expect,
592
+ () =>
593
+ listDurableRuns(store, {
594
+ tenantId: BINDING.tenantId,
595
+ sessionId: BINDING.sessionId,
596
+ }),
597
+ /contiguous prefix|without `projectId`/,
598
+ )
599
+ }),
600
+ )
601
+
602
+ it(
603
+ 'rebuilds an addressable row for every run it holds',
604
+ withStore(async (store) => {
605
+ await store.writeCheckpoint(
606
+ runScope(BINDING, 'run_conformance_a'),
607
+ checkpoint('run_conformance_a'),
608
+ )
609
+ await store.writeCheckpoint(
610
+ runScope(BINDING, 'run_conformance_b'),
611
+ checkpoint('run_conformance_b'),
612
+ )
613
+
614
+ const page = await listDurableRuns(store, { tenantId: BINDING.tenantId })
615
+ expect(page.entries.map((e) => e.runId)).toEqual([
616
+ 'run_conformance_a',
617
+ 'run_conformance_b',
618
+ ])
619
+ // A row that cannot be turned back into a scope is a report,
620
+ // not a work queue.
621
+ expect(page.entries.map((e) => e.tenantId)).toEqual([
622
+ BINDING.tenantId,
623
+ BINDING.tenantId,
624
+ ])
625
+ expect(page.entries.map((e) => e.sessionId)).toEqual([
626
+ BINDING.sessionId,
627
+ BINDING.sessionId,
628
+ ])
629
+ }),
630
+ )
631
+
632
+ if (capabilities.multiTenant) {
633
+ it(
634
+ 'keeps one tenant’s runs out of another tenant’s listing',
635
+ withStore(async (store) => {
636
+ await store.writeCheckpoint(
637
+ runScope(BINDING, 'run_conformance_a'),
638
+ checkpoint('run_conformance_a'),
639
+ )
640
+ await store.writeCheckpoint(
641
+ {
642
+ tenantId: FOREIGN_TENANT,
643
+ projectId: BINDING.projectId,
644
+ sessionId: BINDING.sessionId,
645
+ runId: 'run_conformance_z' as RunId,
646
+ },
647
+ checkpoint('run_conformance_z'),
648
+ )
649
+
650
+ const mine = await listDurableRuns(store, { tenantId: BINDING.tenantId })
651
+ expect(mine.entries.map((e) => e.runId)).toEqual(['run_conformance_a'])
652
+
653
+ const theirs = await listDurableRuns(store, { tenantId: FOREIGN_TENANT })
654
+ expect(theirs.entries.map((e) => e.runId)).toEqual(['run_conformance_z'])
655
+ }),
656
+ )
657
+ }
658
+
659
+ if (capabilities.claims) {
660
+ it(
661
+ 'shows the queue reader which runs nobody holds',
662
+ withStore(async (store) => {
663
+ for (const id of ['run_conformance_a', 'run_conformance_b', 'run_conformance_c'])
664
+ await store.writeCheckpoint(runScope(BINDING, id), checkpoint(id))
665
+
666
+ await claimRun(store, runScope(BINDING, 'run_conformance_a'), {
667
+ holder: 'w1',
668
+ ttlMs: 60_000,
669
+ now: NOW,
670
+ })
671
+ await claimRun(store, runScope(BINDING, 'run_conformance_b'), {
672
+ holder: 'w1',
673
+ ttlMs: 1_000,
674
+ now: NOW,
675
+ })
676
+
677
+ const free = await listDurableRuns(
678
+ store,
679
+ { tenantId: BINDING.tenantId },
680
+ { claimed: false, now: NOW + 2_000 },
681
+ )
682
+ // `run_conformance_b`'s holder is gone. An expired claim
683
+ // counts as unheld, or a dead worker's runs stay invisible
684
+ // forever — the failure the lease exists to prevent,
685
+ // reintroduced by the filter that reads it.
686
+ expect(free.entries.map((e) => e.runId)).toEqual([
687
+ 'run_conformance_b',
688
+ 'run_conformance_c',
689
+ ])
690
+
691
+ const taken = await listDurableRuns(
692
+ store,
693
+ { tenantId: BINDING.tenantId },
694
+ { claimed: true, now: NOW + 2_000 },
695
+ )
696
+ expect(taken.entries.map((e) => e.runId)).toEqual(['run_conformance_a'])
697
+ expect(taken.entries[0]?.claim?.holder).toBe('w1')
698
+ expect(taken.entries[0]?.claim?.expired).toBe(false)
699
+ }),
700
+ )
701
+ }
702
+ })
703
+ }
704
+ })
705
+ }