@heixui/dsh-session-suite 0.0.0-stage → 0.3.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.
@@ -0,0 +1,774 @@
1
+ /**
2
+ * Incremental, content-addressed workspace checkpoints.
3
+ *
4
+ * Design goals (mirrors Trae's session checkpoints, with the safety net the
5
+ * product discussion asked for):
6
+ *
7
+ * - one JSON manifest per checkpoint records only the *tracked* files and their
8
+ * content hashes; file bytes live once in a zstd/gzip-compressed blob store,
9
+ * so N checkpoints of the same tree cost roughly one tree plus the deltas;
10
+ * - captures reuse the previous manifest's `(size, mtimeMs)` fingerprint to skip
11
+ * re-reading unchanged files;
12
+ * - a session keeps at most `keep` automatic checkpoints; locked checkpoints and
13
+ * temporary undo bundles live outside that budget;
14
+ * - restore only deletes files that this session's own history created, never a
15
+ * file the workspace had before the session started.
16
+ */
17
+
18
+ import fs from 'node:fs/promises'
19
+ import path from 'node:path'
20
+ import zlib from 'node:zlib'
21
+ import { promisify } from 'node:util'
22
+ import {
23
+ DEFAULT_IGNORES,
24
+ createIgnoreMatcher,
25
+ ensureDir,
26
+ formatBytes,
27
+ looksBinary,
28
+ mapLimit,
29
+ readJson,
30
+ removeDir,
31
+ sha256,
32
+ shortId,
33
+ statOrUndefined,
34
+ toPosix,
35
+ writeJson,
36
+ } from './util.js'
37
+
38
+ const gzip = promisify(zlib.gzip)
39
+ const gunzip = promisify(zlib.gunzip)
40
+
41
+ const DEFAULTS = {
42
+ /** Automatic checkpoints retained per session. */
43
+ keep: 10,
44
+ /** Files larger than this are never recorded. */
45
+ maxFileBytes: 2 * 1024 * 1024,
46
+ /** Hard stop so a runaway tree cannot stall a turn. */
47
+ maxFiles: 4000,
48
+ /** Serialize captures per session; readers still see the last finished one. */
49
+ captureConcurrency: 8,
50
+ /** Undo bundles expire after this long. */
51
+ undoTtlMs: 10 * 60 * 1000,
52
+ }
53
+
54
+ export class CheckpointStore {
55
+ /**
56
+ * @param {object} options store options
57
+ * @param {string} options.root suite data directory
58
+ * @param {(message: string, error?: unknown) => void} [options.log] diagnostic sink
59
+ * @param {readonly string[]} [options.ignores] extra ignore patterns
60
+ */
61
+ constructor({ root, log = () => {}, ignores = [] }) {
62
+ this.root = root
63
+ this.log = log
64
+ this.ignores = [...DEFAULT_IGNORES, ...ignores]
65
+ /** @type {Map<string, Promise<unknown>>} one capture at a time per session */
66
+ this.locks = new Map()
67
+ }
68
+
69
+ /** @param {string} sessionId session */
70
+ sessionDir(sessionId) {
71
+ return path.join(this.root, 'sessions', sessionId)
72
+ }
73
+
74
+ /** @param {string} sessionId session */
75
+ indexPath(sessionId) {
76
+ return path.join(this.sessionDir(sessionId), 'index.json')
77
+ }
78
+
79
+ /** @param {string} sessionId session */
80
+ blobDir(sessionId) {
81
+ return path.join(this.sessionDir(sessionId), 'blobs')
82
+ }
83
+
84
+ /** @param {string} sessionId session */
85
+ manifestDir(sessionId) {
86
+ return path.join(this.sessionDir(sessionId), 'manifests')
87
+ }
88
+
89
+ /** @param {string} sessionId session */
90
+ async readIndex(sessionId) {
91
+ const index = await readJson(this.indexPath(sessionId))
92
+ if (!index || typeof index !== 'object' || !Array.isArray(index.checkpoints)) {
93
+ return { version: 1, cwd: undefined, checkpoints: [] }
94
+ }
95
+ return index
96
+ }
97
+
98
+ /** @param {string} sessionId session @param {any} index index */
99
+ async writeIndex(sessionId, index) {
100
+ await writeJson(this.indexPath(sessionId), index)
101
+ }
102
+
103
+ /**
104
+ * Snapshot the working tree.
105
+ *
106
+ * @param {object} input capture input
107
+ * @param {string} input.sessionId session that owns the checkpoint
108
+ * @param {string} input.cwd workspace root
109
+ * @param {number} [input.seq] durable session sequence this state belongs to
110
+ * @param {number} [input.turn] turn number the state was observed at
111
+ * @param {'auto' | 'manual' | 'undo' | 'pre-rollback'} [input.kind] checkpoint kind
112
+ * @param {string} [input.label] human label
113
+ * @param {number} [input.ttlMs] expiry for temporary kinds
114
+ * @param {{ keep?: number, maxFileBytes?: number, maxFiles?: number }} [input.limits] overrides
115
+ * @returns {Promise<{ checkpoint: any, skipped: string[], truncated: boolean, newBytes: number }>}
116
+ */
117
+ async capture(input) {
118
+ const sessionId = input.sessionId
119
+ const previous = this.locks.get(sessionId) ?? Promise.resolve()
120
+ const run = previous
121
+ .catch(() => {})
122
+ .then(() => this.#capture(input))
123
+ this.locks.set(sessionId, run)
124
+ try {
125
+ return await run
126
+ } finally {
127
+ if (this.locks.get(sessionId) === run) this.locks.delete(sessionId)
128
+ }
129
+ }
130
+
131
+ async #capture(input) {
132
+ const limits = {
133
+ keep: input.limits?.keep ?? DEFAULTS.keep,
134
+ maxFileBytes: input.limits?.maxFileBytes ?? DEFAULTS.maxFileBytes,
135
+ maxFiles: input.limits?.maxFiles ?? DEFAULTS.maxFiles,
136
+ }
137
+ const cwd = path.resolve(input.cwd)
138
+ const kind = input.kind ?? 'auto'
139
+ const index = await this.readIndex(input.sessionId)
140
+ const isTemporary = kind === 'undo' || kind === 'pre-rollback'
141
+
142
+ /** @type {Map<string, { size: number, mtimeMs: number }>} */
143
+ const fingerprints = new Map()
144
+ const previousManifest = await this.#latestManifest(input.sessionId, index, kind)
145
+ if (previousManifest) {
146
+ for (const [rel, entry] of Object.entries(previousManifest.entries)) {
147
+ if (typeof entry.s === 'number' && typeof entry.m === 'number') {
148
+ fingerprints.set(rel, { size: entry.s, mtimeMs: entry.m })
149
+ }
150
+ }
151
+ }
152
+
153
+ const matcher = createIgnoreMatcher(this.ignores)
154
+ const files = []
155
+ const skipped = []
156
+ let truncated = false
157
+ await walkWorkspace(cwd, cwd, matcher, files, skipped, limits, () => {
158
+ truncated = true
159
+ })
160
+
161
+ const blobs = this.blobDir(input.sessionId)
162
+ const safeCwd = cwd
163
+ let newBytes = 0
164
+ /**
165
+ * Files touched this recently may share an mtime tick with a rewrite of the
166
+ * same size (the classic "racily clean" case), so their fingerprint is not
167
+ * trusted and they are always re-read.
168
+ */
169
+ const racyBefore = Date.now() - 2000
170
+ /** @type {Record<string, { h: string, s: number, m: number }>} */
171
+ const entries = {}
172
+
173
+ await mapLimit(files, DEFAULTS.captureConcurrency, async file => {
174
+ const rel = toPosix(path.relative(safeCwd, file.abs))
175
+ const known = fingerprints.get(rel)
176
+ const trusted = known && file.mtimeMs < racyBefore
177
+ if (trusted && known.size === file.size && Math.abs(known.mtimeMs - file.mtimeMs) < 1) {
178
+ const hash = await this.#hashFromPrevious(input.sessionId, index, rel)
179
+ if (hash) {
180
+ entries[rel] = { h: hash, s: file.size, m: Math.round(file.mtimeMs) }
181
+ return
182
+ }
183
+ }
184
+ let buffer
185
+ try {
186
+ buffer = await fs.readFile(file.abs)
187
+ } catch (error) {
188
+ skipped.push(`${rel} (unreadable: ${error instanceof Error ? error.message : String(error)})`)
189
+ return
190
+ }
191
+ if (looksBinary(buffer.subarray(0, 8192))) {
192
+ skipped.push(`${rel} (binary)`)
193
+ return
194
+ }
195
+ const hash = sha256(buffer)
196
+ const target = this.#blobPath(blobs, hash)
197
+ const existing = await statOrUndefined(target)
198
+ if (!existing) {
199
+ await ensureDir(path.dirname(target))
200
+ const compressed = await gzip(buffer, { level: 6 })
201
+ await fs.writeFile(target, compressed)
202
+ newBytes += compressed.byteLength
203
+ }
204
+ entries[rel] = { h: hash, s: buffer.byteLength, m: Math.round(file.mtimeMs) }
205
+ })
206
+
207
+ const sorted = {}
208
+ for (const key of Object.keys(entries).sort()) sorted[key] = entries[key]
209
+
210
+ const id = `cp_${Date.now().toString(36)}_${shortId(`${input.sessionId}:${input.seq ?? ''}`)}`
211
+ const manifestFile = `${id}.json`
212
+ await ensureDir(this.manifestDir(input.sessionId))
213
+ await writeJson(path.join(this.manifestDir(input.sessionId), manifestFile), {
214
+ version: 1,
215
+ cwd,
216
+ createdAt: Date.now(),
217
+ entries: sorted,
218
+ })
219
+
220
+ const totalBytes = Object.values(sorted).reduce((sum, entry) => sum + (entry.s ?? 0), 0)
221
+ const record = {
222
+ id,
223
+ sessionId: input.sessionId,
224
+ seq: input.seq ?? null,
225
+ turn: input.turn ?? null,
226
+ /**
227
+ * What the snapshot describes: `end` is the workspace after the turn
228
+ * finished (the default), `start` is the workspace before it began (the
229
+ * older format, kept readable for snapshots taken before this change).
230
+ */
231
+ phase: input.phase ?? 'end',
232
+ kind,
233
+ label: input.label ?? defaultLabel(kind),
234
+ createdAt: Date.now(),
235
+ expiresAt: isTemporary ? Date.now() + (input.ttlMs ?? DEFAULTS.undoTtlMs) : null,
236
+ locked: false,
237
+ cwd,
238
+ manifest: manifestFile,
239
+ files: Object.keys(sorted).length,
240
+ bytes: newBytes,
241
+ totalBytes,
242
+ skipped: skipped.length,
243
+ }
244
+
245
+ index.cwd = cwd
246
+ // A temporary bundle never replaces an automatic checkpoint position.
247
+ index.checkpoints = [...index.checkpoints.filter(item => item.id !== id), record]
248
+ await this.#prune(input.sessionId, index, limits.keep)
249
+ await this.writeIndex(input.sessionId, index)
250
+
251
+ this.log(
252
+ `captured ${kind} checkpoint ${id} (${record.files} files, +${formatBytes(newBytes)}) for ${input.sessionId}`,
253
+ )
254
+ return { checkpoint: record, skipped, truncated, newBytes }
255
+ }
256
+
257
+ /**
258
+ * Restore the working tree to one checkpoint.
259
+ *
260
+ * @param {object} input restore input
261
+ * @param {string} input.sessionId session
262
+ * @param {string} input.checkpointId checkpoint to restore
263
+ * @returns {Promise<{ written: number, deleted: number, unchanged: number, bytes: number, cwd: string }>}
264
+ */
265
+ async restore(input) {
266
+ const index = await this.readIndex(input.sessionId)
267
+ const record = index.checkpoints.find(item => item.id === input.checkpointId)
268
+ if (!record) throw new Error(`checkpoint ${input.checkpointId} no longer exists`)
269
+ const manifest = await readJson(path.join(this.manifestDir(input.sessionId), record.manifest))
270
+ if (!manifest || typeof manifest.entries !== 'object') {
271
+ throw new Error(`checkpoint ${input.checkpointId} is missing its manifest`)
272
+ }
273
+
274
+ const cwd = path.resolve(record.cwd ?? index.cwd ?? '')
275
+ const blobs = this.blobDir(input.sessionId)
276
+ const target = manifest.entries
277
+ let written = 0
278
+ let unchanged = 0
279
+ let bytes = 0
280
+
281
+ const rels = Object.keys(target)
282
+ await mapLimit(rels, DEFAULTS.captureConcurrency, async rel => {
283
+ const entry = target[rel]
284
+ const abs = path.join(cwd, rel)
285
+ const current = await statOrUndefined(abs)
286
+ if (current && current.isFile() && current.size === entry.s) {
287
+ const currentBytes = await fs.readFile(abs).catch(() => undefined)
288
+ if (currentBytes && sha256(currentBytes) === entry.h) {
289
+ unchanged += 1
290
+ return
291
+ }
292
+ }
293
+ const blob = await this.#readBlob(blobs, entry.h)
294
+ if (!blob) throw new Error(`blob for ${rel} is missing; cannot restore a consistent tree`)
295
+ await ensureDir(path.dirname(abs))
296
+ await fs.writeFile(abs, blob)
297
+ written += 1
298
+ bytes += blob.byteLength
299
+ })
300
+
301
+ // Delete files this session created after the target checkpoint, and only
302
+ // those: anything the manifest lists is kept, and unknown files stay.
303
+ const later = new Set()
304
+ for (const item of index.checkpoints) {
305
+ if (item.id === record.id) continue
306
+ if (item.createdAt <= record.createdAt) continue
307
+ const other = await readJson(path.join(this.manifestDir(input.sessionId), item.manifest)).catch(() => undefined)
308
+ if (!other || typeof other.entries !== 'object') continue
309
+ for (const rel of Object.keys(other.entries)) later.add(rel)
310
+ }
311
+ let deleted = 0
312
+ for (const rel of later) {
313
+ if (Object.prototype.hasOwnProperty.call(target, rel)) continue
314
+ const abs = path.join(cwd, rel)
315
+ const info = await statOrUndefined(abs)
316
+ if (!info || !info.isFile()) continue
317
+ await fs.rm(abs, { force: true }).catch(() => {})
318
+ deleted += 1
319
+ }
320
+
321
+ return { written, deleted, unchanged, bytes, cwd }
322
+ }
323
+
324
+ /**
325
+ * Delete one checkpoint (locked checkpoints refuse).
326
+ *
327
+ * @param {string} sessionId session
328
+ * @param {string} checkpointId checkpoint
329
+ * @returns {Promise<{ deleted: boolean, reason?: string }>} outcome
330
+ */
331
+ async removeCheckpoint(sessionId, checkpointId) {
332
+ const index = await this.readIndex(sessionId)
333
+ const record = index.checkpoints.find(item => item.id === checkpointId)
334
+ if (!record) return { deleted: false, reason: 'not-found' }
335
+ if (record.locked) return { deleted: false, reason: 'locked' }
336
+ index.checkpoints = index.checkpoints.filter(item => item.id !== checkpointId)
337
+ await this.writeIndex(sessionId, index)
338
+ await fs.rm(path.join(this.manifestDir(sessionId), record.manifest), { force: true }).catch(() => {})
339
+ const freed = await this.#collectGarbage(sessionId, index)
340
+ return { deleted: true, freed }
341
+ }
342
+
343
+ /**
344
+ * @param {string} sessionId session
345
+ * @param {string} checkpointId checkpoint
346
+ * @param {boolean} locked new lock state
347
+ */
348
+ async setLocked(sessionId, checkpointId, locked) {
349
+ const index = await this.readIndex(sessionId)
350
+ const record = index.checkpoints.find(item => item.id === checkpointId)
351
+ if (!record) throw new Error(`checkpoint ${checkpointId} no longer exists`)
352
+ record.locked = locked === true
353
+ await this.writeIndex(sessionId, index)
354
+ return record
355
+ }
356
+
357
+ /**
358
+ * @param {string} sessionId session
359
+ * @returns {Promise<any[]>} checkpoints, newest first
360
+ */
361
+ async list(sessionId) {
362
+ const index = await this.readIndex(sessionId)
363
+ const now = Date.now()
364
+ return index.checkpoints
365
+ .filter(item => !item.expiresAt || item.expiresAt > now)
366
+ .map(item => ({
367
+ ...item,
368
+ effectiveTurn:
369
+ typeof item.turn !== 'number'
370
+ ? item.turn
371
+ : item.phase === 'end'
372
+ ? item.turn
373
+ : item.turn - 1,
374
+ }))
375
+ .sort((a, b) => b.createdAt - a.createdAt)
376
+ }
377
+
378
+ /**
379
+ * Newest checkpoint that describes the tree at (or just after) a turn.
380
+ *
381
+ * @param {string} sessionId session
382
+ * @param {number | null} turn target turn
383
+ * @param {number | null} seq target sequence
384
+ * @returns {Promise<any | undefined>} best checkpoint
385
+ */
386
+ async checkpointAt(sessionId, turn, seq) {
387
+ const list = (await this.list(sessionId)).filter(item => item.kind !== 'undo' && item.kind !== 'pre-rollback')
388
+ if (list.length === 0) return undefined
389
+ if (typeof turn === 'number') {
390
+ // `effectiveTurn` is the turn whose completion this snapshot represents:
391
+ // snapshots taken after a turn map to that turn, and older snapshots taken
392
+ // before one map to the turn that just finished.
393
+ const before = list.filter(item => typeof item.effectiveTurn === 'number' && item.effectiveTurn <= turn)
394
+ if (before.length > 0) {
395
+ return before.sort((a, b) => (b.effectiveTurn ?? 0) - (a.effectiveTurn ?? 0) || b.createdAt - a.createdAt)[0]
396
+ }
397
+ }
398
+ if (typeof seq === 'number') {
399
+ const bySeq = list.filter(item => typeof item.seq === 'number' && item.seq <= seq)
400
+ if (bySeq.length > 0) return bySeq.sort((a, b) => b.createdAt - a.createdAt)[0]
401
+ }
402
+ return list.sort((a, b) => a.createdAt - b.createdAt)[0]
403
+ }
404
+
405
+ /**
406
+ * Total disk usage of a session's snapshots.
407
+ *
408
+ * @param {string} sessionId session
409
+ */
410
+ async stats(sessionId) {
411
+ const index = await this.readIndex(sessionId)
412
+ const blobs = await dirSize(this.blobDir(sessionId))
413
+ const manifests = await dirSize(this.manifestDir(sessionId))
414
+ return {
415
+ checkpoints: index.checkpoints.length,
416
+ bytes: blobs + manifests,
417
+ blobBytes: blobs,
418
+ manifestBytes: manifests,
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Drop every snapshot of a session (permanent delete).
424
+ *
425
+ * @param {string} sessionId session
426
+ */
427
+ async purgeSession(sessionId) {
428
+ await removeDir(this.sessionDir(sessionId))
429
+ }
430
+
431
+ /**
432
+ * Give a forked session the snapshots its parent had.
433
+ *
434
+ * Deleting a turn or rewinding both work by forking the event prefix and
435
+ * retiring the original, so the child inherits the parent's conversation. Its
436
+ * snapshots are stored per session id, though, so without this the child
437
+ * starts with no history at all: the panel is empty, and rewinding inside the
438
+ * continuation has no earlier state to restore. The parent's snapshots still
439
+ * describe the same workspace and the same turns, so they carry over
440
+ * unchanged — only the owning session id differs.
441
+ *
442
+ * Blobs are shared by hard link rather than copied. They are content
443
+ * addressed and immutable, so linking costs no disk; a copy would multiply
444
+ * every snapshot's bytes by the number of forks. Falls back to copying when
445
+ * linking fails (for example across volumes), which is correct if wasteful.
446
+ *
447
+ * @param {object} input inherit input
448
+ * @param {string} input.from parent session
449
+ * @param {string} input.to forked session
450
+ * @param {number} [input.throughSeq] keep only snapshots at or before this seq
451
+ * @param {number} [input.keep] how many of the newest snapshots to carry
452
+ * @returns {Promise<{ inherited: number, skipped: number, linked: number, copied: number }>}
453
+ */
454
+ async inherit({ from, to, throughSeq, keep }) {
455
+ const result = { inherited: 0, skipped: 0, linked: 0, copied: 0 }
456
+ if (!from || !to || from === to) return result
457
+ const source = await this.readIndex(from)
458
+ if (source.checkpoints.length === 0) return result
459
+
460
+ const budget = Number.isFinite(keep) ? Math.max(1, keep) : DEFAULTS.keep
461
+ const eligible = source.checkpoints
462
+ .filter(record => {
463
+ if (record.kind === 'undo' || record.kind === 'pre-rollback') return false
464
+ if (Number.isFinite(throughSeq) && typeof record.seq === 'number' && record.seq > throughSeq) return false
465
+ return true
466
+ })
467
+ .sort((a, b) => b.createdAt - a.createdAt)
468
+ .slice(0, budget)
469
+ .reverse()
470
+ result.skipped += source.checkpoints.length - eligible.length
471
+
472
+ const target = await this.readIndex(to)
473
+ const sourceManifests = this.manifestDir(from)
474
+ const targetManifests = this.manifestDir(to)
475
+ const sourceBlobs = this.blobDir(from)
476
+ const targetBlobs = this.blobDir(to)
477
+ await ensureDir(targetManifests)
478
+
479
+ const carried = []
480
+ for (const record of eligible) {
481
+ // A snapshot taken later than the fork point describes a workspace the
482
+ // child never had; carrying it would let the child restore files from a
483
+ // future it did not inherit.
484
+ if (Number.isFinite(throughSeq) && typeof record.seq === 'number' && record.seq > throughSeq) {
485
+ result.skipped += 1
486
+ continue
487
+ }
488
+ const manifest = await readJson(path.join(sourceManifests, record.manifest)).catch(() => undefined)
489
+ if (!manifest || typeof manifest.entries !== 'object') {
490
+ result.skipped += 1
491
+ continue
492
+ }
493
+
494
+ const id = `cp_${Date.now().toString(36)}_${shortId(`${to}:${record.seq ?? ''}:${record.id}`)}`
495
+ const manifestFile = `${id}.json`
496
+ await writeJson(path.join(targetManifests, manifestFile), { ...manifest, sessionId: to })
497
+
498
+ for (const entry of Object.values(manifest.entries)) {
499
+ if (!entry || typeof entry.h !== 'string') continue
500
+ const sourceBlob = this.#blobPath(sourceBlobs, entry.h)
501
+ const targetBlob = this.#blobPath(targetBlobs, entry.h)
502
+ const existing = await statOrUndefined(targetBlob)
503
+ if (existing) continue
504
+ await ensureDir(path.dirname(targetBlob))
505
+ try {
506
+ await fs.link(sourceBlob, targetBlob)
507
+ result.linked += 1
508
+ } catch {
509
+ try {
510
+ await fs.copyFile(sourceBlob, targetBlob)
511
+ result.copied += 1
512
+ } catch {
513
+ // A missing blob means this checkpoint cannot be restored; drop the
514
+ // record rather than inherit a snapshot that would fail later.
515
+ await fs.rm(path.join(targetManifests, manifestFile), { force: true }).catch(() => {})
516
+ result.skipped += 1
517
+ break
518
+ }
519
+ }
520
+ }
521
+
522
+ const inheritedRecord = {
523
+ ...record,
524
+ id,
525
+ sessionId: to,
526
+ manifest: manifestFile,
527
+ // Shared blobs must not be freed by the child's own garbage collection
528
+ // before the parent is done with them, so inherited snapshots are
529
+ // marked and excluded from pruning.
530
+ inheritedFrom: from,
531
+ }
532
+ carried.push(inheritedRecord)
533
+ }
534
+
535
+ if (carried.length > 0) {
536
+ target.cwd = target.cwd ?? source.cwd
537
+ target.checkpoints = [...target.checkpoints, ...carried]
538
+ await this.writeIndex(to, target)
539
+ result.inherited = carried.length
540
+ this.log(`inherited ${carried.length} checkpoint(s) from ${from} into ${to}`)
541
+ }
542
+ return result
543
+ }
544
+
545
+ /**
546
+ * Remove expired undo bundles and enforce the per-session budget.
547
+ *
548
+ * @param {string} [sessionId] limit pruning to one session
549
+ */
550
+ async sweep(sessionId) {
551
+ const sessions = sessionId ? [sessionId] : await this.#sessionIds()
552
+ for (const id of sessions) {
553
+ try {
554
+ const index = await this.readIndex(id)
555
+ await this.#prune(id, index, DEFAULTS.keep)
556
+ await this.writeIndex(id, index)
557
+ } catch (error) {
558
+ this.log(`sweep failed for ${id}`, error)
559
+ }
560
+ }
561
+ }
562
+
563
+ async #prune(sessionId, index, keep) {
564
+ const now = Date.now()
565
+ let changed = false
566
+ const expired = index.checkpoints.filter(item => item.expiresAt && item.expiresAt <= now)
567
+ if (expired.length > 0) {
568
+ index.checkpoints = index.checkpoints.filter(item => !expired.some(gone => gone.id === item.id))
569
+ for (const item of expired) {
570
+ await fs.rm(path.join(this.manifestDir(sessionId), item.manifest), { force: true }).catch(() => {})
571
+ }
572
+ changed = true
573
+ }
574
+ const budgeted = index.checkpoints
575
+ .filter(item => item.kind === 'auto' || item.kind === 'manual')
576
+ .sort((a, b) => a.createdAt - b.createdAt)
577
+ // Inherited snapshots do not count against the child's own budget. They are
578
+ // a fixed, already-bounded slice of the parent's history (inherit applies
579
+ // the same `keep`), and their blobs are hard links into the parent's store.
580
+ // Counting them here would make a fork with a full inheritance delete every
581
+ // snapshot it takes itself, leaving the conversation unable to snapshot the
582
+ // turns the user is actually writing.
583
+ const own = budgeted.filter(item => !item.inheritedFrom)
584
+ const prunable = own.filter(item => !item.locked && item.kind === 'auto')
585
+ const excess = own.length - Math.max(1, keep)
586
+ if (excess > 0 && prunable.length > 0) {
587
+ const drop = new Set(prunable.slice(0, Math.min(excess, prunable.length)).map(item => item.id))
588
+ index.checkpoints = index.checkpoints.filter(item => !drop.has(item.id))
589
+ for (const id of drop) {
590
+ const record = budgeted.find(item => item.id === id)
591
+ if (record) await fs.rm(path.join(this.manifestDir(sessionId), record.manifest), { force: true }).catch(() => {})
592
+ }
593
+ changed = true
594
+ }
595
+ if (changed) await this.#collectGarbage(sessionId, index)
596
+ }
597
+
598
+ async #collectGarbage(sessionId, index) {
599
+ const referenced = new Set()
600
+ for (const item of index.checkpoints) {
601
+ const manifest = await readJson(path.join(this.manifestDir(sessionId), item.manifest)).catch(() => undefined)
602
+ if (!manifest || typeof manifest.entries !== 'object') continue
603
+ for (const entry of Object.values(manifest.entries)) {
604
+ if (entry && typeof entry.h === 'string') referenced.add(entry.h)
605
+ }
606
+ }
607
+ let freed = 0
608
+ const blobs = this.blobDir(sessionId)
609
+ for (const hash of await listFiles(blobs)) {
610
+ if (referenced.has(hash)) continue
611
+ const abs = this.#blobPath(blobs, hash)
612
+ const info = await statOrUndefined(abs)
613
+ if (info) freed += info.size
614
+ await fs.rm(abs, { force: true }).catch(() => {})
615
+ }
616
+ return freed
617
+ }
618
+
619
+ async #hashFromPrevious(sessionId, index, rel) {
620
+ const candidates = await this.list(sessionId)
621
+ for (const item of candidates) {
622
+ const manifest = await readJson(path.join(this.manifestDir(sessionId), item.manifest)).catch(() => undefined)
623
+ const entry = manifest?.entries?.[rel]
624
+ if (entry && typeof entry.h === 'string') return entry.h
625
+ }
626
+ return undefined
627
+ }
628
+
629
+ async #latestManifest(sessionId, index, kind) {
630
+ const candidates = index.checkpoints
631
+ .filter(item => !item.expiresAt || item.expiresAt > Date.now())
632
+ .sort((a, b) => b.createdAt - a.createdAt)
633
+ for (const item of candidates) {
634
+ if (kind === 'undo' && item.kind !== 'auto' && item.kind !== 'manual') continue
635
+ const manifest = await readJson(path.join(this.manifestDir(sessionId), item.manifest)).catch(() => undefined)
636
+ if (manifest && typeof manifest.entries === 'object') return manifest
637
+ }
638
+ return undefined
639
+ }
640
+
641
+ #blobPath(blobs, hash) {
642
+ return path.join(blobs, hash.slice(0, 2), hash)
643
+ }
644
+
645
+ async #readBlob(blobs, hash) {
646
+ const file = this.#blobPath(blobs, hash)
647
+ try {
648
+ const compressed = await fs.readFile(file)
649
+ return await gunzip(compressed)
650
+ } catch {
651
+ return undefined
652
+ }
653
+ }
654
+
655
+ async #sessionIds() {
656
+ try {
657
+ const entries = await fs.readdir(path.join(this.root, 'sessions'), { withFileTypes: true })
658
+ return entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
659
+ } catch {
660
+ return []
661
+ }
662
+ }
663
+ }
664
+
665
+ /**
666
+ * @param {string} kind checkpoint kind
667
+ * @returns {string} default label
668
+ */
669
+ function defaultLabel(kind) {
670
+ switch (kind) {
671
+ case 'manual':
672
+ return 'manual checkpoint'
673
+ case 'undo':
674
+ return 'undo bundle'
675
+ case 'pre-rollback':
676
+ return 'before rollback'
677
+ default:
678
+ return 'turn checkpoint'
679
+ }
680
+ }
681
+
682
+ /**
683
+ * @param {string} root workspace root
684
+ * @param {string} dir directory being walked
685
+ * @param {(rel: string, name: string) => boolean} matcher ignore matcher
686
+ * @param {{ abs: string, size: number, mtimeMs: number }[]} files collected files
687
+ * @param {string[]} skipped skipped files
688
+ * @param {{ maxFileBytes: number, maxFiles: number }} limits limits
689
+ * @param {() => void} onTruncate truncation signal
690
+ */
691
+ async function walkWorkspace(root, dir, matcher, files, skipped, limits, onTruncate) {
692
+ if (files.length >= limits.maxFiles) {
693
+ onTruncate()
694
+ return
695
+ }
696
+ let entries
697
+ try {
698
+ entries = await fs.readdir(dir, { withFileTypes: true })
699
+ } catch {
700
+ return
701
+ }
702
+ for (const entry of entries) {
703
+ const abs = path.join(dir, entry.name)
704
+ const rel = toPosix(path.relative(root, abs))
705
+ if (matcher(rel, entry.name)) continue
706
+ if (entry.isDirectory()) {
707
+ await walkWorkspace(root, abs, matcher, files, skipped, limits, onTruncate)
708
+ if (files.length >= limits.maxFiles) {
709
+ onTruncate()
710
+ return
711
+ }
712
+ continue
713
+ }
714
+ if (!entry.isFile()) continue
715
+ let info
716
+ try {
717
+ info = await fs.stat(abs)
718
+ } catch {
719
+ continue
720
+ }
721
+ if (info.size > limits.maxFileBytes) {
722
+ skipped.push(`${rel} (too large: ${formatBytes(info.size)})`)
723
+ continue
724
+ }
725
+ files.push({ abs, size: info.size, mtimeMs: info.mtimeMs })
726
+ if (files.length >= limits.maxFiles) {
727
+ onTruncate()
728
+ return
729
+ }
730
+ }
731
+ }
732
+
733
+ /** @param {string} dir directory @returns {Promise<number>} total bytes */
734
+ async function dirSize(dir) {
735
+ let total = 0
736
+ for (const rel of await listFilesRecursive(dir)) {
737
+ const info = await statOrUndefined(path.join(dir, rel)).catch(() => undefined)
738
+ if (info) total += info.size
739
+ }
740
+ return total
741
+ }
742
+
743
+ /** @param {string} dir directory @returns {Promise<string[]>} file names */
744
+ async function listFiles(dir) {
745
+ try {
746
+ const entries = await fs.readdir(dir, { withFileTypes: true })
747
+ return entries.filter(entry => entry.isFile()).map(entry => entry.name)
748
+ } catch {
749
+ return []
750
+ }
751
+ }
752
+
753
+ /** @param {string} dir directory @returns {Promise<string[]>} recursive relative paths */
754
+ async function listFilesRecursive(dir) {
755
+ const out = []
756
+ let entries
757
+ try {
758
+ entries = await fs.readdir(dir, { withFileTypes: true })
759
+ } catch {
760
+ return out
761
+ }
762
+ for (const entry of entries) {
763
+ if (entry.isDirectory()) {
764
+ for (const child of await listFilesRecursive(path.join(dir, entry.name))) {
765
+ out.push(`${entry.name}/${child}`)
766
+ }
767
+ } else if (entry.isFile()) {
768
+ out.push(entry.name)
769
+ }
770
+ }
771
+ return out
772
+ }
773
+
774
+ export { DEFAULTS as CHECKPOINT_DEFAULTS }