dsh-turn-undo 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/client.js +32 -52
  2. package/index.js +314 -125
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -146,6 +146,10 @@ window.__ModuleLoader__.load({
146
146
  '.dtu-trigger{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
147
147
  '.dtu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
148
148
  '.dtu-trigger:disabled{cursor:not-allowed;opacity:.5}',
149
+ // 点击后浏览器保留 :focus,hover 底色会「粘住」不恢复 —— 鼠标移开时
150
+ // 显式清掉 focus/active 态的背景与描边。
151
+ '.dtu-trigger:focus{outline:none}',
152
+ '.dtu-trigger:focus:not(:hover),.dtu-trigger:focus-visible:not(:hover),.dtu-trigger:active:not(:hover){background:transparent;color:var(--dsw-alias-label-tertiary)}',
149
153
  '.dtu-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000}',
150
154
  '.dtu-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px);overflow:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.2)}',
151
155
  '.dtu-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box;padding:18px}',
@@ -197,33 +201,7 @@ window.__ModuleLoader__.load({
197
201
  document.head.appendChild(styleEl)
198
202
  }
199
203
 
200
- function openRestoredSession(sessionId, draftText) {
201
- try {
202
- ctx.sessions.open(sessionId)
203
- } catch (error) {
204
- console.warn('[turn-undo] openRestoredSession open failed:', error.message)
205
- return
206
- }
207
- if (!draftText) return
208
- function trySetDraft(remaining) {
209
- if (remaining <= 0) return
210
- try {
211
- var scope = ctx.sessions.scope(sessionId)
212
- if (scope !== undefined) {
213
- ctx.conversation.input.for(scope).setDraft(draftText)
214
- return
215
- }
216
- } catch (error) {
217
- if (remaining <= 1) {
218
- console.warn('[turn-undo] openRestoredSession setDraft failed:', error.message)
219
- }
220
- }
221
- setTimeout(function () { trySetDraft(remaining - 1) }, 100)
222
- }
223
- trySetDraft(20)
224
- }
225
-
226
- ctx.inject(['slots', 'sessions', 'conversation'], function (scope) {
204
+ ctx.inject(['slots', 'conversation', 'uiWorkspace'], function (scope) {
227
205
  scope.effect(function () {
228
206
  return scope.slots.inject('conversation.session.header.actions', function () {
229
207
  return scope.slots.register({
@@ -231,13 +209,21 @@ window.__ModuleLoader__.load({
231
209
  id: 'turn-undo-portals',
232
210
  order: 100,
233
211
  inject: function () {
234
- var sessionsSvc = scope.sessions
212
+ var workspaceSvc = scope.uiWorkspace
235
213
  var conversationSvc = scope.conversation
236
214
  return {
237
215
  openRestoredSession: function (newSessionId, draftText) {
238
- // Open first: the input shell only exists after the session binding is materialized.
239
- if (sessionsSvc && sessionsSvc.open) {
240
- sessionsSvc.open(newSessionId)
216
+ // 导航必须走 uiWorkspace:ISessions 没有 open(),
217
+ // 旧的 sessionsSvc.open(...) 永远被 if 守卫静默跳过 —— 这正是
218
+ // 「改名了但新会话没打开」的根因。
219
+ try {
220
+ if (workspaceSvc && typeof workspaceSvc.openSession === 'function') {
221
+ workspaceSvc.openSession(newSessionId)
222
+ } else {
223
+ console.warn('[turn-undo] uiWorkspace.openSession unavailable; new session not opened')
224
+ }
225
+ } catch (e) {
226
+ console.warn('[turn-undo] Failed to open restored session:', e.message)
241
227
  }
242
228
  if (!conversationSvc || !conversationSvc.input || !draftText) return
243
229
  function trySetDraft(remaining) {
@@ -395,7 +381,7 @@ window.__ModuleLoader__.load({
395
381
  var previewState = useState(null)
396
382
  var preview = previewState[0]
397
383
  var setPreview = previewState[1]
398
- var loadingState = useState(true)
384
+ var loadingState = useState(false)
399
385
  var loading = loadingState[0]
400
386
  var setLoading = loadingState[1]
401
387
  var errorState = useState(null)
@@ -408,28 +394,17 @@ window.__ModuleLoader__.load({
408
394
  var done = doneState[0]
409
395
  var setDone = doneState[1]
410
396
 
411
- useEffect(function () {
412
- var cancelled = false
413
- setLoading(true)
414
- setError(null)
415
- fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
416
- method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
417
- })
418
- .then(function (res) { return res.json() })
419
- .then(function (data) { if (!cancelled) setPreview(data) })
420
- .catch(function (err) { if (!cancelled) setError(err.message || '请求失败') })
421
- .finally(function () { if (!cancelled) setLoading(false) })
422
- return function () { cancelled = true }
423
- }, [])
397
+ // NOTE: preview 只在用户点开弹框时请求(见 show())。挂载时预取毫无
398
+ // 意义——按钮只渲染图标,不消费 preview——却会让每个会话在打开瞬间
399
+ // 为每条用户消息各发一次全工作区扫描请求(实测单次 1.2–2.7 秒)。
424
400
 
425
401
  function show() {
426
402
  setOpen(true)
427
403
  setPreview(null)
428
404
  setDone(false)
429
405
  setLoading(true)
430
- // NOTE: cancelled 只存在于 mount effect 闭包,show() 引用它会抛
431
- // ReferenceError setLoading(false) 永不执行 → 弹框一直"正在检查…",
432
- // 且未捕获错误可能导致 React 卸下 portal(按钮消失)。必须无条件收尾。
406
+ // NOTE: 必须无条件收尾:任何未捕获错误都会让 setLoading(false) 不执行,
407
+ // 弹框会永远停在"正在检查…",且可能让 React 卸下 portal(按钮消失)。
433
408
  fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
434
409
  method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
435
410
  })
@@ -468,6 +443,7 @@ window.__ModuleLoader__.load({
468
443
 
469
444
  var changes = (preview && Array.isArray(preview.changes)) ? preview.changes : []
470
445
  var noSnapshot = (preview && preview.noSnapshot) === true
446
+ var noBaseline = (preview && preview.noBaseline) === true
471
447
  var previewError = (preview && preview.error) ? preview.error : null
472
448
 
473
449
  return h('div', { className: 'dtu-container' },
@@ -491,7 +467,8 @@ window.__ModuleLoader__.load({
491
467
  sessionId: sessionId, messageText: messageText,
492
468
  onClose: close, preview: preview, loading: loading, error: error,
493
469
  applying: applying, done: done, changes: changes,
494
- previewError: previewError, noSnapshot: noSnapshot, canApply: canApply, applyRestore: applyRestore,
470
+ previewError: previewError, noSnapshot: noSnapshot, noBaseline: noBaseline,
471
+ canApply: canApply, applyRestore: applyRestore,
495
472
  }) : null,
496
473
  )
497
474
  }
@@ -629,6 +606,7 @@ window.__ModuleLoader__.load({
629
606
  var changes = props.changes
630
607
  var previewError = props.previewError
631
608
  var noSnapshot = props.noSnapshot
609
+ var noBaseline = props.noBaseline
632
610
  var canApply = props.canApply
633
611
  var applyRestore = props.applyRestore
634
612
 
@@ -662,9 +640,11 @@ window.__ModuleLoader__.load({
662
640
  previewError ? h('p', { className: 'dtu-error' }, previewError) : null,
663
641
  (!loading && noSnapshot)
664
642
  ? h('p', { className: 'dtu-status' }, '该时点没有可用快照,将仅创建新会话。') : null,
665
- (!loading && !previewError && !noSnapshot && changes.length === 0)
643
+ (!loading && !noSnapshot && noBaseline)
644
+ ? h('p', { className: 'dtu-status' }, '这条消息之前没有可用快照,无法恢复文件,将仅创建新会话。') : null,
645
+ (!loading && !previewError && !noSnapshot && !noBaseline && changes.length === 0)
666
646
  ? h('p', { className: 'dtu-status' }, '这条消息之前没有需要恢复的文件。') : null,
667
- (!loading && !previewError && changes.length > 0)
647
+ (!loading && !previewError && !noSnapshot && !noBaseline && changes.length > 0)
668
648
  ? h('div', { className: 'dtu-section' },
669
649
  h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
670
650
  h('div', { className: 'dtu-files' },
@@ -779,7 +759,7 @@ window.__ModuleLoader__.load({
779
759
  }
780
760
 
781
761
  exports.apply = apply
782
- exports.inject = ['slots', 'sessions', 'conversation']
762
+ exports.inject = ['slots', 'conversation', 'uiWorkspace']
783
763
  return module.exports
784
764
  },
785
765
  })
package/index.js CHANGED
@@ -41,8 +41,16 @@ export const name = 'turn-undo'
41
41
  export { SnapshotStore }
42
42
 
43
43
  // Injected services (via ctx.inject in apply()): webServer, sessions,
44
- // sessionQuery, agents, sessionController.
45
- export const inject = ['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController']
44
+ // sessionQuery, agents, sessionController, workspaceRegistry.
45
+ export const inject = [
46
+ 'webServer',
47
+ 'sessions',
48
+ 'sessionQuery',
49
+ 'agents',
50
+ 'sessionTitle',
51
+ 'sessionController',
52
+ 'workspaceRegistry',
53
+ ]
46
54
 
47
55
  // ---- Configuration defaults ----
48
56
  const DEFAULT_SNAPSHOT_TTL_DAYS = 7
@@ -51,6 +59,12 @@ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024 // 10 MB
51
59
  const DEFAULT_MAX_FILES_PER_SNAPSHOT = 10000
52
60
  const DEFAULT_MAX_SNAPSHOT_BYTES = 500 * 1024 * 1024 // 500 MB
53
61
  const DEFAULT_SNAPSHOT_DELAY_MS = 250
62
+ // Object-store GC: unreferenced objects are only reclaimed once they are older
63
+ // than the grace window (a capture writes objects before its manifest, so a
64
+ // young unreferenced object may still be in flight). The sweep itself is
65
+ // throttled because it stats every object and parses every surviving manifest.
66
+ const DEFAULT_OBJECT_GC_GRACE_MS = 60 * 60 * 1000
67
+ const DEFAULT_OBJECT_GC_INTERVAL_MS = 6 * 60 * 60 * 1000
54
68
  const DEFAULT_EXCLUDES = [
55
69
  'node_modules/',
56
70
  '.git/',
@@ -187,6 +201,9 @@ class SnapshotStore {
187
201
  this.maxBytes = cfg.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES
188
202
  this.ttlDays = cfg.snapshotTtlDays ?? DEFAULT_SNAPSHOT_TTL_DAYS
189
203
  this.maxSnapshots = cfg.maxSnapshotsPerSession ?? DEFAULT_MAX_SNAPSHOTS_PER_SESSION
204
+ this.objectGcGraceMs = cfg.objectGcGraceMs ?? DEFAULT_OBJECT_GC_GRACE_MS
205
+ this.objectGcIntervalMs = cfg.objectGcIntervalMs ?? DEFAULT_OBJECT_GC_INTERVAL_MS
206
+ this.lastObjectSweep = 0
190
207
  this.objDir = join(this.base, 'objects')
191
208
  this.snapDir = join(this.base, 'snapshots')
192
209
  }
@@ -511,20 +528,6 @@ class SnapshotStore {
511
528
  return result
512
529
  }
513
530
 
514
- /** Read manifest entries into a map of path -> content */
515
- readManifestContents(manifest) {
516
- const contents = {}
517
- for (const [rel, entry] of Object.entries(manifest)) {
518
- if (entry.kind === 'file' && entry.hash) {
519
- const content = this.readObjectContent(entry.hash)
520
- if (content !== null) {
521
- contents[rel] = content
522
- }
523
- }
524
- }
525
- return contents
526
- }
527
-
528
531
  /** Load snapshot manifests for a session, oldest first. */
529
532
  loadChain(sessionId) {
530
533
  const dir = join(this.snapDir, sessionId)
@@ -649,6 +652,86 @@ class SnapshotStore {
649
652
  try { unlinkSync(join(dir, remaining[i])) } catch {}
650
653
  }
651
654
  }
655
+ // Manifests are pruned above; reclaim the objects they no longer reference.
656
+ // Without this the content-addressed store grows forever (an observed store
657
+ // reached 983 MB / 40k files whose manifests had long been deleted).
658
+ try { this.sweepObjects() } catch (e) {
659
+ console.warn('[turn-undo] object sweep failed:', e.message)
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Mark-and-sweep the content-addressed object store.
665
+ *
666
+ * Every hash reachable from a surviving manifest is marked; unreferenced
667
+ * objects older than the grace window are deleted. Objects younger than the
668
+ * grace window are kept because capture() writes objects before it writes
669
+ * the manifest that references them — deleting those would corrupt an
670
+ * in-flight snapshot.
671
+ *
672
+ * Throttled to `objectGcIntervalMs` since the mark phase parses every
673
+ * surviving manifest.
674
+ * @param opts - `force` bypasses the throttle (tests / explicit invocation);
675
+ * `dryRun` counts what would be reclaimed without deleting anything.
676
+ * @returns Counts, or null when the throttle skipped this call.
677
+ */
678
+ sweepObjects(opts = {}) {
679
+ const now = Date.now()
680
+ if (!opts.force && now - this.lastObjectSweep < this.objectGcIntervalMs) return null
681
+ this.lastObjectSweep = now
682
+
683
+ // ── Mark ──────────────────────────────────────────────────────────
684
+ const referenced = new Set()
685
+ let unreadable = 0
686
+ if (existsSync(this.snapDir)) {
687
+ for (const sessionId of readdirSync(this.snapDir)) {
688
+ const dir = join(this.snapDir, sessionId)
689
+ let dirStat
690
+ try { dirStat = statSync(dir) } catch { continue }
691
+ if (!dirStat.isDirectory()) continue
692
+ for (const f of readdirSync(dir)) {
693
+ if (!f.endsWith('.json')) continue
694
+ const data = readJsonFile(join(dir, f))
695
+ if (!data || !data.manifest) { unreadable++; continue }
696
+ for (const entry of Object.values(data.manifest)) {
697
+ if (entry && typeof entry.hash === 'string') referenced.add(entry.hash)
698
+ }
699
+ }
700
+ }
701
+ }
702
+
703
+ // Fail-safe: an unparseable manifest would contribute no marks, so its
704
+ // objects would look unreferenced and be deleted — silently destroying a
705
+ // snapshot that is merely unreadable. Abort rather than sweep on a
706
+ // possibly-incomplete mark set.
707
+ if (unreadable > 0) {
708
+ console.warn(`[turn-undo] object sweep skipped: ${unreadable} manifest(s) unreadable`)
709
+ return { removed: 0, freedBytes: 0, keptYoung: 0, referenced: referenced.size, aborted: 'unreadable-manifest' }
710
+ }
711
+
712
+ // ── Sweep ─────────────────────────────────────────────────────────
713
+ let removed = 0
714
+ let freedBytes = 0
715
+ let keptYoung = 0
716
+ if (!existsSync(this.objDir)) return { removed, freedBytes, keptYoung, referenced: referenced.size }
717
+ for (const name of readdirSync(this.objDir)) {
718
+ if (referenced.has(name)) continue
719
+ const abs = join(this.objDir, name)
720
+ let st
721
+ try { st = statSync(abs) } catch { continue }
722
+ if (!st.isFile()) continue
723
+ if (now - st.mtimeMs < this.objectGcGraceMs) { keptYoung++; continue }
724
+ if (opts.dryRun) { removed++; freedBytes += st.size; continue }
725
+ try {
726
+ unlinkSync(abs)
727
+ removed++
728
+ freedBytes += st.size
729
+ } catch {}
730
+ }
731
+ if (removed > 0) {
732
+ console.info(`[turn-undo] object sweep reclaimed ${removed} objects (${Math.round(freedBytes / 1048576)} MB), kept ${keptYoung} young, ${referenced.size} referenced`)
733
+ }
734
+ return { removed, freedBytes, keptYoung, referenced: referenced.size }
652
735
  }
653
736
  }
654
737
 
@@ -693,23 +776,44 @@ function resolveForkBoundary(source, messageSeq, promptText) {
693
776
  return { boundary: null, turn: null, cwd, reason: 'no-turn-start' }
694
777
  }
695
778
  const previousEnd = events.findLast(e => e.type === 'turn/end' && e.seq < start.seq)
779
+
780
+ // 第一条消息(无前一个 turn/end)也必须 fork:边界取 turn/start 前
781
+ // 最近的一个「索引==seq」有效事件(会话初始化完成处),子会话继承
782
+ // workspace/preset/cwd,而不是 create() 一个空白会话 —— 这正是
783
+ // 「点击撤销后直接创建新会话、没有 fork 出会话」的根因。
784
+ let boundary = previousEnd ? previousEnd.seq : null
785
+ if (boundary !== null && !(events[boundary] && events[boundary].seq === boundary)) {
786
+ boundary = null
787
+ }
788
+ if (boundary === null) {
789
+ const startIdx = events.indexOf(start)
790
+ for (let i = startIdx - 1; i >= 0; i--) {
791
+ if (events[i] && events[i].seq === i) { boundary = i; break }
792
+ }
793
+ }
696
794
  return {
697
- boundary: previousEnd ? previousEnd.seq : null,
795
+ boundary,
698
796
  turn,
699
797
  cwd,
700
- reason: previousEnd ? 'ok' : 'no-previous-end',
798
+ reason: boundary !== null ? 'ok' : 'no-previous-end',
701
799
  }
702
800
  }
703
801
 
704
802
  // ── Strategy 2: text-based fallback ────────────────────────────────
803
+ // Only reached for event streams that carry no usable `seq` (persisted
804
+ // sessions). When the stream DOES carry seqs but none is this user message,
805
+ // the requested point genuinely does not exist — guessing a different
806
+ // message here would silently restore the workspace to the wrong turn.
807
+ const seqsAvailable = events.some(e => typeof e.seq === 'number')
808
+ if (seqsAvailable && typeof messageSeq === 'number') {
809
+ return { boundary: null, turn: null, cwd, reason: 'no-matching-message' }
810
+ }
811
+
705
812
  // Use promptText to match the user message, avoiding the bug of
706
813
  // treating messageSeq as an array index when seq is unavailable.
707
- //
708
- // If promptText is not provided, find the latest user message as a
709
- // safe fallback.
710
814
  if (promptText) {
711
815
  const normalizedPromptText = promptText.trim().substring(0, 200).toLowerCase()
712
- message = events.find(e => (
816
+ const candidates = events.filter(e => (
713
817
  e.type === 'user/message'
714
818
  && e.data?.source && typeof e.data.source === 'object'
715
819
  && e.data.source.kind === 'user'
@@ -721,20 +825,13 @@ function resolveForkBoundary(source, messageSeq, promptText) {
721
825
  return text.includes(normalizedPromptText) || normalizedPromptText.includes(text)
722
826
  })
723
827
  ))
724
- }
725
-
726
- // If promptText matching failed or not provided, find the latest user message.
727
- if (!message) {
728
- for (let i = events.length - 1; i >= 0; i--) {
729
- const e = events[i]
730
- if (e.type === 'turn/start') break
731
- if (e.type === 'user/message'
732
- && e.data?.source && typeof e.data.source === 'object'
733
- && e.data.source.kind === 'user') {
734
- message = e
735
- break
736
- }
828
+ // Ambiguous text must not be resolved by position: the first match is
829
+ // routinely an earlier prompt that merely contains the same words
830
+ // ("继续" and the like).
831
+ if (candidates.length > 1) {
832
+ return { boundary: null, turn: null, cwd, reason: 'ambiguous-message' }
737
833
  }
834
+ if (candidates.length === 1) message = candidates[0]
738
835
  }
739
836
 
740
837
  if (!message) return { boundary: null, turn: null, cwd, reason: 'no-user-message' }
@@ -964,20 +1061,55 @@ async function executeSurfaceRewind(agent, targetSeq) {
964
1061
  * no completed turn before it (e.g. the very first user message).
965
1062
  * @returns the new child session id.
966
1063
  */
1064
+ /**
1065
+ * Find the workspace that owns `sessionId`, so a `cwd`-less `create()` can pass
1066
+ * `workspaceId` and get attached to the workspace catalogue (visible in the
1067
+ * sidebar). Mirrors upstream `SessionController.forkWorkspace`: only sessions
1068
+ * already indexed as live are visible through `WorkspaceRegistry.list()`.
1069
+ * @returns the owning workspace id, or `undefined` when the session is not
1070
+ * indexed (caller then falls back to a `cwd`-only create).
1071
+ */
1072
+ function findWorkspaceIdForSession(ctx, sessionId) {
1073
+ try {
1074
+ const registry = ctx.workspaceRegistry
1075
+ if (!registry || typeof registry.list !== 'function') return undefined
1076
+ for (const workspace of registry.list()) {
1077
+ const ids = workspace && workspace.sessionIds
1078
+ if (Array.isArray(ids) && ids.includes(sessionId)) return workspace.id
1079
+ }
1080
+ } catch (e) {
1081
+ console.warn('[turn-undo] Workspace lookup failed (falling back to cwd):', e.message)
1082
+ }
1083
+ return undefined
1084
+ }
1085
+
967
1086
  async function forkAndMarkUndone(ctx, sessionId, messageSeq, promptText) {
968
1087
  const source = await readSession(ctx, sessionId)
969
1088
  if (!source) throw new Error('source session not found')
970
1089
  const b = resolveForkBoundary(source, messageSeq, promptText)
971
1090
 
1091
+ console.info(
1092
+ '[turn-undo] resolveBoundary session=' + sessionId,
1093
+ 'messageSeq=' + messageSeq,
1094
+ 'boundary=' + String(b.boundary),
1095
+ 'turn=' + String(b.turn),
1096
+ 'reason=' + b.reason,
1097
+ )
972
1098
  let childId
973
1099
  if (typeof b.boundary === 'number') {
1100
+ // fork() 会自动把子会话 attach 到 source 所在的 workspace(见上游
1101
+ // commands.ts forkWorkspace),所以 fork 分支在侧栏可见。
974
1102
  const { sessionId: cid } = await ctx.sessionController.fork({ sessionId, atSeq: b.boundary })
975
1103
  childId = cid
976
1104
  } else {
1105
+ // create() 只有显式给 workspaceId 才会 attachSession;只给 cwd 的会话
1106
+ // 不会进 workspace 目录,GUI 侧栏看不到 —— 这正是「原会话改名了但没有
1107
+ // 新会话出现」的根因。所以先反查 source 所属 workspace。
977
1108
  const cwd = source.header?.cwd
978
1109
  if (!cwd) throw new Error('cannot undo first message without a cwd')
1110
+ const workspaceId = findWorkspaceIdForSession(ctx, sessionId)
979
1111
  const created = await ctx.sessionController.create({
980
- cwd,
1112
+ ...(workspaceId !== undefined ? { workspaceId } : { cwd }),
981
1113
  ...(source.header?.agentPreset ? { agentPreset: source.header.agentPreset } : {}),
982
1114
  })
983
1115
  childId = created.sessionId
@@ -1017,23 +1149,52 @@ function createHandler(ctx, runtime, sessions, agents) {
1017
1149
 
1018
1150
  if (!sessionId) return json(response, 400, { error: 'Missing sessionId' })
1019
1151
 
1152
+ // 单次 readSession 即可:live 会话的 snapshotEvents() 会重放事件,
1153
+ // 每个请求读两遍是纯浪费。
1154
+ const source = await readSession(ctx, sessionId)
1155
+ if (!source) return json(response, 404, { error: 'Session not found' })
1156
+
1020
1157
  let targetTurn = null
1158
+ let unresolved = null
1021
1159
  if (messageSeqParam) {
1022
- const source = await readSession(ctx, sessionId)
1023
- if (source) {
1024
- const b = resolveForkBoundary(source, parseInt(messageSeqParam, 10), promptTextParam)
1025
- // For preview, pass the turn that the user wants to undo (b.turn).
1026
- // The preview function will compare this turn's snapshot with the previous one.
1027
- targetTurn = b.turn !== null && b.turn > 0 ? b.turn : null
1028
- }
1160
+ const b = resolveForkBoundary(source, parseInt(messageSeqParam, 10), promptTextParam)
1161
+ // For preview, pass the turn that the user wants to undo (b.turn).
1162
+ if (b.turn !== null && b.turn > 0) targetTurn = b.turn
1163
+ else unresolved = b.reason ?? 'no-turn'
1029
1164
  }
1030
1165
 
1031
1166
  // Wait for any in-flight snapshot capture so the preview reflects the
1032
1167
  // latest committed workspace state (avoids showing a stale turn/end).
1033
1168
  try { await runtime.waitForSnapshots() } catch {}
1034
- const source = await readSession(ctx, sessionId)
1035
- const cwd = source ? getCwd(source) : undefined
1036
- const preview = runtime.store.preview(sessionId, targetTurn, cwd)
1169
+
1170
+ // 定位不到消息时返回明确失败,而不是 targetTurn=null 的"0 变更"
1171
+ // —— 后者会让弹框显示"无变化",掩盖真实的解析失败。
1172
+ if (unresolved !== null) {
1173
+ return json(response, 200, {
1174
+ ok: false,
1175
+ status: 'unresolved',
1176
+ reason: unresolved,
1177
+ error: '定位不到这条消息对应的对话位置,无法撤销',
1178
+ targetTurn: null,
1179
+ totalChanges: 0,
1180
+ changes: [],
1181
+ })
1182
+ }
1183
+
1184
+ let preview
1185
+ try {
1186
+ preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
1187
+ } catch (e) {
1188
+ logger?.warn?.('[turn-undo] preview failed:', e.message)
1189
+ preview = {
1190
+ ok: false,
1191
+ status: 'preview-failed',
1192
+ error: '预览失败:' + e.message,
1193
+ targetTurn,
1194
+ totalChanges: 0,
1195
+ changes: [],
1196
+ }
1197
+ }
1037
1198
  return json(response, 200, preview)
1038
1199
  }
1039
1200
 
@@ -1053,15 +1214,25 @@ function createHandler(ctx, runtime, sessions, agents) {
1053
1214
  const source = await readSession(ctx, sessionId)
1054
1215
  if (!source) return json(response, 400, { error: 'Session not found' })
1055
1216
  const cwd = getCwd(source)
1056
- const b = messageSeq !== null ? resolveForkBoundary(source, messageSeq, promptText) : { boundary: null, turn: null, cwd }
1217
+ const b = messageSeq !== null
1218
+ ? resolveForkBoundary(source, messageSeq, promptText)
1219
+ : { boundary: null, turn: null, cwd, reason: 'no-message-seq' }
1057
1220
 
1058
1221
  // Determine restore target: "recover to before this message" means
1059
1222
  // the state at turn/start, i.e. turn T - 0.5. That snapshot captures
1060
1223
  // the workspace before the user sent this message and before any AI work.
1061
- let restoreTurn = null
1062
- if (b.turn !== null && b.turn > 0) {
1063
- restoreTurn = b.turn - 0.5
1224
+ //
1225
+ // 解析不到目标消息时必须中止:继续执行会 fork 出新会话却跳过文件恢复,
1226
+ // 却仍返回 ok:true —— 用户会以为撤销成功。
1227
+ if (b.turn === null || b.turn <= 0) {
1228
+ return json(response, 200, {
1229
+ ok: false,
1230
+ status: 'unresolved',
1231
+ reason: b.reason ?? 'no-turn',
1232
+ error: '定位不到这条消息对应的对话位置,未做任何修改',
1233
+ })
1064
1234
  }
1235
+ const restoreTurn = b.turn - 0.5
1065
1236
 
1066
1237
  // 3. Wait for any pending snapshot to settle so the manifest is on disk.
1067
1238
  // Without this, a restore issued right after turn/end may read an
@@ -1081,12 +1252,7 @@ function createHandler(ctx, runtime, sessions, agents) {
1081
1252
  }
1082
1253
 
1083
1254
  // 4. Restore files to the best snapshot at/before restoreTurn.
1084
- let restoreResult
1085
- if (restoreTurn !== null) {
1086
- restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
1087
- } else {
1088
- restoreResult = { ok: false, error: 'NO_SNAPSHOT' }
1089
- }
1255
+ const restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
1090
1256
 
1091
1257
  // 5. Fork 新会话(DSH 原生,web 的在此分叉按钮同款:ctx.agents.create),
1092
1258
  // 并把旧会话标题加上 (已撤销) 前缀后保留。
@@ -1141,6 +1307,8 @@ export function apply(ctx, config = {}) {
1141
1307
  maxSnapshotBytes: config.maxSnapshotBytes,
1142
1308
  snapshotTtlDays: config.snapshotTtlDays,
1143
1309
  maxSnapshotsPerSession: config.maxSnapshotsPerSession,
1310
+ objectGcGraceMs: config.objectGcGraceMs,
1311
+ objectGcIntervalMs: config.objectGcIntervalMs,
1144
1312
  })
1145
1313
  const runtime = new SnapshotRuntime({
1146
1314
  store,
@@ -1163,70 +1331,92 @@ export function apply(ctx, config = {}) {
1163
1331
  })
1164
1332
 
1165
1333
  // HTTP API endpoints.
1166
- ctx.inject(['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController'], (scope) => {
1167
- scope.effect(() => {
1168
- const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
1169
- scope.webServer.register({
1170
- kind: 'exact',
1171
- path: API_PATH,
1172
- handler,
1173
- })
1174
- return () => {}
1175
- }, 'turn-undo: http-api')
1176
- })
1334
+ ctx.inject(
1335
+ ['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController', 'workspaceRegistry'],
1336
+ (scope) => {
1337
+ scope.effect(() => {
1338
+ const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
1339
+ scope.webServer.register({
1340
+ kind: 'exact',
1341
+ path: API_PATH,
1342
+ handler,
1343
+ })
1344
+ return () => {}
1345
+ }, 'turn-undo: http-api')
1346
+ },
1347
+ )
1177
1348
  }
1178
1349
 
1179
1350
  /**
1180
1351
  * Build a manifest from the current workspace state without persisting it.
1181
- * Used by preview when the target turn is beyond the latest snapshot.
1352
+ *
1353
+ * Entries whose size+mtime+mode already match `referenceManifest` reuse the
1354
+ * recorded hash instead of re-reading the bytes — the same short-circuit
1355
+ * capture() uses. Without it every preview re-hashes the whole workspace.
1356
+ * @param cwd - workspace root being scanned.
1357
+ * @param referenceManifest - a prior manifest (usually the latest snapshot).
1182
1358
  */
1183
- SnapshotStore.prototype.buildLiveManifest = function (cwd) {
1359
+ SnapshotStore.prototype.buildLiveManifest = function (cwd, referenceManifest) {
1184
1360
  const ignore = makeIgnore(cwd, this.excludes)
1185
1361
  const files = this.scan(cwd, ignore)
1186
1362
  if (!files) return null
1187
1363
  const manifest = {}
1188
- for (const p of files) {
1189
- const abs = p
1364
+ for (const abs of files) {
1190
1365
  let st
1191
1366
  try { st = lstatSync(abs) } catch { continue }
1192
1367
  if (!st.isFile()) continue
1193
1368
  const rel = relative(cwd, abs).split('\\').join('/')
1194
- manifest[rel] = {
1195
- kind: 'file',
1196
- hash: hashFile(abs),
1197
- size: st.size,
1198
- mtime: st.mtimeMs,
1199
- mode: st.mode.toString(8).padStart(4, '0'),
1369
+ const mode = st.mode.toString(8).padStart(4, '0')
1370
+ const prev = referenceManifest && referenceManifest[rel]
1371
+ if (prev && prev.kind === 'file' && prev.hash
1372
+ && prev.size === st.size && prev.mtime === st.mtimeMs && prev.mode === mode) {
1373
+ manifest[rel] = prev
1374
+ } else {
1375
+ manifest[rel] = {
1376
+ kind: 'file',
1377
+ hash: hashFile(abs),
1378
+ size: st.size,
1379
+ mtime: st.mtimeMs,
1380
+ mode,
1381
+ }
1200
1382
  }
1201
1383
  }
1202
1384
  return manifest
1203
1385
  }
1204
1386
 
1387
+ /** Read a workspace file's text; '' when unreadable (binary, deleted, permission). */
1388
+ SnapshotStore.prototype.readWorkspaceContent = function (cwd, rel) {
1389
+ try { return readFileSync(resolve(cwd, rel), 'utf-8') } catch { return '' }
1390
+ }
1391
+
1205
1392
  // Add preview helper to SnapshotStore prototype.
1206
- // Returns the files that changed during the target turn (turn/end vs turn/start).
1393
+ // Returns the files that undo (restoring to the target's baseline) will affect.
1207
1394
  SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1208
- const chain = this.loadChain(sessionId)
1209
1395
  if (targetTurn === null) {
1210
1396
  return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
1211
1397
  }
1398
+ const chain = this.loadChain(sessionId)
1399
+ if (chain.length === 0) {
1400
+ return { ok: true, status: 'no-snapshot', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
1401
+ }
1212
1402
 
1213
1403
  // 撤销"发送这条消息之前"会一并回退该消息之后的所有改动,因此影响范围 =
1214
1404
  // baseline(targetTurn 之前最近的快照,即恢复到什么状态)与 latest
1215
1405
  // (会话最新快照,即撤销点之后累积到当前的状态终点)之差。
1216
- // baseline 取小于 targetTurn 的最新快照:正常是该 turn/start 快照
1217
- // (T - 0.5),若中间某 turn 无文件变化没写 manifest,则回退到更早的最近
1218
- // 快照;对于没有前置快照的首条消息,用空对象 {} 作为基线(即回到空状态)。
1219
1406
  let baseline = null
1220
1407
  for (const m of chain) {
1221
1408
  if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
1222
1409
  baseline = m
1223
1410
  }
1224
1411
  }
1225
- const baselineManifest = baseline ? baseline.manifest : {}
1226
1412
 
1227
- if (chain.length === 0) {
1228
- return { ok: true, status: 'ready', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
1413
+ // 目标消息早于最早可用快照:既无法描述也无法执行恢复。把当前所有文件
1414
+ // 都列成 "created" 会宣称"恢复到空工作区",而 restore() 随后必以
1415
+ // NO_SNAPSHOT 拒绝 —— 必须明确报告无基线,而不是给出误导性的文件清单。
1416
+ if (baseline === null) {
1417
+ return { ok: true, status: 'no-baseline', targetTurn, totalChanges: 0, changes: [], noBaseline: true }
1229
1418
  }
1419
+ const baselineManifest = baseline.manifest
1230
1420
 
1231
1421
  // 会话最新快照 = 撤销点之后所有改动的累积终点。
1232
1422
  const latest = chain[chain.length - 1]
@@ -1235,55 +1425,54 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
1235
1425
  // yet or snapshots were skipped), compare baseline against the CURRENT
1236
1426
  // workspace state instead of the stale latest snapshot. This ensures the
1237
1427
  // preview shows meaningful changes even when the active turn has no snapshot.
1238
- let latestManifest
1428
+ let latestManifest = latest.manifest
1429
+ let latestIsLive = false
1239
1430
  if (targetTurn > latest.turn && cwd) {
1240
- const live = this.buildLiveManifest(cwd)
1241
- latestManifest = live || latest.manifest
1242
- } else {
1243
- latestManifest = latest.manifest
1431
+ const live = this.buildLiveManifest(cwd, latest.manifest)
1432
+ if (live) {
1433
+ latestManifest = live
1434
+ latestIsLive = true
1435
+ }
1244
1436
  }
1245
1437
 
1246
- // Calculate changes between latest and baseline: these are the files that
1247
- // undo (restoring to baseline) will affect every change made at or after
1248
- // the target turn.
1249
- const changes = []
1250
-
1251
- // Read manifest contents for diff computation
1252
- const baselineContents = this.readManifestContents(baselineManifest)
1253
- const latestContents = this.readManifestContents(latestManifest)
1254
-
1438
+ // ── Collect changed paths FIRST ───────────────────────────────────────
1439
+ // Contents are read only for files whose hash differs; reading every object
1440
+ // in both manifests made one preview cost hundreds of file reads.
1255
1441
  const deleted = []
1256
1442
  const created = []
1257
-
1258
- // Collect deleted files
1443
+ const modified = []
1259
1444
  for (const rel of Object.keys(baselineManifest)) {
1260
- if (!latestManifest[rel]) {
1261
- changes.push({ path: rel, kind: 'deleted' })
1262
- deleted.push({ path: rel, hash: baselineManifest[rel].hash })
1263
- }
1445
+ if (!latestManifest[rel]) deleted.push({ path: rel, hash: baselineManifest[rel].hash })
1264
1446
  }
1265
-
1266
- // Collect created and modified files
1267
1447
  for (const rel of Object.keys(latestManifest)) {
1268
1448
  const entry = latestManifest[rel]
1269
- if (!baselineManifest[rel]) {
1270
- changes.push({ path: rel, kind: 'created' })
1271
- created.push({ path: rel, hash: entry.hash })
1272
- } else {
1273
- const prevEntry = baselineManifest[rel]
1274
- if (entry.hash !== prevEntry.hash) {
1275
- const oldContent = baselineContents[rel] || ''
1276
- const newContent = latestContents[rel] || ''
1277
- const oldLines = oldContent.split('\n')
1278
- const newLines = newContent.split('\n')
1279
- const diff = this.generateDiff(oldLines, newLines, 10)
1280
- changes.push({
1281
- path: rel,
1282
- kind: 'modified',
1283
- diff: { oldLines: oldLines.length, newLines: newLines.length, hunks: diff }
1284
- })
1285
- }
1286
- }
1449
+ const prev = baselineManifest[rel]
1450
+ if (!prev) created.push({ path: rel, hash: entry.hash })
1451
+ else if (entry.hash !== prev.hash) modified.push({ path: rel, oldHash: prev.hash, newHash: entry.hash })
1452
+ }
1453
+
1454
+ const changes = []
1455
+ for (const d of deleted) changes.push({ path: d.path, kind: 'deleted' })
1456
+ for (const c of created) changes.push({ path: c.path, kind: 'created' })
1457
+ for (const m of modified) {
1458
+ const oldContent = this.readObjectContent(m.oldHash) || ''
1459
+ // A live entry's bytes were never written to the object store; read the
1460
+ // working tree instead, or the diff would show every old line as removed.
1461
+ const newContent = latestIsLive
1462
+ ? this.readWorkspaceContent(cwd, m.path)
1463
+ : (this.readObjectContent(m.newHash) || '')
1464
+ const oldLines = oldContent.split('\n')
1465
+ const newLines = newContent.split('\n')
1466
+ // generateDiff is O(n*m) DP; skip hunks for pathologically large files
1467
+ // rather than allocating a multi-hundred-MB table on a preview click.
1468
+ const tooLarge = oldLines.length * newLines.length > 4_000_000
1469
+ changes.push({
1470
+ path: m.path,
1471
+ kind: 'modified',
1472
+ diff: tooLarge
1473
+ ? { oldLines: oldLines.length, newLines: newLines.length, hunks: [], truncated: true }
1474
+ : { oldLines: oldLines.length, newLines: newLines.length, hunks: this.generateDiff(oldLines, newLines, 10) },
1475
+ })
1287
1476
  }
1288
1477
 
1289
1478
  // Detect rename/move: same content hash, different path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-undo",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Undo to any point in a DSH conversation — revert files and fork a new session",
5
5
  "type": "module",
6
6
  "main": "index.js",