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/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
+ }