@heixui/dsh-session-suite 0.0.0-stage → 0.3.1
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.
- package/LICENSE +21 -0
- package/README.md +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +683 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +76 -4
|
@@ -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 }
|