dsh-turn-undo 0.0.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 +119 -0
- package/client.js +470 -0
- package/cordis.patch.yml +9 -0
- package/index.js +1083 -0
- package/package.json +42 -0
package/index.js
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-turn-undo — server half of the turn-undo plugin.
|
|
3
|
+
*
|
|
4
|
+
* Core mechanisms (content-addressed turn snapshots):
|
|
5
|
+
* 1. Listen to `session/event` for `turn/end` (reliable, covers every file
|
|
6
|
+
* write including bash shell commands — no tool-argument parsing, which
|
|
7
|
+
* is impossible for bash and was rejected in design).
|
|
8
|
+
* 2. On turn/end, queue an async workspace snapshot (content-addressed by
|
|
9
|
+
* sha256, hardlink-reusing unchanged files) into $DSH_HOME/turn-undo/.
|
|
10
|
+
* 3. Restore = fetch a target turn's manifest, align the workspace to it
|
|
11
|
+
* (write back + delete extra files), skipping excluded dirs.
|
|
12
|
+
* 4. Rewind in place via session.surface replacement (surfaceOp.replace): drop
|
|
13
|
+
* every surface node from the target user message onwards — no fork, no new
|
|
14
|
+
* session, and no cancel/rename (both removed in DSH 0.1.2+).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
readFileSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
copyFileSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
statSync,
|
|
27
|
+
lstatSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
readlinkSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
linkSync,
|
|
32
|
+
createWriteStream,
|
|
33
|
+
createReadStream,
|
|
34
|
+
} from 'node:fs'
|
|
35
|
+
import { createHash } from 'node:crypto'
|
|
36
|
+
import { join, dirname, basename, relative, resolve } from 'node:path'
|
|
37
|
+
|
|
38
|
+
export const name = 'turn-undo'
|
|
39
|
+
|
|
40
|
+
// Exported for integration testing (class is otherwise module-private).
|
|
41
|
+
export { SnapshotStore }
|
|
42
|
+
|
|
43
|
+
// Injected services (via ctx.inject in apply()): webServer, sessions,
|
|
44
|
+
// sessionQuery, agents, sessionController.
|
|
45
|
+
export const inject = ['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController']
|
|
46
|
+
|
|
47
|
+
// ---- Configuration defaults ----
|
|
48
|
+
const DEFAULT_SNAPSHOT_TTL_DAYS = 7
|
|
49
|
+
const DEFAULT_MAX_SNAPSHOTS_PER_SESSION = 50
|
|
50
|
+
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024 // 10 MB
|
|
51
|
+
const DEFAULT_MAX_FILES_PER_SNAPSHOT = 10000
|
|
52
|
+
const DEFAULT_MAX_SNAPSHOT_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
53
|
+
const DEFAULT_SNAPSHOT_DELAY_MS = 250
|
|
54
|
+
const DEFAULT_EXCLUDES = [
|
|
55
|
+
'node_modules/',
|
|
56
|
+
'.git/',
|
|
57
|
+
'.venv/',
|
|
58
|
+
'venv/',
|
|
59
|
+
'__pycache__/',
|
|
60
|
+
'target/',
|
|
61
|
+
'dist/',
|
|
62
|
+
'build/',
|
|
63
|
+
'.next/',
|
|
64
|
+
'.turbo/',
|
|
65
|
+
'.gradle/',
|
|
66
|
+
'.idea/',
|
|
67
|
+
'.vscode/',
|
|
68
|
+
'coverage/',
|
|
69
|
+
'.DS_Store',
|
|
70
|
+
'*.log',
|
|
71
|
+
]
|
|
72
|
+
const API_PATH = '/api/turn-undo'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the base directory for turn-undo storage.
|
|
76
|
+
*/
|
|
77
|
+
function getBaseDir(_ctx) {
|
|
78
|
+
const dshHome = process.env.DSH_HOME || join(process.env.HOME || '', '.dsh')
|
|
79
|
+
return join(dshHome, 'turn-undo')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureDir(dir) {
|
|
83
|
+
if (!existsSync(dir)) {
|
|
84
|
+
mkdirSync(dir, { recursive: true })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJsonFile(filePath) {
|
|
89
|
+
if (!existsSync(filePath)) return null
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'))
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeJsonFile(filePath, data) {
|
|
98
|
+
const dir = dirname(filePath)
|
|
99
|
+
ensureDir(dir)
|
|
100
|
+
const tmpFile = filePath + '.tmp'
|
|
101
|
+
writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8')
|
|
102
|
+
try {
|
|
103
|
+
renameSync(tmpFile, filePath)
|
|
104
|
+
} catch {
|
|
105
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
|
|
106
|
+
if (existsSync(tmpFile)) unlinkSync(tmpFile)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function json(res, status, value) {
|
|
111
|
+
res.writeHead(status, {
|
|
112
|
+
'content-type': 'application/json; charset=utf-8',
|
|
113
|
+
'cache-control': 'no-store',
|
|
114
|
+
})
|
|
115
|
+
res.end(JSON.stringify(value) + '\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getCwd(source) {
|
|
119
|
+
return source?.header?.cwd
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readSession(ctx, sessionId) {
|
|
123
|
+
const live = ctx.sessions?.get?.(sessionId)
|
|
124
|
+
if (live) {
|
|
125
|
+
// Live Session objects expose snapshotEvents() rather than a public events array.
|
|
126
|
+
return {
|
|
127
|
+
id: sessionId,
|
|
128
|
+
header: live.header || {},
|
|
129
|
+
events: typeof live.snapshotEvents === 'function'
|
|
130
|
+
? live.snapshotEvents(0, live.seq)
|
|
131
|
+
: (live.events || []),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const stored = await ctx.sessionQuery?.readSession?.(sessionId)
|
|
135
|
+
if (stored) {
|
|
136
|
+
return {
|
|
137
|
+
id: sessionId,
|
|
138
|
+
header: stored.session || {},
|
|
139
|
+
events: stored.events || [],
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ===========================================================================
|
|
146
|
+
// Content-addressed snapshot store
|
|
147
|
+
// ===========================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the ignore predicate from the excludes config. Patterns are matched
|
|
151
|
+
* as path prefixes (for trailing-slash dir patterns) or basename globs
|
|
152
|
+
* (e.g. "*.log"). Simplified but covers common cases.
|
|
153
|
+
*/
|
|
154
|
+
function makeIgnore(cwd, excludes) {
|
|
155
|
+
const patterns = (excludes || DEFAULT_EXCLUDES).filter(Boolean)
|
|
156
|
+
return function isIgnored(absPath) {
|
|
157
|
+
const rel = relative(cwd, absPath).split('\\').join('/')
|
|
158
|
+
for (const pat of patterns) {
|
|
159
|
+
if (pat.startsWith('*')) {
|
|
160
|
+
// basename glob e.g. "*.log"
|
|
161
|
+
if (pat.slice(1) && basename(rel).endsWith(pat.slice(1))) return true
|
|
162
|
+
} else {
|
|
163
|
+
if (rel === pat || rel.startsWith(pat)) return true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** sha256 of a file's bytes. */
|
|
171
|
+
function hashFile(absPath) {
|
|
172
|
+
const h = createHash('sha256')
|
|
173
|
+
h.update(readFileSync(absPath))
|
|
174
|
+
return h.digest('hex')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* SnapshotStore manages objects/ + snapshots/<session>/<turn>.manifest.
|
|
179
|
+
* Pure Node fs — no git dependency.
|
|
180
|
+
*/
|
|
181
|
+
class SnapshotStore {
|
|
182
|
+
constructor(cfg) {
|
|
183
|
+
this.base = cfg.baseDir
|
|
184
|
+
this.excludes = cfg.excludes || DEFAULT_EXCLUDES
|
|
185
|
+
this.maxFileBytes = cfg.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
|
|
186
|
+
this.maxFiles = cfg.maxFilesPerSnapshot ?? DEFAULT_MAX_FILES_PER_SNAPSHOT
|
|
187
|
+
this.maxBytes = cfg.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES
|
|
188
|
+
this.ttlDays = cfg.snapshotTtlDays ?? DEFAULT_SNAPSHOT_TTL_DAYS
|
|
189
|
+
this.maxSnapshots = cfg.maxSnapshotsPerSession ?? DEFAULT_MAX_SNAPSHOTS_PER_SESSION
|
|
190
|
+
this.objDir = join(this.base, 'objects')
|
|
191
|
+
this.snapDir = join(this.base, 'snapshots')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Capture the workspace at cwd into the session's chain.
|
|
196
|
+
* Returns the manifest (or null if unchanged since the session's last).
|
|
197
|
+
*/
|
|
198
|
+
capture(cwd, sessionId, turn) {
|
|
199
|
+
ensureDir(this.objDir)
|
|
200
|
+
ensureDir(join(this.snapDir, sessionId))
|
|
201
|
+
|
|
202
|
+
const ignore = makeIgnore(cwd, this.excludes)
|
|
203
|
+
const chain = this.loadChain(sessionId)
|
|
204
|
+
const prevManifest = chain.length ? chain[chain.length - 1].manifest : null
|
|
205
|
+
|
|
206
|
+
const files = this.scan(cwd, ignore)
|
|
207
|
+
if (!files) {
|
|
208
|
+
// Exceeds limits — skip this turn (no snapshot).
|
|
209
|
+
return { skipped: true, reason: 'limits' }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const manifest = {}
|
|
213
|
+
let filesChanged = 0
|
|
214
|
+
let totalBytes = 0
|
|
215
|
+
for (const p of files) {
|
|
216
|
+
const abs = p
|
|
217
|
+
let st
|
|
218
|
+
try {
|
|
219
|
+
st = lstatSync(abs)
|
|
220
|
+
} catch {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if (st.isDirectory()) continue
|
|
224
|
+
if (!st.isFile()) continue
|
|
225
|
+
if (st.size > this.maxFileBytes) continue
|
|
226
|
+
|
|
227
|
+
const rel = relative(cwd, abs).split('\\').join('/')
|
|
228
|
+
let entry
|
|
229
|
+
// Reuse previous object if content unchanged (compare size+mtime via
|
|
230
|
+
// prev manifest, else re-hash the file).
|
|
231
|
+
const prev = prevManifest && prevManifest[rel]
|
|
232
|
+
if (prev && prev.size === st.size && prev.mtime === st.mtimeMs) {
|
|
233
|
+
entry = prev // unchanged — reuse (no new object written)
|
|
234
|
+
} else {
|
|
235
|
+
const hash = hashFile(abs)
|
|
236
|
+
const objPath = join(this.objDir, hash)
|
|
237
|
+
if (!existsSync(objPath)) {
|
|
238
|
+
this.copyIntoObjects(abs, objPath)
|
|
239
|
+
}
|
|
240
|
+
entry = {
|
|
241
|
+
kind: 'file',
|
|
242
|
+
hash,
|
|
243
|
+
size: st.size,
|
|
244
|
+
mtime: st.mtimeMs,
|
|
245
|
+
mode: st.mode.toString(8).padStart(4, '0'),
|
|
246
|
+
}
|
|
247
|
+
filesChanged++
|
|
248
|
+
}
|
|
249
|
+
manifest[rel] = entry
|
|
250
|
+
totalBytes += st.size
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// If identical to previous manifest (nothing changed this turn), return null.
|
|
254
|
+
if (prevManifest && this.manifestsEqual(prevManifest, manifest)) {
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const manifestData = {
|
|
259
|
+
sessionId,
|
|
260
|
+
turn,
|
|
261
|
+
timestamp: new Date().toISOString(),
|
|
262
|
+
totalBytes,
|
|
263
|
+
manifest,
|
|
264
|
+
}
|
|
265
|
+
const mfPath = join(this.snapDir, sessionId, `${turn}.json`)
|
|
266
|
+
writeJsonFile(mfPath, manifestData)
|
|
267
|
+
return { manifest: manifestData, filesChanged }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Recursively list workspace files, applying ignore + limits. */
|
|
271
|
+
scan(cwd, ignore) {
|
|
272
|
+
const files = []
|
|
273
|
+
let total = 0
|
|
274
|
+
let byteCount = 0
|
|
275
|
+
const walk = (dir) => {
|
|
276
|
+
let entries
|
|
277
|
+
try {
|
|
278
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
279
|
+
} catch {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
for (const ent of entries) {
|
|
283
|
+
const abs = join(dir, ent.name)
|
|
284
|
+
if (ignore(abs)) continue
|
|
285
|
+
let st
|
|
286
|
+
try {
|
|
287
|
+
st = lstatSync(abs)
|
|
288
|
+
} catch {
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (st.isDirectory()) {
|
|
292
|
+
if (files.length + total > this.maxFiles) return
|
|
293
|
+
walk(abs)
|
|
294
|
+
} else if (st.isFile()) {
|
|
295
|
+
if (files.length >= this.maxFiles) return
|
|
296
|
+
if (st.size > this.maxFileBytes) { total++ ; continue }
|
|
297
|
+
byteCount += st.size
|
|
298
|
+
if (byteCount > this.maxBytes) return
|
|
299
|
+
files.push(abs)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
walk(cwd)
|
|
304
|
+
if (files.length > this.maxFiles || byteCount > this.maxBytes) {
|
|
305
|
+
return null // over limits -> skip snapshot
|
|
306
|
+
}
|
|
307
|
+
return files
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Recursively list all workspace file paths, applying ignore but with NO
|
|
312
|
+
* capacity caps. Used by restore's pruning step so a large/overflowing
|
|
313
|
+
* workspace still has its "extra" files removed to match the snapshot.
|
|
314
|
+
*/
|
|
315
|
+
walkAll(cwd, ignore) {
|
|
316
|
+
const files = []
|
|
317
|
+
const walk = (dir) => {
|
|
318
|
+
let entries
|
|
319
|
+
try {
|
|
320
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
321
|
+
} catch {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
for (const ent of entries) {
|
|
325
|
+
const abs = join(dir, ent.name)
|
|
326
|
+
if (ignore(abs)) continue
|
|
327
|
+
let st
|
|
328
|
+
try {
|
|
329
|
+
st = lstatSync(abs)
|
|
330
|
+
} catch {
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
if (st.isDirectory()) {
|
|
334
|
+
walk(abs)
|
|
335
|
+
} else if (st.isFile()) {
|
|
336
|
+
files.push(abs)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
walk(cwd)
|
|
341
|
+
return files
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Store a workspace file into the objects dir.
|
|
346
|
+
*
|
|
347
|
+
* MUST be a real copy, NOT a hard link: a hard link shares the inode with the
|
|
348
|
+
* live workspace file, and any later in-place write to the workspace file
|
|
349
|
+
* (the common `writeFileSync` / `>>` append path) would silently corrupt the
|
|
350
|
+
* object. Content addressing already dedupes (same bytes -> one object) and
|
|
351
|
+
* unchanged files reuse the previous manifest hash without writing a new
|
|
352
|
+
* object, so a copy costs space only for genuinely new content.
|
|
353
|
+
*
|
|
354
|
+
* A hard link is used ONLY when restoring an object back into the workspace,
|
|
355
|
+
* where the newly-materialized file is immediately superseded by the next
|
|
356
|
+
* turn's capture and there is no long-lived object to corrupt.
|
|
357
|
+
*/
|
|
358
|
+
copyIntoObjects(src, dst) {
|
|
359
|
+
try {
|
|
360
|
+
copyFileSync(src, dst)
|
|
361
|
+
} catch {
|
|
362
|
+
// e.g. src vanished mid-read; try streaming fallback
|
|
363
|
+
try {
|
|
364
|
+
const rs = createReadStream(src)
|
|
365
|
+
const ws = createWriteStream(dst)
|
|
366
|
+
rs.pipe(ws)
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
rs.on('error', reject)
|
|
369
|
+
ws.on('error', reject)
|
|
370
|
+
ws.on('finish', resolve)
|
|
371
|
+
})
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.warn('[turn-undo] object write failed:', e.message)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Materialize an object into the workspace (write-back during restore). */
|
|
379
|
+
materializeObject(objPath, dest) {
|
|
380
|
+
try {
|
|
381
|
+
unlinkSync(dest)
|
|
382
|
+
} catch { /* may not exist */ }
|
|
383
|
+
ensureDir(dirname(dest))
|
|
384
|
+
try {
|
|
385
|
+
linkSync(objPath, dest) // hard link back: cheap + safe (object won't be re-modified)
|
|
386
|
+
} catch {
|
|
387
|
+
copyFileSync(objPath, dest)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
manifestsEqual(a, b) {
|
|
392
|
+
const ka = Object.keys(a)
|
|
393
|
+
const kb = Object.keys(b)
|
|
394
|
+
if (ka.length !== kb.length) return false
|
|
395
|
+
for (const k of ka) {
|
|
396
|
+
const ea = a[k]
|
|
397
|
+
const eb = b[k]
|
|
398
|
+
if (!eb) return false
|
|
399
|
+
if (ea.kind !== eb.kind) return false
|
|
400
|
+
if (ea.hash !== eb.hash) return false
|
|
401
|
+
}
|
|
402
|
+
return true
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Load snapshot manifests for a session, oldest first. */
|
|
406
|
+
loadChain(sessionId) {
|
|
407
|
+
const dir = join(this.snapDir, sessionId)
|
|
408
|
+
if (!existsSync(dir)) return []
|
|
409
|
+
const out = []
|
|
410
|
+
for (const f of readdirSync(dir).filter(x => x.endsWith('.json')).sort(numeric)) {
|
|
411
|
+
const data = readJsonFile(join(dir, f))
|
|
412
|
+
if (data && data.manifest) out.push(data)
|
|
413
|
+
}
|
|
414
|
+
return out
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Restore the workspace at cwd to the state captured at or before targetTurn.
|
|
419
|
+
* Creates a safety snapshot first. Returns stats.
|
|
420
|
+
*/
|
|
421
|
+
restore(cwd, sessionId, targetTurn) {
|
|
422
|
+
const chain = this.loadChain(sessionId)
|
|
423
|
+
// Find the NEWEST snapshot whose turn <= targetTurn (best available state
|
|
424
|
+
// at/before the boundary). Filter to only completed turns present.
|
|
425
|
+
let target = null
|
|
426
|
+
for (const m of chain) {
|
|
427
|
+
if (m.turn <= targetTurn && (target === null || m.turn > target.turn)) {
|
|
428
|
+
target = m
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (!target) {
|
|
432
|
+
return { ok: false, error: 'NO_SNAPSHOT' }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Restore files to the target manifest state.
|
|
436
|
+
const ignore = makeIgnore(cwd, this.excludes)
|
|
437
|
+
const manifest = target.manifest
|
|
438
|
+
const rels = Object.keys(manifest)
|
|
439
|
+
|
|
440
|
+
let restoredFiles = 0
|
|
441
|
+
let deletedFiles = 0
|
|
442
|
+
// 1. Write back / refresh files present in target manifest.
|
|
443
|
+
for (const rel of rels) {
|
|
444
|
+
const entry = manifest[rel]
|
|
445
|
+
const abs = resolve(cwd, rel)
|
|
446
|
+
if (ignore(abs)) continue
|
|
447
|
+
try {
|
|
448
|
+
ensureDir(dirname(abs))
|
|
449
|
+
if (entry.kind === 'file') {
|
|
450
|
+
const objPath = join(this.objDir, entry.hash)
|
|
451
|
+
if (!existsSync(objPath)) continue
|
|
452
|
+
this.materializeObject(objPath, abs)
|
|
453
|
+
restoredFiles++
|
|
454
|
+
}
|
|
455
|
+
} catch (e) {
|
|
456
|
+
/* skip un-restorable files */
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 2. Delete files present on disk but absent from the target manifest,
|
|
461
|
+
// except excluded dirs. Only within cwd. Uses a boundary-unlimited walk
|
|
462
|
+
// (unlike scan, whose caps would silently stop the pruning early).
|
|
463
|
+
const current = this.walkAll(cwd, ignore)
|
|
464
|
+
for (const abs of current) {
|
|
465
|
+
if (ignore(abs)) continue
|
|
466
|
+
const rel = relative(cwd, abs).split('\\').join('/')
|
|
467
|
+
if (!(rel in manifest)) {
|
|
468
|
+
try {
|
|
469
|
+
rmSync(abs, { force: true })
|
|
470
|
+
deletedFiles++
|
|
471
|
+
} catch { /* noop */ }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
restoredFiles,
|
|
478
|
+
deletedFiles,
|
|
479
|
+
targetTurn,
|
|
480
|
+
restoredTurn: target.turn,
|
|
481
|
+
totalFiles: rels.length,
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Clean old snapshots (TTL + per-session cap).
|
|
487
|
+
*/
|
|
488
|
+
cleanup() {
|
|
489
|
+
const now = Date.now()
|
|
490
|
+
const ttlMs = this.ttlDays * 24 * 60 * 60 * 1000
|
|
491
|
+
if (!existsSync(this.snapDir)) return
|
|
492
|
+
for (const sessionId of readdirSync(this.snapDir)) {
|
|
493
|
+
const dir = join(this.snapDir, sessionId)
|
|
494
|
+
if (!statSync(dir).isDirectory()) continue
|
|
495
|
+
const files = readdirSync(dir).filter(x => x.endsWith('.json')).sort(numeric)
|
|
496
|
+
// TTL
|
|
497
|
+
for (const f of files) {
|
|
498
|
+
const data = readJsonFile(join(dir, f))
|
|
499
|
+
if (data?.timestamp) {
|
|
500
|
+
const age = now - new Date(data.timestamp).getTime()
|
|
501
|
+
if (age > ttlMs) { try { unlinkSync(join(dir, f)) } catch {} }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Cap newest N
|
|
505
|
+
const remaining = readdirSync(dir).filter(x => x.endsWith('.json')).sort(numeric)
|
|
506
|
+
const overflow = remaining.length - this.maxSnapshots
|
|
507
|
+
for (let i = 0; i < overflow; i++) {
|
|
508
|
+
try { unlinkSync(join(dir, remaining[i])) } catch {}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function numeric(a, b) {
|
|
515
|
+
const na = parseFloat(a)
|
|
516
|
+
const nb = parseFloat(b)
|
|
517
|
+
return (isNaN(na) ? 0 : na) - (isNaN(nb) ? 0 : nb)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ===========================================================================
|
|
521
|
+
// Boundary / fork resolution (mirrors dsh-turn-rewind messageTarget)
|
|
522
|
+
// ===========================================================================
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Locate the fork boundary (previous turn/end seq) and the turn number for a
|
|
526
|
+
* given user message seq.
|
|
527
|
+
*
|
|
528
|
+
* Two strategies (tried in order):
|
|
529
|
+
* 1. seq-based: match by e.seq === messageSeq, then use findLast with
|
|
530
|
+
* e.seq < ... comparisons. Works for live sessions where every event
|
|
531
|
+
* carries a reliable `seq`.
|
|
532
|
+
* 2. text-based fallback: match by message text content. This covers
|
|
533
|
+
* persisted sessions (no seq field) and avoids the bug of treating
|
|
534
|
+
* `messageSeq` as an array index.
|
|
535
|
+
*/
|
|
536
|
+
function resolveForkBoundary(source, messageSeq, promptText) {
|
|
537
|
+
const events = source?.events ?? []
|
|
538
|
+
const cwd = source?.header?.cwd
|
|
539
|
+
|
|
540
|
+
// ── Strategy 1: seq-based ──────────────────────────────────────────
|
|
541
|
+
let message = events.find(e => (
|
|
542
|
+
e.type === 'user/message'
|
|
543
|
+
&& e.seq === messageSeq
|
|
544
|
+
&& e.data?.source && typeof e.data.source === 'object'
|
|
545
|
+
&& e.data.source.kind === 'user'
|
|
546
|
+
))
|
|
547
|
+
|
|
548
|
+
if (message && typeof message.seq === 'number') {
|
|
549
|
+
const start = events.findLast(e => e.type === 'turn/start' && e.seq < message.seq)
|
|
550
|
+
const turn = start ? start.data?.turn : undefined
|
|
551
|
+
if (!start || typeof turn !== 'number' || turn < 0) {
|
|
552
|
+
return { boundary: null, turn: null, cwd, reason: 'no-turn-start' }
|
|
553
|
+
}
|
|
554
|
+
const previousEnd = events.findLast(e => e.type === 'turn/end' && e.seq < start.seq)
|
|
555
|
+
return {
|
|
556
|
+
boundary: previousEnd ? previousEnd.seq : null,
|
|
557
|
+
turn,
|
|
558
|
+
cwd,
|
|
559
|
+
reason: previousEnd ? 'ok' : 'no-previous-end',
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── Strategy 2: text-based fallback ────────────────────────────────
|
|
564
|
+
// Use promptText to match the user message, avoiding the bug of
|
|
565
|
+
// treating messageSeq as an array index when seq is unavailable.
|
|
566
|
+
//
|
|
567
|
+
// If promptText is not provided, find the latest user message as a
|
|
568
|
+
// safe fallback.
|
|
569
|
+
if (promptText) {
|
|
570
|
+
const normalizedPromptText = promptText.trim().substring(0, 200).toLowerCase()
|
|
571
|
+
message = events.find(e => (
|
|
572
|
+
e.type === 'user/message'
|
|
573
|
+
&& e.data?.source && typeof e.data.source === 'object'
|
|
574
|
+
&& e.data.source.kind === 'user'
|
|
575
|
+
&& e.data?.content
|
|
576
|
+
&& Array.isArray(e.data.content)
|
|
577
|
+
&& e.data.content.some(part => {
|
|
578
|
+
if (part.type !== 'text') return false
|
|
579
|
+
const text = (part.text || '').trim().substring(0, 200).toLowerCase()
|
|
580
|
+
return text.includes(normalizedPromptText) || normalizedPromptText.includes(text)
|
|
581
|
+
})
|
|
582
|
+
))
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// If promptText matching failed or not provided, find the latest user message.
|
|
586
|
+
if (!message) {
|
|
587
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
588
|
+
const e = events[i]
|
|
589
|
+
if (e.type === 'turn/start') break
|
|
590
|
+
if (e.type === 'user/message'
|
|
591
|
+
&& e.data?.source && typeof e.data.source === 'object'
|
|
592
|
+
&& e.data.source.kind === 'user') {
|
|
593
|
+
message = e
|
|
594
|
+
break
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!message) return { boundary: null, turn: null, cwd, reason: 'no-user-message' }
|
|
600
|
+
|
|
601
|
+
const targetIndex = events.indexOf(message)
|
|
602
|
+
if (targetIndex === -1) return { boundary: null, turn: null, cwd, reason: 'no-user-message' }
|
|
603
|
+
|
|
604
|
+
// Look for turn/start before the target message (by position, not seq)
|
|
605
|
+
let turn = null
|
|
606
|
+
let startIdx = -1
|
|
607
|
+
for (let i = targetIndex - 1; i >= 0; i--) {
|
|
608
|
+
const e = events[i]
|
|
609
|
+
if (e.type === 'turn/start' && typeof e.data?.turn === 'number' && e.data.turn >= 0) {
|
|
610
|
+
turn = e.data.turn
|
|
611
|
+
startIdx = i
|
|
612
|
+
break
|
|
613
|
+
}
|
|
614
|
+
// Stop at turn/end — the turn/start for this message must be after the
|
|
615
|
+
// last turn/end and before the current user message.
|
|
616
|
+
if (e.type === 'turn/end') break
|
|
617
|
+
}
|
|
618
|
+
if (turn === null) {
|
|
619
|
+
return { boundary: null, turn: null, cwd, reason: 'no-turn-start' }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Find the previous turn/end (before the found turn/start)
|
|
623
|
+
let boundary = null
|
|
624
|
+
for (let i = startIdx - 1; i >= 0; i--) {
|
|
625
|
+
if (events[i].type === 'turn/end') {
|
|
626
|
+
// Use seq if available; otherwise null (will trigger session.create
|
|
627
|
+
// instead of fork, which is safe).
|
|
628
|
+
boundary = 'seq' in events[i] ? events[i].seq : null
|
|
629
|
+
break
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return { boundary, turn, cwd, reason: 'ok' }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Archive an old session after a successful fork+restore: cancel it.
|
|
638
|
+
* Cancelling removes the old session so it doesn't conflict with the forked one.
|
|
639
|
+
* If cancel fails, we rename it as a last resort.
|
|
640
|
+
*/
|
|
641
|
+
async function archiveSession(ctx, sessionId) {
|
|
642
|
+
// DSH 0.1.2+ 移除了 cancel/rename/getProjection;当前为 surface 原地回滚,
|
|
643
|
+
// 不产生新会话,无需归档。此处保留为安全的 no-op 提示。
|
|
644
|
+
try {
|
|
645
|
+
console.info('[turn-undo] Session', sessionId, 'rewound in place; no archive needed')
|
|
646
|
+
return true
|
|
647
|
+
} catch (error) {
|
|
648
|
+
console.warn('[turn-undo] Archive failed:', error.message)
|
|
649
|
+
return false
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ===========================================================================
|
|
654
|
+
// Snapshot runtime (turn/end listener)
|
|
655
|
+
// ===========================================================================
|
|
656
|
+
|
|
657
|
+
class SnapshotRuntime {
|
|
658
|
+
constructor(cfg) {
|
|
659
|
+
// Use the shared store passed in by apply(); only build one if absent.
|
|
660
|
+
this.store = cfg.store || new SnapshotStore(cfg)
|
|
661
|
+
this.delayMs = cfg.snapshotDelayMs ?? DEFAULT_SNAPSHOT_DELAY_MS
|
|
662
|
+
this.queues = new Map() // cwd -> Promise chain (serialize per workspace)
|
|
663
|
+
this.logger = cfg.logger
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
observe(session, event) {
|
|
667
|
+
const cwd = getCwd(session)
|
|
668
|
+
if (!cwd) return
|
|
669
|
+
const sessionId = session.id
|
|
670
|
+
const turn = event.data?.turn
|
|
671
|
+
if (typeof turn !== 'number') return
|
|
672
|
+
|
|
673
|
+
// Capture base snapshot at turn/start (before any AI work).
|
|
674
|
+
// Use a fractional turn number so it doesn't overwrite the turn/end snapshot.
|
|
675
|
+
if (event.type === 'turn/start') {
|
|
676
|
+
this.enqueueCapture(cwd, sessionId, turn - 0.5)
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Track nothing per-tool — the whole turn is snapshotted at turn/end.
|
|
681
|
+
if (event.type !== 'turn/end') return
|
|
682
|
+
this.enqueueCapture(cwd, sessionId, turn)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Force a snapshot now (e.g. right after cancel during a mid-turn undo). */
|
|
686
|
+
captureNow(cwd, sessionId, turn) {
|
|
687
|
+
return this.enqueueCapture(cwd, sessionId, turn, true)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Wait for all pending snapshot captures to finish.
|
|
692
|
+
* This ensures the latest manifest is on disk before restore reads it.
|
|
693
|
+
*/
|
|
694
|
+
async waitForSnapshots() {
|
|
695
|
+
const promises = []
|
|
696
|
+
for (const [, pending] of this.queues) {
|
|
697
|
+
promises.push(pending.catch(() => {})) // swallow errors, we just wait
|
|
698
|
+
}
|
|
699
|
+
if (promises.length) {
|
|
700
|
+
await Promise.all(promises)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
enqueueCapture(cwd, sessionId, turn, propagate = false) {
|
|
705
|
+
const key = cwd
|
|
706
|
+
const prev = this.queues.get(key) || Promise.resolve()
|
|
707
|
+
const run = prev.then(async () => {
|
|
708
|
+
// brief delay so writes settle
|
|
709
|
+
await wait(this.delayMs)
|
|
710
|
+
const result = this.store.capture(cwd, sessionId, turn)
|
|
711
|
+
this.store.cleanup()
|
|
712
|
+
return result
|
|
713
|
+
})
|
|
714
|
+
const settled = run.catch((e) => {
|
|
715
|
+
this.logger?.warn?.(`[turn-undo] snapshot failed: ${e.message}`)
|
|
716
|
+
return null
|
|
717
|
+
})
|
|
718
|
+
this.queues.set(key, settled)
|
|
719
|
+
return propagate ? run : settled
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function wait(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
724
|
+
|
|
725
|
+
// ===========================================================================
|
|
726
|
+
// Surface rewind utilities (P0-1: markerTurnOf, markerStepOf, planSurfaceRewind)
|
|
727
|
+
// ===========================================================================
|
|
728
|
+
|
|
729
|
+
/** Turn number for the rewind marker (reuse last started turn, never collides with a future turn/start). */
|
|
730
|
+
function markerTurnOf(events) {
|
|
731
|
+
let lastStarted = 0
|
|
732
|
+
for (const event of events) {
|
|
733
|
+
if (event.type === 'turn/start' && event.data?.turn > lastStarted) {
|
|
734
|
+
lastStarted = event.data.turn
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return lastStarted
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Step number for the marker's ghost step frame (lastStarted + 1 is always safe). */
|
|
741
|
+
function markerStepOf(events, turn) {
|
|
742
|
+
let lastStarted = 0
|
|
743
|
+
for (const event of events) {
|
|
744
|
+
if (event.type === 'step/start' && event.data?.turn === turn && event.data?.step > lastStarted) {
|
|
745
|
+
lastStarted = event.data.step
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return lastStarted + 1
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Compute the surface replacement plan for a rewind target.
|
|
753
|
+
* DSH 0.1.2+ 的 session.surface.nodes 是 seq 数字数组(number[]);
|
|
754
|
+
* 旧版可能是 { seq } 对象数组,这里两种都兼容。
|
|
755
|
+
*/
|
|
756
|
+
function planSurfaceRewind(events, surfaceNodes, targetSeq) {
|
|
757
|
+
// 归一化 surface 节点为数字 seq 列表(DSH 0.1.2+ 的 surface.nodes 是 number[],
|
|
758
|
+
// 旧版可能是 { seq } 对象索引形式)。
|
|
759
|
+
const seqList = surfaceNodes.length > 0 && typeof surfaceNodes[0] === 'number'
|
|
760
|
+
? surfaceNodes.slice()
|
|
761
|
+
: surfaceNodes.map(n => n.seq)
|
|
762
|
+
|
|
763
|
+
// 丢弃从该消息开始之后的 surface 节点:找到第一个 seq >= targetSeq 的节点并全部截断。
|
|
764
|
+
// 不用精确 indexOf(targetSeq)——当 user 消息已被后续 replace 遮蔽、或 messageSeq
|
|
765
|
+
// 介于两个 surface 节点之间时,精确匹配会失败而报 target seq not on surface;
|
|
766
|
+
// at-or-after 策略从该消息或其后的最近 surface 节点截断,仍实现恢复到发送这条消息之前。
|
|
767
|
+
const dropFrom = seqList.findIndex(s => s >= targetSeq)
|
|
768
|
+
if (dropFrom === -1) {
|
|
769
|
+
throw new Error('target seq ' + targetSeq + ' not on surface')
|
|
770
|
+
}
|
|
771
|
+
const shadowedSeqs = seqList.slice(dropFrom)
|
|
772
|
+
return {
|
|
773
|
+
targetSeq,
|
|
774
|
+
targetIndex: dropFrom,
|
|
775
|
+
shadowedSeqs,
|
|
776
|
+
surfaceStart: shadowedSeqs[0],
|
|
777
|
+
surfaceEnd: shadowedSeqs[shadowedSeqs.length - 1],
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Rewind one live session's surface to before a target user message, in place:
|
|
783
|
+
* append a ghost step/marker assistant/message carrying surfaceOp.replace that
|
|
784
|
+
* drops every surface node from target onwards. No fork.
|
|
785
|
+
*/
|
|
786
|
+
async function executeSurfaceRewind(agent, targetSeq) {
|
|
787
|
+
const session = agent.session
|
|
788
|
+
const surfaceNodes = session.surface?.nodes || []
|
|
789
|
+
if (!surfaceNodes || surfaceNodes.length === 0) {
|
|
790
|
+
throw new Error('no surface nodes available')
|
|
791
|
+
}
|
|
792
|
+
const plan = planSurfaceRewind(session.events || [], surfaceNodes, targetSeq)
|
|
793
|
+
const turn = markerTurnOf(session.events || [])
|
|
794
|
+
const step = markerStepOf(session.events || [], turn)
|
|
795
|
+
|
|
796
|
+
agent.session.append('step/start', { turn, step })
|
|
797
|
+
try {
|
|
798
|
+
const marker = {
|
|
799
|
+
content: [],
|
|
800
|
+
source: { provider: 'turn-undo', model: 'rewind-marker' },
|
|
801
|
+
}
|
|
802
|
+
agent.session.append('assistant/message', { turn, step, message: marker }, {
|
|
803
|
+
surfaceOp: { op: 'replace', start: plan.surfaceStart, end: plan.surfaceEnd },
|
|
804
|
+
sourceEventSeqs: plan.shadowedSeqs,
|
|
805
|
+
})
|
|
806
|
+
} finally {
|
|
807
|
+
agent.session.append('step/end', { turn, step })
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return plan
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ===========================================================================
|
|
814
|
+
// Fork + mark-undone
|
|
815
|
+
// ===========================================================================
|
|
816
|
+
/**
|
|
817
|
+
* Fork a new child session at the message via DSH SessionController,
|
|
818
|
+
* then mark the OLD session undone.
|
|
819
|
+
*
|
|
820
|
+
* - Uses the same `sessionController.fork` RPC as the web UI's
|
|
821
|
+
* "Branch into a new conversation" / "Fork session" buttons.
|
|
822
|
+
* - Falls back to `sessionController.create` when the target message has
|
|
823
|
+
* no completed turn before it (e.g. the very first user message).
|
|
824
|
+
* @returns the new child session id.
|
|
825
|
+
*/
|
|
826
|
+
async function forkAndMarkUndone(ctx, sessionId, messageSeq, promptText) {
|
|
827
|
+
const source = await readSession(ctx, sessionId)
|
|
828
|
+
if (!source) throw new Error('source session not found')
|
|
829
|
+
const b = resolveForkBoundary(source, messageSeq, promptText)
|
|
830
|
+
|
|
831
|
+
let childId
|
|
832
|
+
if (typeof b.boundary === 'number') {
|
|
833
|
+
const { sessionId: cid } = await ctx.sessionController.fork({ sessionId, atSeq: b.boundary })
|
|
834
|
+
childId = cid
|
|
835
|
+
} else {
|
|
836
|
+
const cwd = source.header?.cwd
|
|
837
|
+
if (!cwd) throw new Error('cannot undo first message without a cwd')
|
|
838
|
+
const created = await ctx.sessionController.create({
|
|
839
|
+
cwd,
|
|
840
|
+
...(source.header?.agentPreset ? { agentPreset: source.header.agentPreset } : {}),
|
|
841
|
+
})
|
|
842
|
+
childId = created.sessionId
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
let oldTitle = ''
|
|
847
|
+
if (ctx.sessionQuery && typeof ctx.sessionQuery.readTitle === 'function') {
|
|
848
|
+
const t = await ctx.sessionQuery.readTitle(sessionId)
|
|
849
|
+
oldTitle = t && t.title ? t.title : ''
|
|
850
|
+
}
|
|
851
|
+
const prefix = '(已撤销)'
|
|
852
|
+
if (!oldTitle.startsWith(prefix)) {
|
|
853
|
+
await ctx.sessionController.rename({
|
|
854
|
+
sessionId,
|
|
855
|
+
title: prefix + (oldTitle || '(未命名会话)'),
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
} catch (e) {
|
|
859
|
+
console.warn('[turn-undo] Rename old session failed (non-fatal):', e.message)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return childId
|
|
863
|
+
}
|
|
864
|
+
// ===========================================================================
|
|
865
|
+
// HTTP handler + plugin entry
|
|
866
|
+
// ===========================================================================
|
|
867
|
+
|
|
868
|
+
function createHandler(ctx, runtime, sessions, agents) {
|
|
869
|
+
return async (request, response) => {
|
|
870
|
+
try {
|
|
871
|
+
if (request.method === 'GET') {
|
|
872
|
+
const url = new URL(request.url ?? API_PATH, 'http://dsh.local')
|
|
873
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
874
|
+
const messageSeqParam = url.searchParams.get('messageSeq')
|
|
875
|
+
const promptTextParam = url.searchParams.get('promptText')
|
|
876
|
+
|
|
877
|
+
if (!sessionId) return json(response, 400, { error: 'Missing sessionId' })
|
|
878
|
+
|
|
879
|
+
let targetTurn = null
|
|
880
|
+
if (messageSeqParam) {
|
|
881
|
+
const source = await readSession(ctx, sessionId)
|
|
882
|
+
if (source) {
|
|
883
|
+
const b = resolveForkBoundary(source, parseInt(messageSeqParam, 10), promptTextParam)
|
|
884
|
+
// For preview, pass the turn that the user wants to undo (b.turn).
|
|
885
|
+
// The preview function will compare this turn's snapshot with the previous one.
|
|
886
|
+
targetTurn = b.turn !== null && b.turn > 0 ? b.turn : null
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const preview = runtime.store.preview(sessionId, targetTurn)
|
|
891
|
+
return json(response, 200, preview)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (request.method === 'POST') {
|
|
895
|
+
let body = ''
|
|
896
|
+
await new Promise((resolve, reject) => {
|
|
897
|
+
request.on('data', c => { body += c })
|
|
898
|
+
request.on('end', resolve)
|
|
899
|
+
request.on('error', reject)
|
|
900
|
+
})
|
|
901
|
+
const data = JSON.parse(body)
|
|
902
|
+
const sessionId = data.sessionId
|
|
903
|
+
const messageSeq = data.messageSeq ? parseInt(data.messageSeq, 10) : null
|
|
904
|
+
const promptText = data.promptText || null
|
|
905
|
+
if (!sessionId) return json(response, 400, { error: 'Missing sessionId' })
|
|
906
|
+
|
|
907
|
+
const source = await readSession(ctx, sessionId)
|
|
908
|
+
if (!source) return json(response, 400, { error: 'Session not found' })
|
|
909
|
+
const cwd = getCwd(source)
|
|
910
|
+
const b = messageSeq !== null ? resolveForkBoundary(source, messageSeq, promptText) : { boundary: null, turn: null, cwd }
|
|
911
|
+
|
|
912
|
+
// Determine restore target: "recover to before this message" means
|
|
913
|
+
// the state at turn/start, i.e. turn T - 0.5. That snapshot captures
|
|
914
|
+
// the workspace before the user sent this message and before any AI work.
|
|
915
|
+
let restoreTurn = null
|
|
916
|
+
if (b.turn !== null && b.turn > 0) {
|
|
917
|
+
restoreTurn = b.turn - 0.5
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// 3. Wait for any pending snapshot to settle so the manifest is on disk.
|
|
921
|
+
// Without this, a restore issued right after turn/end may read an
|
|
922
|
+
// empty snapshot chain and skip file restoration.
|
|
923
|
+
try { await runtime.waitForSnapshots() } catch {}
|
|
924
|
+
|
|
925
|
+
// 4. Restore files to the best snapshot at/before restoreTurn.
|
|
926
|
+
let restoreResult
|
|
927
|
+
if (restoreTurn !== null) {
|
|
928
|
+
restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
|
|
929
|
+
} else {
|
|
930
|
+
restoreResult = { ok: false, error: 'NO_SNAPSHOT' }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// 5. Fork 新会话(DSH 原生,web 的在此分叉按钮同款:ctx.agents.create),
|
|
934
|
+
// 并把旧会话标题加上 (已撤销) 前缀后保留。
|
|
935
|
+
let newSessionId = null
|
|
936
|
+
try {
|
|
937
|
+
newSessionId = await forkAndMarkUndone(ctx, sessionId, messageSeq, promptText)
|
|
938
|
+
} catch (e) {
|
|
939
|
+
console.error('[turn-undo] Fork failed:', e.message)
|
|
940
|
+
return json(response, 200, {
|
|
941
|
+
ok: false,
|
|
942
|
+
status: 'fork-failed',
|
|
943
|
+
error: e.message,
|
|
944
|
+
restore: restoreResult && {
|
|
945
|
+
ok: restoreResult.ok === true,
|
|
946
|
+
skipped: restoreResult.error === 'NO_SNAPSHOT',
|
|
947
|
+
},
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return json(response, 200, {
|
|
952
|
+
ok: true,
|
|
953
|
+
status: 'completed',
|
|
954
|
+
newSessionId,
|
|
955
|
+
restore: {
|
|
956
|
+
ok: restoreResult?.ok === true,
|
|
957
|
+
skipped: restoreResult?.error === 'NO_SNAPSHOT',
|
|
958
|
+
restoredFiles: restoreResult?.restoredFiles ?? 0,
|
|
959
|
+
deletedFiles: restoreResult?.deletedFiles ?? 0,
|
|
960
|
+
},
|
|
961
|
+
})
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return json(response, 405, { error: 'Method not allowed' })
|
|
965
|
+
} catch (error) {
|
|
966
|
+
console.error('[turn-undo] Handler error:', error.message)
|
|
967
|
+
return json(response, 500, { error: error.message })
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Main plugin apply function.
|
|
974
|
+
*/
|
|
975
|
+
export function apply(ctx, config = {}) {
|
|
976
|
+
const baseDir = getBaseDir(ctx)
|
|
977
|
+
const logger = ctx.logger || console
|
|
978
|
+
const store = new SnapshotStore({
|
|
979
|
+
baseDir,
|
|
980
|
+
excludes: config.excludes,
|
|
981
|
+
maxFileBytes: config.maxFileBytes,
|
|
982
|
+
maxFilesPerSnapshot: config.maxFilesPerSnapshot,
|
|
983
|
+
maxSnapshotBytes: config.maxSnapshotBytes,
|
|
984
|
+
snapshotTtlDays: config.snapshotTtlDays,
|
|
985
|
+
maxSnapshotsPerSession: config.maxSnapshotsPerSession,
|
|
986
|
+
})
|
|
987
|
+
const runtime = new SnapshotRuntime({
|
|
988
|
+
store,
|
|
989
|
+
excludes: config.excludes,
|
|
990
|
+
snapshotDelayMs: config.snapshotDelayMs,
|
|
991
|
+
logger,
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
// Listen to session/event for turn/start and turn/end snapshots.
|
|
995
|
+
// This is a global broadcast (DSH emits it with { global: true }), so we
|
|
996
|
+
// attach on the root ctx. Exceptions here must never derail the agent turn.
|
|
997
|
+
ctx.on('session/event', (session, event) => {
|
|
998
|
+
try {
|
|
999
|
+
if (event && (event.type === 'turn/start' || event.type === 'turn/end')) {
|
|
1000
|
+
runtime.observe(session, event)
|
|
1001
|
+
}
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
logger?.warn?.('[turn-undo] session/event handler error:', e.message)
|
|
1004
|
+
}
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
// HTTP API endpoints.
|
|
1008
|
+
ctx.inject(['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController'], (scope) => {
|
|
1009
|
+
scope.effect(() => {
|
|
1010
|
+
const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
|
|
1011
|
+
scope.webServer.register({
|
|
1012
|
+
kind: 'exact',
|
|
1013
|
+
path: API_PATH,
|
|
1014
|
+
handler,
|
|
1015
|
+
})
|
|
1016
|
+
return () => {}
|
|
1017
|
+
}, 'turn-undo: http-api')
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Add preview helper to SnapshotStore prototype.
|
|
1022
|
+
// Returns the files that changed during the target turn (turn/end vs turn/start).
|
|
1023
|
+
SnapshotStore.prototype.preview = function (sessionId, targetTurn) {
|
|
1024
|
+
const chain = this.loadChain(sessionId)
|
|
1025
|
+
if (targetTurn === null) {
|
|
1026
|
+
return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// The turn/end snapshot shows the workspace after AI work in this turn.
|
|
1030
|
+
let target = null
|
|
1031
|
+
for (const m of chain) {
|
|
1032
|
+
if (m.turn === targetTurn) {
|
|
1033
|
+
target = m
|
|
1034
|
+
break
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (!target) {
|
|
1038
|
+
return { ok: true, status: 'ready', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Use the newest snapshot strictly before this turn as the baseline.
|
|
1042
|
+
// It is normally the turn/start snapshot (T - 0.5), but if an intermediate
|
|
1043
|
+
// turn had no file changes, no manifest was written, so we fall back to the
|
|
1044
|
+
// most recent available baseline. For the very first turn we use an empty
|
|
1045
|
+
// baseline.
|
|
1046
|
+
let prev = null
|
|
1047
|
+
for (const m of chain) {
|
|
1048
|
+
if (m.turn < targetTurn && (prev === null || m.turn > prev.turn)) {
|
|
1049
|
+
prev = m
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const prevManifest = prev ? prev.manifest : {}
|
|
1053
|
+
|
|
1054
|
+
// Calculate changes: files that differ between turn/start and turn/end.
|
|
1055
|
+
const changes = []
|
|
1056
|
+
const targetManifest = target.manifest
|
|
1057
|
+
|
|
1058
|
+
for (const rel of Object.keys(targetManifest)) {
|
|
1059
|
+
const entry = targetManifest[rel]
|
|
1060
|
+
if (!prevManifest[rel]) {
|
|
1061
|
+
changes.push({ path: rel, kind: 'created' })
|
|
1062
|
+
} else {
|
|
1063
|
+
const prevEntry = prevManifest[rel]
|
|
1064
|
+
if (entry.hash !== prevEntry.hash) {
|
|
1065
|
+
changes.push({ path: rel, kind: 'modified' })
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
for (const rel of Object.keys(prevManifest)) {
|
|
1071
|
+
if (!targetManifest[rel]) {
|
|
1072
|
+
changes.push({ path: rel, kind: 'deleted' })
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
return {
|
|
1077
|
+
ok: true,
|
|
1078
|
+
status: 'ready',
|
|
1079
|
+
targetTurn: target.turn,
|
|
1080
|
+
totalChanges: changes.length,
|
|
1081
|
+
changes,
|
|
1082
|
+
}
|
|
1083
|
+
}
|