@namzu/sdk 20.0.0 → 20.1.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 (40) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/contracts/a2a.d.ts +2 -2
  3. package/dist/manager/run/persistence.d.ts.map +1 -1
  4. package/dist/manager/run/persistence.js +11 -0
  5. package/dist/manager/run/persistence.js.map +1 -1
  6. package/dist/public-runtime.d.ts +3 -1
  7. package/dist/public-runtime.d.ts.map +1 -1
  8. package/dist/public-runtime.js +7 -1
  9. package/dist/public-runtime.js.map +1 -1
  10. package/dist/store/index.d.ts +3 -0
  11. package/dist/store/index.d.ts.map +1 -1
  12. package/dist/store/index.js +12 -0
  13. package/dist/store/index.js.map +1 -1
  14. package/dist/store/run/checkpoint-disk.d.ts +56 -2
  15. package/dist/store/run/checkpoint-disk.d.ts.map +1 -1
  16. package/dist/store/run/checkpoint-disk.js +83 -2
  17. package/dist/store/run/checkpoint-disk.js.map +1 -1
  18. package/dist/store/run/checkpoint-memory.d.ts +31 -0
  19. package/dist/store/run/checkpoint-memory.d.ts.map +1 -0
  20. package/dist/store/run/checkpoint-memory.js +83 -0
  21. package/dist/store/run/checkpoint-memory.js.map +1 -0
  22. package/dist/store/run/disk.d.ts +53 -0
  23. package/dist/store/run/disk.d.ts.map +1 -1
  24. package/dist/store/run/disk.js +73 -30
  25. package/dist/store/run/disk.js.map +1 -1
  26. package/dist/store/run/listing.d.ts +75 -0
  27. package/dist/store/run/listing.d.ts.map +1 -0
  28. package/dist/store/run/listing.js +183 -0
  29. package/dist/store/run/listing.js.map +1 -0
  30. package/dist/types/run/checkpoint-store.d.ts +210 -1
  31. package/dist/types/run/checkpoint-store.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/manager/run/persistence.ts +18 -4
  34. package/src/public-runtime.ts +13 -0
  35. package/src/store/index.ts +18 -0
  36. package/src/store/run/checkpoint-disk.ts +128 -4
  37. package/src/store/run/checkpoint-memory.ts +101 -0
  38. package/src/store/run/disk.ts +72 -27
  39. package/src/store/run/listing.ts +229 -0
  40. package/src/types/run/checkpoint-store.ts +221 -1
@@ -192,33 +192,7 @@ export class RunDiskStore {
192
192
  }
193
193
 
194
194
  async listCheckpoints(): Promise<IterationCheckpoint[]> {
195
- const dir = this.requireInit()
196
- const cpDir = join(dir, 'checkpoints')
197
- try {
198
- const files = await readdir(cpDir)
199
- const checkpoints: IterationCheckpoint[] = []
200
- for (const file of files) {
201
- if (!file.endsWith('.json')) continue
202
- // An unreadable checkpoint used to be logged and skipped, so
203
- // this returned a silently short list that four callers treat
204
- // as complete. A missing NEWEST checkpoint quietly resumes
205
- // from an older point and re-runs a whole iteration of tool
206
- // calls; a missing PARKED one reports "not parked" and drops
207
- // an approval a human already granted, because the file is
208
- // the only durable record of a park. Pruning under-deletes
209
- // too: a file the keep-count cannot see is immortal.
210
- //
211
- // The by-id read next door was already strict. Two read paths
212
- // disagreeing about whether damage matters is how the lenient
213
- // one gets trusted.
214
- const content = await readFile(join(cpDir, file), 'utf-8')
215
- checkpoints.push(parseCheckpoint(content, file))
216
- }
217
- return checkpoints.sort((a, b) => a.createdAt - b.createdAt)
218
- } catch (err) {
219
- if (isFileNotFound(err)) return []
220
- throw err
221
- }
195
+ return readCheckpointsIn(this.requireInit())
222
196
  }
223
197
 
224
198
  async deleteCheckpoint(checkpointId: CheckpointId): Promise<void> {
@@ -230,6 +204,29 @@ export class RunDiskStore {
230
204
  }
231
205
  }
232
206
 
207
+ /**
208
+ * @deprecated Superseded by
209
+ * {@link import('../../types/run/checkpoint-store.js').CheckpointStore.listDurableRuns},
210
+ * reached through {@link import('./listing.js').listDurableRuns}.
211
+ * Removed in the next major.
212
+ *
213
+ * Three things are wrong with `index.json` as the answer to "which runs
214
+ * are there":
215
+ *
216
+ * 1. Its entries carry no tenant, project or session, so a row cannot be
217
+ * turned back into an addressable scope — nothing can be resumed or
218
+ * swept from it.
219
+ * 2. `addToIndex` skips every sub-run, so an inbox built on it drops
220
+ * every approval raised by delegated work, and the symptom looks like
221
+ * a hung specialist rather than a blind listing.
222
+ * 3. It is a catalogue of runs that STARTED, not of runs with durable
223
+ * state, so it cannot tell a run something could resume from one that
224
+ * left nothing behind.
225
+ *
226
+ * Deprecated for one minor rather than deleted outright: this is public
227
+ * surface and a consumer calling it today gets real data back, so the
228
+ * deprecate-before-you-remove rule applies.
229
+ */
233
230
  static async listRuns(baseDir: string): Promise<
234
231
  Array<{
235
232
  id: string
@@ -297,6 +294,54 @@ export class RunDiskStore {
297
294
  }
298
295
  }
299
296
 
297
+ /**
298
+ * Every checkpoint stored under one run directory, ascending by `createdAt`.
299
+ *
300
+ * A free function rather than a method because the scope-level listing walks
301
+ * run directories it has never bound a {@link RunDiskStore} to — and binding
302
+ * one would CREATE the directory, which is not something a read should do.
303
+ * Sharing the function is what keeps the two read paths from disagreeing
304
+ * about what a damaged file means.
305
+ *
306
+ * An unreadable checkpoint used to be logged and skipped, so this returned a
307
+ * silently short list that four callers treat as complete. A missing NEWEST
308
+ * checkpoint quietly resumes from an older point and re-runs a whole
309
+ * iteration of tool calls; a missing PARKED one reports "not parked" and
310
+ * drops an approval a human already granted, because the file is the only
311
+ * durable record of a park. Pruning under-deletes too: a file the keep-count
312
+ * cannot see is immortal. The by-id read next door was already strict, and
313
+ * two read paths disagreeing about whether damage matters is how the lenient
314
+ * one gets trusted.
315
+ *
316
+ * The same reasoning carries up to the listing, which is why the throw
317
+ * propagates there rather than dropping the run: a damaged checkpoint that
318
+ * removed a run from an approval inbox is the missing-park failure again,
319
+ * one level up.
320
+ *
321
+ * A missing `checkpoints/` directory is the only absence that reads as
322
+ * empty — the run genuinely has none. A file that disappears BETWEEN the
323
+ * directory listing and its read throws, where the old shape returned the
324
+ * empty array and discarded every checkpoint it had already parsed.
325
+ */
326
+ export async function readCheckpointsIn(runDir: string): Promise<IterationCheckpoint[]> {
327
+ const cpDir = join(runDir, 'checkpoints')
328
+ let files: string[]
329
+ try {
330
+ files = await readdir(cpDir)
331
+ } catch (err) {
332
+ if (isFileNotFound(err)) return []
333
+ throw err
334
+ }
335
+
336
+ const checkpoints: IterationCheckpoint[] = []
337
+ for (const file of files) {
338
+ if (!file.endsWith('.json')) continue
339
+ const content = await readFile(join(cpDir, file), 'utf-8')
340
+ checkpoints.push(parseCheckpoint(content, file))
341
+ }
342
+ return checkpoints.sort((a, b) => a.createdAt - b.createdAt)
343
+ }
344
+
300
345
  async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
301
346
  await atomicWriteFile(filePath, JSON.stringify(stamp(SCHEMA, value), null, 2))
302
347
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Shared machinery for {@link CheckpointStore.listDurableRuns}.
3
+ *
4
+ * Every rule the listing promises — the contiguous-prefix refusal, the park
5
+ * precedence, the ordering, the paging — lives here and is used by BOTH
6
+ * shipped implementations. A rule implemented twice is a rule that holds in
7
+ * one store and not the other, and the whole point of a store contract is
8
+ * that a host can swap the backend without swapping the semantics.
9
+ */
10
+
11
+ import { NamzuError } from '../../types/errors/index.js'
12
+ import type { IterationCheckpoint, PendingDecision } from '../../types/hitl/index.js'
13
+ import type {
14
+ CheckpointListingScope,
15
+ CheckpointRunScope,
16
+ CheckpointStore,
17
+ DurableRunEntry,
18
+ DurableRunPage,
19
+ ListDurableRunsOptions,
20
+ ParkState,
21
+ ParkSummary,
22
+ } from '../../types/run/checkpoint-store.js'
23
+
24
+ /** Page size when the caller names none. */
25
+ export const DEFAULT_DURABLE_RUN_LIMIT = 100
26
+
27
+ /**
28
+ * Refuse a listing scope with a hole in it.
29
+ *
30
+ * `{ tenantId, sessionId }` reads as "that session under whichever project
31
+ * holds it". A flat backend can answer it; a hierarchical one cannot look up
32
+ * a session without its project. Answering differently per backend is the
33
+ * one thing the contract exists to prevent, so neither answers: the caller
34
+ * names the project it means.
35
+ */
36
+ export function assertContiguousListingScope(scope: CheckpointListingScope, caller: string): void {
37
+ if (scope.sessionId !== undefined && scope.projectId === undefined) {
38
+ throw new NamzuError({
39
+ code: 'invalid_config',
40
+ message: `${caller}: listing scope has a hole — \`sessionId\` was supplied without \`projectId\`. A run listing scope is a contiguous prefix of tenant → project → session; name the project the session belongs to.`,
41
+ details: { tenantId: scope.tenantId, sessionId: scope.sessionId },
42
+ })
43
+ }
44
+ }
45
+
46
+ /** Whether a park's absolute deadline has passed. No deadline never expires. */
47
+ function isPastDeadline(pending: PendingDecision, now: number): boolean {
48
+ return pending.deadlineAt !== undefined && now >= pending.deadlineAt
49
+ }
50
+
51
+ /**
52
+ * Which of the three states a recorded park is in.
53
+ *
54
+ * `resolved` is checked FIRST: a park that was answered after its deadline
55
+ * passed is answered, not expired. Reading the deadline first would report
56
+ * a decision a human actually made as an expiry nobody made, and the
57
+ * checkpoint is the evidence record for exactly that question.
58
+ */
59
+ function parkStateOf(pending: PendingDecision, now: number): ParkState {
60
+ if (pending.resolvedAt !== undefined) return 'resolved'
61
+ return isPastDeadline(pending, now) ? 'expired' : 'outstanding'
62
+ }
63
+
64
+ function toParkSummary(
65
+ cp: IterationCheckpoint,
66
+ pending: PendingDecision,
67
+ now: number,
68
+ ): ParkSummary {
69
+ return {
70
+ state: parkStateOf(pending, now),
71
+ checkpointId: cp.id,
72
+ requestType: pending.request.type,
73
+ parkedAt: pending.parkedAt,
74
+ ...(pending.deadlineAt !== undefined ? { deadlineAt: pending.deadlineAt } : {}),
75
+ ...(pending.resolvedAt !== undefined ? { resolvedAt: pending.resolvedAt } : {}),
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The one park that describes what a run is doing now, out of every park it
81
+ * ever recorded.
82
+ *
83
+ * Precedence: newest `outstanding`, else newest `expired`, else newest
84
+ * `resolved`.
85
+ *
86
+ * `outstanding` wins because that is the question an inbox is asking, and
87
+ * because the answer has to be the SAME checkpoint `findPendingCheckpoint`
88
+ * returns. A run can hold several parks — it parks, a human answers, it runs
89
+ * on, it parks again — and it can hold an outstanding one that is older than
90
+ * a resolved one only in the reverse case, where an earlier park expired
91
+ * unanswered and the run was resumed past it. Ranking by recency alone would
92
+ * then hand an inbox a resolved checkpoint and report the live park as
93
+ * nothing.
94
+ *
95
+ * @param checkpoints the run's checkpoints, any order.
96
+ */
97
+ export function summarizePark(
98
+ checkpoints: readonly IterationCheckpoint[],
99
+ now: number,
100
+ ): ParkSummary | undefined {
101
+ let best: ParkSummary | undefined
102
+ let bestRank = -1
103
+ let bestParkedAt = Number.NEGATIVE_INFINITY
104
+
105
+ for (const cp of checkpoints) {
106
+ const pending = cp.pending
107
+ if (!pending) continue
108
+ const summary = toParkSummary(cp, pending, now)
109
+ const rank = PARK_RANK[summary.state]
110
+ if (rank > bestRank || (rank === bestRank && pending.parkedAt > bestParkedAt)) {
111
+ best = summary
112
+ bestRank = rank
113
+ bestParkedAt = pending.parkedAt
114
+ }
115
+ }
116
+
117
+ return best
118
+ }
119
+
120
+ const PARK_RANK: Record<ParkState, number> = {
121
+ resolved: 0,
122
+ expired: 1,
123
+ outstanding: 2,
124
+ }
125
+
126
+ /**
127
+ * Project one run's checkpoints into a listing entry.
128
+ *
129
+ * Returns `null` for a run with no checkpoints: the listing is of runs with
130
+ * DURABLE state, and a run with nothing stored has nothing a sweeper could
131
+ * resume. A disk walk hits this case for real — a sub-run's directory is
132
+ * created as a bare shell under its own id before anything is written to it.
133
+ */
134
+ export function toDurableRunEntry(
135
+ scope: CheckpointRunScope,
136
+ checkpoints: readonly IterationCheckpoint[],
137
+ now: number,
138
+ ): DurableRunEntry | null {
139
+ if (checkpoints.length === 0) return null
140
+
141
+ let latest = checkpoints[0] as IterationCheckpoint
142
+ for (const cp of checkpoints) {
143
+ if (cp.createdAt > latest.createdAt) latest = cp
144
+ }
145
+
146
+ const park = summarizePark(checkpoints, now)
147
+
148
+ return {
149
+ tenantId: scope.tenantId,
150
+ projectId: scope.projectId,
151
+ sessionId: scope.sessionId,
152
+ runId: scope.runId,
153
+ ...(scope.parentRunId ? { parentRunId: scope.parentRunId } : {}),
154
+ checkpointCount: checkpoints.length,
155
+ latestCheckpointId: latest.id,
156
+ latestCheckpointAt: latest.createdAt,
157
+ ...(park ? { park } : {}),
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Apply the park filter, the contract's ordering and the cursor to a set of
163
+ * entries an implementation has gathered.
164
+ *
165
+ * Both shipped stores gather differently — one walks a directory tree, one
166
+ * reads a map — and then hand the result here, so "ordered by `runId`, page
167
+ * ends where the next begins" is one implementation rather than two.
168
+ */
169
+ export function paginateDurableRuns(
170
+ entries: readonly DurableRunEntry[],
171
+ options?: ListDurableRunsOptions,
172
+ ): DurableRunPage {
173
+ const wanted = options?.park
174
+ const filtered =
175
+ wanted && wanted.length > 0
176
+ ? entries.filter((e) => e.park !== undefined && wanted.includes(e.park.state))
177
+ : entries
178
+
179
+ // Ordered by `runId` because it is the only per-run key that cannot move
180
+ // under a paging caller — see the contract comment on `listDurableRuns`.
181
+ const ordered = [...filtered].sort((a, b) => (a.runId < b.runId ? -1 : a.runId > b.runId ? 1 : 0))
182
+
183
+ const after = options?.cursor
184
+ const start = after === undefined ? 0 : ordered.findIndex((e) => e.runId > after)
185
+ const from = start < 0 ? ordered.length : start
186
+
187
+ const limit = Math.max(1, Math.trunc(options?.limit ?? DEFAULT_DURABLE_RUN_LIMIT))
188
+ const page = ordered.slice(from, from + limit)
189
+ const exhausted = from + page.length >= ordered.length
190
+
191
+ return {
192
+ entries: page,
193
+ // No cursor when there is nothing behind it, so `while (cursor)`
194
+ // terminates rather than fetching one empty page to find out.
195
+ ...(exhausted || page.length === 0
196
+ ? {}
197
+ : { cursor: (page[page.length - 1] as DurableRunEntry).runId }),
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Every run with durable state under a scope, refusing when the store cannot
203
+ * answer.
204
+ *
205
+ * The refusal is the point. `listDurableRuns` is optional on the contract so
206
+ * that adding it did not break every host that had already implemented the
207
+ * interface — and an optional capability reached without a check degrades
208
+ * into a wrong answer: a store that cannot list would hand an approval inbox
209
+ * an empty page, and "nothing is waiting on a human" is not what "I cannot
210
+ * tell" means. A host that gets this error knows to supply a backend that
211
+ * implements the listing; a host that got `[]` would ship an inbox that
212
+ * silently never fires.
213
+ */
214
+ export async function listDurableRuns(
215
+ store: CheckpointStore,
216
+ scope: CheckpointListingScope,
217
+ options?: ListDurableRunsOptions,
218
+ ): Promise<DurableRunPage> {
219
+ if (typeof store.listDurableRuns !== 'function') {
220
+ throw new NamzuError({
221
+ code: 'capability_unavailable',
222
+ message:
223
+ 'listDurableRuns: the injected checkpoint store does not implement `listDurableRuns`, so it cannot enumerate runs above a run id. Refusing rather than reporting an empty listing, which would read as "no runs are parked" when the truth is that this store cannot tell. Supply a store that implements it (the built-in disk and in-memory stores both do).',
224
+ details: { tenantId: scope.tenantId },
225
+ })
226
+ }
227
+ assertContiguousListingScope(scope, 'listDurableRuns')
228
+ return store.listDurableRuns(scope, options)
229
+ }
@@ -9,7 +9,7 @@
9
9
  * five-layer attribution (Convention #17) instead of a filesystem path.
10
10
  */
11
11
 
12
- import type { CheckpointId, IterationCheckpoint } from '../hitl/index.js'
12
+ import type { CheckpointId, HITLDecisionRequest, IterationCheckpoint } from '../hitl/index.js'
13
13
  import type { RunId, SessionId, TenantId } from '../ids/index.js'
14
14
  import type { ProjectId } from '../session/ids.js'
15
15
 
@@ -39,6 +39,160 @@ export interface CheckpointRunScope {
39
39
  parentRunId?: RunId
40
40
  }
41
41
 
42
+ /**
43
+ * A CONTIGUOUS PREFIX of the run attribution hierarchy, addressing a SET of
44
+ * runs rather than one.
45
+ *
46
+ * A separate type from {@link CheckpointRunScope} on purpose. That type
47
+ * addresses exactly one run and four accessors depend on it doing so; making
48
+ * its one distinguishing field optional in place would turn "the scope of a
49
+ * run" into "some identifiers, maybe", and every accessor's guarantee with
50
+ * it.
51
+ *
52
+ * Three properties, each deliberate:
53
+ *
54
+ * - **`tenantId` is required.** Isolation is the one boundary that is never
55
+ * optional here. An untenanted listing is a cross-tenant read with a
56
+ * friendly name.
57
+ * - **It stops ABOVE the run.** No `runId`, no `parentRunId`. A caller
58
+ * holding a run id already has a full {@link CheckpointRunScope} and four
59
+ * accessors that take it; admitting one here would make
60
+ * `CheckpointRunScope` structurally assignable to this type and re-merge
61
+ * the two ideas the split exists to keep apart.
62
+ * - **The prefix must be contiguous.** A `sessionId` with no `projectId` is
63
+ * REFUSED, not silently widened to "that session under whichever project
64
+ * holds it". A flat backend can answer it and a hierarchical one cannot,
65
+ * so the answer would depend on the backend's storage shape — which is
66
+ * the one thing a store contract exists to hide.
67
+ */
68
+ export interface CheckpointListingScope {
69
+ /** Isolation boundary (Convention #17). Never optional. */
70
+ readonly tenantId: TenantId
71
+ /** Narrow to one project. Absent = every project of the tenant. */
72
+ readonly projectId?: ProjectId
73
+ /** Narrow to one session. Requires `projectId`. */
74
+ readonly sessionId?: SessionId
75
+ }
76
+
77
+ /**
78
+ * What a run's human-in-the-loop park is doing, as far as durable state can
79
+ * tell.
80
+ *
81
+ * A closed union rather than a boolean because the two unanswered states are
82
+ * drained by DIFFERENT operators: `outstanding` is an approval inbox's queue
83
+ * and `expired` is a reclamation sweep's, and serving one to the other either
84
+ * re-presents a dead approval forever or discards a live one.
85
+ */
86
+ export type ParkState =
87
+ /** `pending` set, no `resolvedAt`, deadline not passed. A human owes an answer. */
88
+ | 'outstanding'
89
+ /** `pending` set, no `resolvedAt`, deadline passed. Nobody will answer it. */
90
+ | 'expired'
91
+ /** `pending` set with `resolvedAt`. Kept as evidence of who decided what. */
92
+ | 'resolved'
93
+
94
+ /**
95
+ * **Do not widen this union to say who is working on the run.**
96
+ *
97
+ * A consumer switches over `ParkState` exhaustively, so a fourth member is a
98
+ * backward-incompatible change and a `major` — and the pull to add one is
99
+ * real, because the next capability this contract takes is a cross-process
100
+ * claim, and a queue worker draining the inbox wants to skip runs another
101
+ * worker already holds.
102
+ *
103
+ * That is a different fact about a different subject. A park is a question
104
+ * put to a HUMAN; a claim is a lease held by a PROCESS, and one run can have
105
+ * both, neither, or either. Encoding them in one union makes the pair
106
+ * unsayable and loses the state a worker needs most: parked AND unclaimed.
107
+ *
108
+ * The additive shape is a sibling optional field — `claim?: …` on
109
+ * {@link DurableRunEntry}, `claimed?: …` on {@link ListDurableRunsOptions}.
110
+ * A consumer reading rows is not broken by a new optional field, so the
111
+ * claim ships as a second `minor` on this contract rather than a second
112
+ * migration of it. This note exists because the union is the obvious place
113
+ * to reach for and the wrong one.
114
+ */
115
+
116
+ /** A run's park disposition, projected from the checkpoint that carries it. */
117
+ export interface ParkSummary {
118
+ readonly state: ParkState
119
+ /** The parked checkpoint — address it directly with `readCheckpoint`. */
120
+ readonly checkpointId: CheckpointId
121
+ /** What the human was asked. Enough to route an inbox without a second read. */
122
+ readonly requestType: HITLDecisionRequest['type']
123
+ /** Epoch ms at which the run parked. */
124
+ readonly parkedAt: number
125
+ /** Absolute expiry, when the park carries one. */
126
+ readonly deadlineAt?: number
127
+ /** Epoch ms at which the answer arrived. Only on `resolved`. */
128
+ readonly resolvedAt?: number
129
+ }
130
+
131
+ /**
132
+ * One run that has durable checkpoint state under the queried scope.
133
+ *
134
+ * **Extends {@link CheckpointRunScope}, and that is the load-bearing part.**
135
+ * A listing whose rows cannot be turned back into an addressable scope is a
136
+ * report, not a work queue. Because an entry IS a run scope,
137
+ * `findPendingCheckpoint(store, entry)`, `new CheckpointManager(store, entry)`
138
+ * and `resumeRun({ scope: entry, … })` all accept a row straight out of the
139
+ * listing — with no re-assembly, and so no chance of assembling it wrong.
140
+ *
141
+ * ### What an entry deliberately does NOT carry
142
+ *
143
+ * A run STATUS. A checkpoint is written mid-flight, so nothing in this store
144
+ * distinguishes a run that finished from one that died — the same fact
145
+ * {@link import('../../runtime/query/run-state.js').loadRunState} already
146
+ * states, where a rebuilt snapshot always reports `running` and the host's
147
+ * own record stays the authority. A `status` field here would answer
148
+ * "mid-flight" for every run that ever succeeded, and a sweeper built on it
149
+ * would resume finished work.
150
+ *
151
+ * A crash sweep is therefore: list every run with durable state under the
152
+ * scope, intersect with the host's own run records, resume the difference.
153
+ */
154
+ export interface DurableRunEntry extends CheckpointRunScope {
155
+ /** How many checkpoints the run has right now. Pruning lowers it. */
156
+ readonly checkpointCount: number
157
+ /** Newest checkpoint by `createdAt` — the one a resume restores by default. */
158
+ readonly latestCheckpointId: CheckpointId
159
+ /** `createdAt` of {@link DurableRunEntry.latestCheckpointId}. */
160
+ readonly latestCheckpointAt: number
161
+ /** Absent when the run has never parked. */
162
+ readonly park?: ParkSummary
163
+ }
164
+
165
+ /** Filters and paging for {@link CheckpointStore.listDurableRuns}. */
166
+ export interface ListDurableRunsOptions {
167
+ /**
168
+ * Keep only runs whose park is in one of these states. A run that never
169
+ * parked has no state and is excluded by ANY value here; omit the filter
170
+ * to include it.
171
+ */
172
+ readonly park?: readonly ParkState[]
173
+ /** Page size. Defaults to 100, clamped to at least 1. */
174
+ readonly limit?: number
175
+ /** Resume token from the previous page's {@link DurableRunPage.cursor}. */
176
+ readonly cursor?: string
177
+ /**
178
+ * Clock for expiry, so a sweep can be tested and so every entry in one
179
+ * page is judged against the same instant. Defaults to `Date.now()` — the
180
+ * same seam `findPendingCheckpoint` already takes.
181
+ */
182
+ readonly now?: number
183
+ }
184
+
185
+ /** One page of {@link DurableRunEntry}. */
186
+ export interface DurableRunPage {
187
+ readonly entries: readonly DurableRunEntry[]
188
+ /**
189
+ * Pass to the next call. **Absent means the listing is exhausted**, so
190
+ * `while (cursor)` terminates; a store never returns a cursor it already
191
+ * knows yields nothing.
192
+ */
193
+ readonly cursor?: string
194
+ }
195
+
42
196
  /**
43
197
  * Persistence contract consumed by
44
198
  * {@link import('../../runtime/query/checkpoint.js').CheckpointManager} and
@@ -49,6 +203,27 @@ export interface CheckpointRunScope {
49
203
  * not-found error. `deleteCheckpoint` is idempotent: deleting an absent
50
204
  * checkpoint succeeds as a no-op (mirrors the disk store's ENOENT
51
205
  * swallowing).
206
+ *
207
+ * ## Optional capabilities, and the rule that comes with them
208
+ *
209
+ * {@link CheckpointStore.listDurableRuns} is optional, following
210
+ * `SessionStore.listSessionsByProject`. A required method would break every
211
+ * host that has already implemented this interface, which is a `major` for
212
+ * what is otherwise an additive capability.
213
+ *
214
+ * The rule optionality obliges: **a caller of an optional capability REFUSES
215
+ * when it is absent; it never degrades.** An approval inbox built on a store
216
+ * that cannot list has to throw, because an empty page would say "nothing is
217
+ * waiting on a human" when the truth is "I cannot tell" — an optional
218
+ * dependency degrading a check, which this repository has been bitten by
219
+ * before. Reach the capability through
220
+ * {@link import('../../store/run/listing.js').listDurableRuns}, which
221
+ * refuses on absence rather than answering.
222
+ *
223
+ * Any capability added here later takes the same shape — optional method,
224
+ * refusing helper. A cross-process claim is the next one, and a two-worker
225
+ * deployment against a store with no lease has to fail loudly rather than
226
+ * proceed.
52
227
  */
53
228
  export interface CheckpointStore {
54
229
  /** Persist one checkpoint. Overwrites an existing checkpoint with the same id. */
@@ -69,4 +244,49 @@ export interface CheckpointStore {
69
244
 
70
245
  /** Delete a checkpoint by id. Absent checkpoints succeed as a no-op. */
71
246
  deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void>
247
+
248
+ /**
249
+ * Every run with durable checkpoint state under a scope ABOVE the run.
250
+ * OPTIONAL — see the optional-capability rule on this interface.
251
+ *
252
+ * This is the read an approval inbox and a park sweep are built from, and
253
+ * the one thing this contract had no way to express: every other accessor
254
+ * needs a `runId`, so a host could only ask about runs it already knew
255
+ * about. `hitlParkTtlMs` documents a host sweep as the reclamation path
256
+ * for an unanswered park, and until this existed the sweep had no way to
257
+ * enumerate what to sweep.
258
+ *
259
+ * ### Ordering, and why it is not chronological
260
+ *
261
+ * Rows come back ordered by `runId` ascending, and the cursor is a
262
+ * position in that order.
263
+ *
264
+ * A cursor has to sort on a key that cannot move, or a paging caller
265
+ * skips rows and repeats rows. Every time-valued key this store can
266
+ * derive per run DOES move: the newest checkpoint's timestamp advances
267
+ * whenever the run checkpoints again, and the oldest one's advances
268
+ * whenever `CheckpointManager.prune` deletes oldest-first, which is what
269
+ * pruning does. `runId` is the only immutable, unique per-run key
270
+ * available, and being unique it is already a total order — the
271
+ * degenerate case of the rule `orderChildren` follows (sort on a key that
272
+ * cannot move, make the order total with an id), not a departure from it.
273
+ *
274
+ * The cost is that page order is arbitrary rather than oldest-first,
275
+ * because run ids carry no timestamp. Entries carry `latestCheckpointAt`
276
+ * and `park.parkedAt` so a caller can sort what it has read.
277
+ *
278
+ * A run whose FIRST checkpoint is written after paging began may be
279
+ * missed by that pass — it lands at whatever `runId` it minted, possibly
280
+ * behind the cursor. That is the right trade for a queue: the sweep runs
281
+ * again and picks it up next pass, whereas a moving sort key loses runs
282
+ * that already existed.
283
+ *
284
+ * @param scope contiguous prefix; `tenantId` required. Implementations
285
+ * reject a hole (`sessionId` with no `projectId`) rather than guessing.
286
+ * @param options filters and paging. See {@link ListDurableRunsOptions}.
287
+ */
288
+ listDurableRuns?(
289
+ scope: CheckpointListingScope,
290
+ options?: ListDurableRunsOptions,
291
+ ): Promise<DurableRunPage>
72
292
  }