@namzu/sdk 20.0.0 → 20.2.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 (49) hide show
  1. package/CHANGELOG.md +108 -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/runtime/query/checkpoint.d.ts +20 -0
  11. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  12. package/dist/runtime/query/checkpoint.js +47 -0
  13. package/dist/runtime/query/checkpoint.js.map +1 -1
  14. package/dist/store/index.d.ts +3 -0
  15. package/dist/store/index.d.ts.map +1 -1
  16. package/dist/store/index.js +12 -0
  17. package/dist/store/index.js.map +1 -1
  18. package/dist/store/run/checkpoint-disk.d.ts +56 -2
  19. package/dist/store/run/checkpoint-disk.d.ts.map +1 -1
  20. package/dist/store/run/checkpoint-disk.js +83 -2
  21. package/dist/store/run/checkpoint-disk.js.map +1 -1
  22. package/dist/store/run/checkpoint-memory.d.ts +31 -0
  23. package/dist/store/run/checkpoint-memory.d.ts.map +1 -0
  24. package/dist/store/run/checkpoint-memory.js +83 -0
  25. package/dist/store/run/checkpoint-memory.js.map +1 -0
  26. package/dist/store/run/disk.d.ts +53 -0
  27. package/dist/store/run/disk.d.ts.map +1 -1
  28. package/dist/store/run/disk.js +73 -30
  29. package/dist/store/run/disk.js.map +1 -1
  30. package/dist/store/run/listing.d.ts +75 -0
  31. package/dist/store/run/listing.d.ts.map +1 -0
  32. package/dist/store/run/listing.js +242 -0
  33. package/dist/store/run/listing.js.map +1 -0
  34. package/dist/types/hitl/index.d.ts +27 -0
  35. package/dist/types/hitl/index.d.ts.map +1 -1
  36. package/dist/types/hitl/index.js.map +1 -1
  37. package/dist/types/run/checkpoint-store.d.ts +260 -1
  38. package/dist/types/run/checkpoint-store.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/manager/run/persistence.ts +18 -4
  41. package/src/public-runtime.ts +13 -0
  42. package/src/runtime/query/checkpoint.ts +51 -0
  43. package/src/store/index.ts +18 -0
  44. package/src/store/run/checkpoint-disk.ts +128 -4
  45. package/src/store/run/checkpoint-memory.ts +101 -0
  46. package/src/store/run/disk.ts +72 -27
  47. package/src/store/run/listing.ts +307 -0
  48. package/src/types/hitl/index.ts +28 -0
  49. package/src/types/run/checkpoint-store.ts +274 -1
@@ -198,12 +198,25 @@ export {
198
198
  DiskCheckpointStore,
199
199
  DiskMemoryStore,
200
200
  DiskTaskStore,
201
+ InMemoryCheckpointStore,
201
202
  InMemoryMemoryIndex,
202
203
  InMemoryMemoryStore,
203
204
  InMemoryStore,
204
205
  InMemoryTaskStore,
205
206
  RunDiskStore,
206
207
  } from './store/index.js'
208
+ export type { DiskCheckpointStoreAttribution } from './store/index.js'
209
+ // Enumerating runs above a run id — the read an approval inbox and a park
210
+ // sweep are built from, and the one the contract had no way to express.
211
+ // `listDurableRuns` REFUSES on a store that cannot list rather than
212
+ // reporting an empty page, because "nothing is waiting on a human" is not
213
+ // what "I cannot tell" means.
214
+ export {
215
+ assertContiguousListingScope,
216
+ listDurableRuns,
217
+ paginateDurableRuns,
218
+ toDurableRunEntry,
219
+ } from './store/index.js'
207
220
 
208
221
  export {
209
222
  AgentRegistry,
@@ -51,6 +51,12 @@ export function projectEmergencyToCheckpoint(dump: EmergencySaveData): Iteration
51
51
  return {
52
52
  id: `cp_emergency_${emergencySuffix}` as CheckpointId,
53
53
  runId: dump.runId,
54
+ // The dump records the run's start, so this projection carries the
55
+ // same stamp an ordinary checkpoint of that run would — a run whose
56
+ // only surviving record is an emergency dump still has a real
57
+ // attribution instant, and dropping it here would put that run in the
58
+ // "never recorded" bucket for no reason.
59
+ runCreatedAt: dump.startedAt,
54
60
  iteration: dump.currentIteration,
55
61
  messages: dump.messages,
56
62
  tokenUsage: dump.tokenUsage,
@@ -148,6 +154,27 @@ export class CheckpointManager {
148
154
  /** See {@link setParkTtl}. */
149
155
  private parkTtlMs?: number
150
156
 
157
+ /**
158
+ * The run's attribution instant, stamped onto every checkpoint this
159
+ * manager writes.
160
+ *
161
+ * Settled exactly once, by whichever of two things happens first, and
162
+ * never reassigned — every write to it below is `??=`, and there are only
163
+ * two:
164
+ *
165
+ * - `restore` ADOPTS it from the checkpoint a resume came back through.
166
+ * A resumed run is the same run, and its creation is already on the
167
+ * record; a fresh process minting a new one would move the key that
168
+ * exists specifically because it does not move.
169
+ * - `create` MINTS it from the run's own start instant when nothing was
170
+ * adopted, which is the fresh-run case.
171
+ *
172
+ * Restore runs during run setup, before the first iteration and therefore
173
+ * before the first `create`, so the adopt always wins on the resume path
174
+ * without either site needing to know which path it is on.
175
+ */
176
+ private runCreatedAt?: number
177
+
151
178
  /**
152
179
  * @param store scope-keyed checkpoint persistence. The default query
153
180
  * pipeline passes the run's disk-backed store
@@ -182,9 +209,17 @@ export class CheckpointManager {
182
209
  workingState?: WorkingStateSnapshot
183
210
  },
184
211
  ): Promise<IterationCheckpoint> {
212
+ // The run's own start, not `Date.now()`. This is meant to say when the
213
+ // run was attributed; taking the clock at the first checkpoint would
214
+ // say when it first became durable, which is a different and later
215
+ // fact, and naming it after the earlier one would make it wrong in
216
+ // exactly the way that is hard to notice.
217
+ this.runCreatedAt ??= runMgr.getSession().startedAt ?? Date.now()
218
+
185
219
  const checkpoint: IterationCheckpoint = {
186
220
  id: generateCheckpointId(),
187
221
  runId: runMgr.id,
222
+ runCreatedAt: this.runCreatedAt,
188
223
  iteration,
189
224
  messages: [...runMgr.messages],
190
225
  tokenUsage: { ...runMgr.tokenUsage },
@@ -342,6 +377,22 @@ export class CheckpointManager {
342
377
  details: { checkpointId, runId: this.scope.runId },
343
378
  })
344
379
  }
380
+
381
+ // Adopt the run's recorded attribution. A resumed run is the SAME run
382
+ // under the same id, and its creation is already on the record; a
383
+ // fresh process minting a new one would step the stamp forward on
384
+ // every resume, which is the motion it exists to avoid.
385
+ //
386
+ // This needs no "is it really my run" guard, and one was written and
387
+ // removed: `readCheckpoint` is keyed by THIS manager's scope, so the
388
+ // only checkpoints reachable here are the ones belonging to
389
+ // `scope.runId`. A replay fork never arrives here at all — it reads
390
+ // its origin through the SOURCE scope in `prepareReplayState` and
391
+ // starts a fresh run, so it mints its own stamp rather than claiming
392
+ // its origin's age. A guard that no input can trip would have read as
393
+ // protection and been none.
394
+ this.runCreatedAt ??= checkpoint.runCreatedAt
395
+
345
396
  return checkpoint
346
397
  }
347
398
 
@@ -3,6 +3,24 @@ export type { Identifiable, Timestamped } from './InMemoryStore.js'
3
3
 
4
4
  export { RunDiskStore } from './run/disk.js'
5
5
  export { DiskCheckpointStore } from './run/checkpoint-disk.js'
6
+ export type { DiskCheckpointStoreAttribution } from './run/checkpoint-disk.js'
7
+ export { InMemoryCheckpointStore } from './run/checkpoint-memory.js'
8
+ // The refusing entry point to the optional listing capability, plus the two
9
+ // projections a host implementing its own backend actually calls: one turns
10
+ // a run's checkpoints into a row, the other applies the contract's filter,
11
+ // ordering and cursor. Re-deriving either is how two stores start
12
+ // disagreeing about what "outstanding" means or where a page ends.
13
+ //
14
+ // `summarizePark` and `DEFAULT_DURABLE_RUN_LIMIT` are deliberately NOT here.
15
+ // The first is an internal of `toDurableRunEntry` and no caller wants half a
16
+ // row; the second is a number a host reads by omitting `limit`. A name a
17
+ // host has no use for is surface to keep correct forever for nobody.
18
+ export {
19
+ assertContiguousListingScope,
20
+ listDurableRuns,
21
+ paginateDurableRuns,
22
+ toDurableRunEntry,
23
+ } from './run/listing.js'
6
24
 
7
25
  export { ActivityStore } from './activity/memory.js'
8
26
  export type { ActivityEvent, ActivityEventListener } from './activity/memory.js'
@@ -1,8 +1,40 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { NamzuError } from '../../types/errors/index.js'
1
4
  import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
2
- import type { RunId } from '../../types/ids/index.js'
3
- import type { CheckpointRunScope, CheckpointStore } from '../../types/run/checkpoint-store.js'
5
+ import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
6
+ import type {
7
+ CheckpointListingScope,
8
+ CheckpointRunScope,
9
+ CheckpointStore,
10
+ DurableRunEntry,
11
+ DurableRunPage,
12
+ ListDurableRunsOptions,
13
+ } from '../../types/run/checkpoint-store.js'
4
14
  import type { RunStoreConfig } from '../../types/run/index.js'
5
- import { RunDiskStore } from './disk.js'
15
+ import type { ProjectId } from '../../types/session/ids.js'
16
+ import { RunDiskStore, readCheckpointsIn } from './disk.js'
17
+ import { assertContiguousListingScope, paginateDurableRuns, toDurableRunEntry } from './listing.js'
18
+
19
+ /**
20
+ * The attribution a disk store's own layout does not record.
21
+ *
22
+ * The canonical layout is
23
+ * `{root}/projects/{projectId}/sessions/{sessionId}/runs/{runId}` — there is
24
+ * no tenant segment anywhere in it, and `baseDir` is already one session's
25
+ * `runs/` directory, so the project and session are implicit in a string the
26
+ * store cannot parse back out without knowing the layout that built it.
27
+ *
28
+ * A per-run read never needed any of it: the caller supplies a full
29
+ * `CheckpointRunScope` and the store only uses `runId`. A LISTING does — its
30
+ * rows have to be addressable, and a row with no tenant is a row nothing can
31
+ * be resumed from. So the store is told, once, what tree it is holding.
32
+ */
33
+ export interface DiskCheckpointStoreAttribution {
34
+ readonly tenantId: TenantId
35
+ readonly projectId: ProjectId
36
+ readonly sessionId: SessionId
37
+ }
6
38
 
7
39
  /**
8
40
  * Disk conformance layer for {@link CheckpointStore}: adapts the existing
@@ -20,10 +52,20 @@ import { RunDiskStore } from './disk.js'
20
52
  */
21
53
  export class DiskCheckpointStore implements CheckpointStore {
22
54
  private readonly config: RunStoreConfig
55
+ private readonly attribution?: DiskCheckpointStoreAttribution
23
56
  private readonly bound = new Map<RunId, Promise<RunDiskStore>>()
24
57
 
25
- constructor(config: RunStoreConfig) {
58
+ /**
59
+ * @param config the run-store config; `baseDir` is one session's `runs/`
60
+ * directory.
61
+ * @param attribution what tree this is, for
62
+ * {@link DiskCheckpointStore.listDurableRuns}. Optional so that adding
63
+ * the listing did not change an existing construction; a store built
64
+ * without it refuses to list rather than inventing a tenant.
65
+ */
66
+ constructor(config: RunStoreConfig, attribution?: DiskCheckpointStoreAttribution) {
26
67
  this.config = config
68
+ this.attribution = attribution
27
69
  }
28
70
 
29
71
  private bind(scope: CheckpointRunScope): Promise<RunDiskStore> {
@@ -64,4 +106,86 @@ export class DiskCheckpointStore implements CheckpointStore {
64
106
  const store = await this.bind(scope)
65
107
  await store.deleteCheckpoint(checkpointId)
66
108
  }
109
+
110
+ /**
111
+ * Every run with checkpoints under this store's tree.
112
+ *
113
+ * Reads the directories rather than binding a {@link RunDiskStore} per
114
+ * run, because binding CREATES the run directory — a listing that
115
+ * materialized a directory for every run it looked at would grow the tree
116
+ * it is reporting on.
117
+ *
118
+ * ### Why a two-level walk reaches every depth
119
+ *
120
+ * `initRun` nests exactly one level: a run with a parent goes to
121
+ * `{baseDir}/{parentRunId}/children/{runId}`, and a grandchild goes to
122
+ * `{baseDir}/{itsOwnParentRunId}/children/{runId}` — beside the top-level
123
+ * runs, not beneath its grandparent. So the tree is flat-with-one-nesting
124
+ * at every depth, `{baseDir}/*` plus `{baseDir}/* /children/*` enumerates
125
+ * all of it, and each run's `parentRunId` is the directory it sits under.
126
+ * A deep run leaves a bare shell directory under its own id at the top
127
+ * level (`{baseDir}/{parentRunId}` created by `mkdir -p` for a child of a
128
+ * run whose own data lives elsewhere); those hold no `checkpoints/` and
129
+ * drop out as entries with no durable state.
130
+ */
131
+ async listDurableRuns(
132
+ scope: CheckpointListingScope,
133
+ options?: ListDurableRunsOptions,
134
+ ): Promise<DurableRunPage> {
135
+ assertContiguousListingScope(scope, 'DiskCheckpointStore.listDurableRuns')
136
+
137
+ const attribution = this.attribution
138
+ if (!attribution) {
139
+ throw new NamzuError({
140
+ code: 'invalid_config',
141
+ message:
142
+ 'DiskCheckpointStore.listDurableRuns: this store was constructed without attribution, so it cannot say which tenant, project or session its runs belong to — and a listing row that carries no scope is a row nothing can be resumed or swept from. Pass the second constructor argument. Refusing rather than returning rows stamped with a guessed tenant.',
143
+ details: { baseDir: this.config.baseDir },
144
+ })
145
+ }
146
+
147
+ // A listing is scoped, not addressed: a query for another tenant is a
148
+ // question this tree has no rows for, not an isolation violation. Same
149
+ // reasoning `SessionStore.listSessions` already states for sessions
150
+ // that happen to share a thread id across tenants.
151
+ if (
152
+ scope.tenantId !== attribution.tenantId ||
153
+ (scope.projectId !== undefined && scope.projectId !== attribution.projectId) ||
154
+ (scope.sessionId !== undefined && scope.sessionId !== attribution.sessionId)
155
+ ) {
156
+ return { entries: [] }
157
+ }
158
+
159
+ const now = options?.now ?? Date.now()
160
+ const entries: DurableRunEntry[] = []
161
+
162
+ for (const runId of await this.readRunDirs(this.config.baseDir)) {
163
+ const runDir = join(this.config.baseDir, runId)
164
+
165
+ const own = toDurableRunEntry({ ...attribution, runId }, await readCheckpointsIn(runDir), now)
166
+ if (own) entries.push(own)
167
+
168
+ for (const childId of await this.readRunDirs(join(runDir, 'children'))) {
169
+ const child = toDurableRunEntry(
170
+ { ...attribution, runId: childId, parentRunId: runId },
171
+ await readCheckpointsIn(join(runDir, 'children', childId)),
172
+ now,
173
+ )
174
+ if (child) entries.push(child)
175
+ }
176
+ }
177
+
178
+ return paginateDurableRuns(entries, options)
179
+ }
180
+
181
+ /** Directory names under `dir`, or none when `dir` does not exist. */
182
+ private async readRunDirs(dir: string): Promise<RunId[]> {
183
+ try {
184
+ const found = await readdir(dir, { withFileTypes: true })
185
+ return found.filter((e) => e.isDirectory()).map((e) => e.name as RunId)
186
+ } catch (err) {
187
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
188
+ throw err
189
+ }
190
+ }
67
191
  }
@@ -0,0 +1,101 @@
1
+ import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
2
+ import type {
3
+ CheckpointListingScope,
4
+ CheckpointRunScope,
5
+ CheckpointStore,
6
+ DurableRunEntry,
7
+ DurableRunPage,
8
+ ListDurableRunsOptions,
9
+ } from '../../types/run/checkpoint-store.js'
10
+ import { assertContiguousListingScope, paginateDurableRuns, toDurableRunEntry } from './listing.js'
11
+
12
+ /**
13
+ * Process-local {@link CheckpointStore}, keyed by the full five-layer scope.
14
+ *
15
+ * Shipped rather than left as a test fixture for two reasons. It is the
16
+ * reference a host reads when writing a backend of its own — the disk store
17
+ * is path-addressed and answers "what does an attribution-keyed store look
18
+ * like" with a directory layout, which is the wrong lesson. And it is the
19
+ * only implementation that can hold more than one tenant at once, because
20
+ * the disk layout has no tenant in it: a test that two tenants' listings
21
+ * stay separate is not expressible against disk, and a rule that cannot be
22
+ * tested on the store a host will actually inject is a rule on paper.
23
+ *
24
+ * Not durable, deliberately: it is for tests, for a single-process host that
25
+ * genuinely wants checkpoints to die with the process, and as the parity
26
+ * partner that proves the listing contract is not a filesystem in disguise.
27
+ */
28
+ export class InMemoryCheckpointStore implements CheckpointStore {
29
+ /** `tenant/project/session/run` → checkpoint id → checkpoint. */
30
+ private readonly runs = new Map<string, Map<CheckpointId, IterationCheckpoint>>()
31
+ /** Same key → the run's scope, so a listing can rebuild an addressable entry. */
32
+ private readonly scopes = new Map<string, CheckpointRunScope>()
33
+
34
+ private key(scope: CheckpointRunScope): string {
35
+ return [scope.tenantId, scope.projectId, scope.sessionId, scope.runId].join('/')
36
+ }
37
+
38
+ async writeCheckpoint(scope: CheckpointRunScope, checkpoint: IterationCheckpoint): Promise<void> {
39
+ const key = this.key(scope)
40
+ let run = this.runs.get(key)
41
+ if (!run) {
42
+ run = new Map()
43
+ this.runs.set(key, run)
44
+ }
45
+ // The run's scope is kept beside its checkpoints because the key is a
46
+ // joined string and a listing has to hand back the parts — above all
47
+ // `parentRunId`, which is what makes a sub-run's row addressable.
48
+ //
49
+ // Written on every call rather than only the first, and that is
50
+ // simplicity, not defence: a run's scope is fixed when the run is
51
+ // constructed, so the two cannot differ, and a `has` guard here would
52
+ // be a branch no input can take.
53
+ this.scopes.set(key, {
54
+ tenantId: scope.tenantId,
55
+ projectId: scope.projectId,
56
+ sessionId: scope.sessionId,
57
+ runId: scope.runId,
58
+ ...(scope.parentRunId ? { parentRunId: scope.parentRunId } : {}),
59
+ })
60
+ run.set(checkpoint.id, checkpoint)
61
+ }
62
+
63
+ async readCheckpoint(
64
+ scope: CheckpointRunScope,
65
+ checkpointId: CheckpointId,
66
+ ): Promise<IterationCheckpoint | null> {
67
+ return this.runs.get(this.key(scope))?.get(checkpointId) ?? null
68
+ }
69
+
70
+ async listCheckpoints(scope: CheckpointRunScope): Promise<IterationCheckpoint[]> {
71
+ const run = this.runs.get(this.key(scope))
72
+ if (!run) return []
73
+ return [...run.values()].sort((a, b) => a.createdAt - b.createdAt)
74
+ }
75
+
76
+ async deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void> {
77
+ this.runs.get(this.key(scope))?.delete(checkpointId)
78
+ }
79
+
80
+ async listDurableRuns(
81
+ scope: CheckpointListingScope,
82
+ options?: ListDurableRunsOptions,
83
+ ): Promise<DurableRunPage> {
84
+ assertContiguousListingScope(scope, 'InMemoryCheckpointStore.listDurableRuns')
85
+ const now = options?.now ?? Date.now()
86
+
87
+ const entries: DurableRunEntry[] = []
88
+ for (const [key, checkpoints] of this.runs) {
89
+ const runScope = this.scopes.get(key)
90
+ if (!runScope) continue
91
+ if (runScope.tenantId !== scope.tenantId) continue
92
+ if (scope.projectId !== undefined && runScope.projectId !== scope.projectId) continue
93
+ if (scope.sessionId !== undefined && runScope.sessionId !== scope.sessionId) continue
94
+
95
+ const entry = toDurableRunEntry(runScope, [...checkpoints.values()], now)
96
+ if (entry) entries.push(entry)
97
+ }
98
+
99
+ return paginateDurableRuns(entries, options)
100
+ }
101
+ }
@@ -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
  }