dsh-vscode-mode 0.14.2 → 0.14.4

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/src/revert.ts CHANGED
@@ -7,6 +7,7 @@ import type { DiffRecord } from './shared/types.js'
7
7
  import type { Ctx, Session } from './store.js'
8
8
  import { policyOf, resolveTarget } from './store.js'
9
9
  import { applyLocations, locateHunks, preciseHunk } from './shared/diff.js'
10
+ import { debugRecord } from './debugLog.js'
10
11
 
11
12
  /** 回滚结果;stale=true 表示该 hunk 的新文本已不在文件中(无可回滚内容,不算失败)。 */
12
13
  export type Result = { ok: true } | { ok: false; error: string; stale?: boolean }
@@ -82,25 +83,116 @@ export async function revertCall(ctx: Ctx, session: Session, record: DiffRecord)
82
83
  }
83
84
 
84
85
  /** 单 hunk 回滚:用 callHunk(edit 单 hunk 精确串)经 fs.editText 反替换。 */
85
- export async function revertHunk(ctx: Ctx, session: Session, record: DiffRecord, idx: number): Promise<Result> {
86
+ export async function revertHunk(ctx: Ctx, session: Session, record: DiffRecord, idx: number, traceId?: string): Promise<Result> {
86
87
  const fs = ctx.get('fs')
87
88
  if (!fs) return { ok: false, error: '回滚不可用:缺少 fs' }
88
89
  const precise = preciseHunk(record, idx)
89
90
  if (!precise) return { ok: false, error: '找不到该差异块' }
91
+ // #region debug log
92
+ const started = traceId ? performance.now() : 0
93
+ let readMs = 0
94
+ let locateMs = 0
95
+ let stage = 'resolve'
96
+ // #endregion
90
97
  try {
91
98
  const target = await resolveTarget(ctx, session, record.path)
99
+ stage = 'stat'
92
100
  const info = await fs.stat(target)
93
101
  if (!info || info.type !== 'file') return { ok: false, error: '回滚失败:文件不存在' }
94
102
  if ((info.size ?? 0) > 8 * 1024 * 1024) return { ok: false, error: '回滚失败:文件过大,无法安全定位' }
103
+ stage = 'read'
95
104
  const content = await fs.readText(target)
105
+ // #region debug log
106
+ if (traceId) readMs = performance.now() - started
107
+ const locateStart = traceId ? performance.now() : 0
108
+ // #endregion
109
+ stage = 'locate'
96
110
  const location = locateHunks(content, [precise])[0]
111
+ // #region debug log
112
+ if (traceId) locateMs = performance.now() - locateStart
113
+ // #endregion
97
114
  // 新文本已不在文件中(被后续修改覆盖/撤销):不采纳的目标已达成,标记 stale 供调用方按"已回滚"记录决策
98
115
  if (!location?.matched) return { ok: false, stale: true, error: '回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)' }
99
116
  const result = applyLocations(content, [location], true)
117
+ stage = 'write'
100
118
  await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session))
101
119
  return { ok: true }
102
120
  } catch (error) {
103
121
  return { ok: false, error: '回滚失败:该区域可能已被后续修改影响(' + String(error) + ')' }
122
+ } finally {
123
+ // #region debug log
124
+ if (traceId) {
125
+ try { debugRecord(ctx, session.header?.cwd ?? '', `[DEBUG diffRevert] trace=${traceId} mode=single stage=${stage} readMs=${readMs.toFixed(1)} locateMs=${locateMs.toFixed(1)} totalMs=${(performance.now() - started).toFixed(1)}`) } catch { /* 诊断不可影响回滚 */ }
126
+ }
127
+ // #endregion
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 同一记录的连续 hunk 在内存里逐项回滚,只对文件读写各一次。
133
+ * 不替代单条回滚;调用方只传确定同路径且非远端的连续项。
134
+ * @author ddj 2026年09月29号
135
+ * @param ctx DSH 上下文
136
+ * @param session 会话
137
+ * @param record 差异记录
138
+ * @param indices 按决策顺序排列的 hunk 索引
139
+ * @param traceId 可选的诊断追踪号,仅开启诊断时传入
140
+ * @returns 每个 hunk 对应的独立执行结果
141
+ */
142
+ export async function revertHunks(ctx: Ctx, session: Session, record: DiffRecord, indices: number[], traceId?: string): Promise<Result[]> {
143
+ const fs = ctx.get('fs')
144
+ if (!fs) return indices.map(() => ({ ok: false, error: '回滚不可用:缺少 fs' }))
145
+ const failAll = (error: string): Result[] => indices.map((idx) => preciseHunk(record, idx)
146
+ ? { ok: false, error } : { ok: false, error: '找不到该差异块' })
147
+ try {
148
+ // #region debug log
149
+ const started = traceId ? performance.now() : 0
150
+ // #endregion
151
+ const target = await resolveTarget(ctx, session, record.path)
152
+ const info = await fs.stat(target)
153
+ if (!info || info.type !== 'file') return failAll('回滚失败:文件不存在')
154
+ if ((info.size ?? 0) > 8 * 1024 * 1024) return failAll('回滚失败:文件过大,无法安全定位')
155
+ let content = await fs.readText(target)
156
+ // #region debug log
157
+ const readMs = traceId ? performance.now() - started : 0
158
+ const locateStart = traceId ? performance.now() : 0
159
+ // #endregion
160
+ let size = info.size ?? Buffer.byteLength(content)
161
+ const results: Result[] = []
162
+ for (const idx of indices) {
163
+ const hunk = preciseHunk(record, idx)
164
+ if (!hunk) { results.push({ ok: false, error: '找不到该差异块' }); continue }
165
+ if (size > 8 * 1024 * 1024) {
166
+ results.push({ ok: false, error: '回滚失败:文件过大,无法安全定位' })
167
+ continue
168
+ }
169
+ const location = locateHunks(content, [hunk])[0]
170
+ if (!location?.matched) {
171
+ results.push({ ok: false, stale: true, error: '回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)' })
172
+ continue
173
+ }
174
+ size += Buffer.byteLength(hunk.oldText ?? '') - Buffer.byteLength(content.slice(location.start, location.end))
175
+ content = applyLocations(content, [location], true).content
176
+ results.push({ ok: true })
177
+ }
178
+ // #region debug log
179
+ const locateMs = traceId ? performance.now() - locateStart : 0
180
+ const writeStart = traceId ? performance.now() : 0
181
+ // #endregion
182
+ if (results.some((result) => result.ok)) {
183
+ await fs.writeText(target, content, void 0, void 0, policyOf(ctx, session))
184
+ }
185
+ // #region debug log
186
+ try {
187
+ if (traceId) {
188
+ const totalMs = performance.now() - started
189
+ debugRecord(ctx, session.header?.cwd ?? '', `[DEBUG diffRevert] trace=${traceId} mode=batch hunks=${indices.length} bytes=${info.size ?? 0} readMs=${readMs.toFixed(1)} locateMs=${locateMs.toFixed(1)} writeMs=${(performance.now() - writeStart).toFixed(1)} totalMs=${totalMs.toFixed(1)}`)
190
+ }
191
+ } catch { /* 诊断不可影响回滚 */ }
192
+ // #endregion
193
+ return results
194
+ } catch (error) {
195
+ return failAll('回滚失败:该区域可能已被后续修改影响(' + String(error) + ')')
104
196
  }
105
197
  }
106
198
 
package/src/rpc.ts CHANGED
@@ -14,26 +14,26 @@ import {
14
14
  appendArchiveEntries,
15
15
  archiveRecords,
16
16
  autoArchiveStale,
17
- parseArchive,
18
17
  policyOf,
19
- readArchiveText,
20
18
  resolveTarget,
21
19
  saveBucket,
22
20
  } from './store.js'
23
21
  import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
22
  import { basename, dirname, join } from 'node:path'
25
23
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
24
+ import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
25
+ import { log } from './log.js'
26
26
  import type { Registry } from './registry.js'
27
27
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
28
28
  import type { SearchOrchestrator } from './search/orchestrator.js'
29
29
  import { newSearcher } from './search/orchestrator.js'
30
30
  import type { ContentSearcher } from './search/content.js'
31
31
  import { newContentSearcher } from './search/content.js'
32
- import { restoreFile, revertCall, revertHunk } from './revert.js'
32
+ import { restoreFile, revertCall, revertHunk, revertHunks, type Result } from './revert.js'
33
33
  import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rules.js'
34
34
  import { isSnippetFilePath, snippetsEntries, snippetsList, snippetsRead, snippetsRemove, snippetsSave } from './snippets.js'
35
35
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
36
- import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
36
+ import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle, mcpSnapshot } from './mcpProject.js'
37
37
  import { normalizeFileOpenTool, FILE_OPEN_DEFAULT, FILE_OPEN_SETTINGS_NS, sectionOf, updateSection } from './fileOpenSettings.js'
38
38
  import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
39
39
  import { shellMenuRegister, shellMenuRemove, shellMenuStatus } from './integrate.js'
@@ -58,6 +58,10 @@ import { settingsDocPath } from './settingsDocPath.js'
58
58
  const staleCheckedAt = new Map<string, number>()
59
59
  /** stale 自动清理最小间隔。 */
60
60
  const STALE_CHECK_MIN_MS = 10_000
61
+ // #region debug log
62
+ /** 诊断由 client 的 edrv.debug 激活,过期后不再产生 Host 耗时日志。 */
63
+ const diffTraceUntil = new Map<string, { until: number; id: string }>()
64
+ // #endregion
61
65
  /**
62
66
  * 无会话期诊断的伪 cwd:冷启动早期本插件客户端就会上报界面状态镜像事件
63
67
  * (此时 `sessions` 可能尚未就绪,`requireSession` 会失败),退回本固定值落盘
@@ -237,7 +241,7 @@ async function afterManualSave(
237
241
  const done: DiffRecord[] = []
238
242
  for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec)
239
243
  if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, '被手动编辑覆盖')
240
- else await saveBucket(ctx, sc.cwd, bucket, sc.session)
244
+ else await saveBucket(ctx, sc.cwd, bucket, sc.session, { upserts: [...bucket.values()].filter((rec) => rec.path === path), removed: [] })
241
245
  }
242
246
  }
243
247
 
@@ -259,18 +263,110 @@ async function applyDecisions(
259
263
  ): Promise<DecideResult[]> {
260
264
  const results: DecideResult[] = []
261
265
  const resolved: DiffRecord[] = []
266
+ const touched = new Map<string, DiffRecord>()
262
267
  const ws = findRemoteWs(cwd)
263
268
  let changed = false
264
- for (const item of items) {
269
+ const grouped = new Map<number, Result>()
270
+ // #region debug log
271
+ const trace = diffTraceUntil.get(cwd)
272
+ const traceId = trace && trace.until > Date.now() ? trace.id : null
273
+ const started = traceId ? performance.now() : 0
274
+ let revertMs = 0
275
+ let revertGroups = 0
276
+ // #endregion
277
+ for (let index = 0; index < items.length; index++) {
278
+ const item = items[index]
265
279
  const record = bucket.get(item.callId)
266
280
  if (!record) {
267
281
  results.push({ callId: item.callId, ok: false, error: '记录不存在' })
268
282
  continue
269
283
  }
284
+ if (record.archived) {
285
+ results.push({ callId: item.callId, ok: false, error: '记录已归档,无法重复决策' })
286
+ continue
287
+ }
288
+ // 重试已执行的决定只补持久化,不能再次修改磁盘上的同一个 hunk。
289
+ const previous = item.scope === 'hunk'
290
+ ? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call
291
+ const alreadyDone = previous === item.decision
292
+ if (alreadyDone) {
293
+ if (recordResolved(record) && !record.archived) resolved.push(record)
294
+ changed = true
295
+ touched.set(record.callId, record)
296
+ results.push({ callId: item.callId, ok: true, record: recView(record, ws) })
297
+ continue
298
+ }
270
299
  let revertedStale = false
271
- if (item.decision === 'rejected') {
300
+ if (item.decision === 'rejected' && !alreadyDone) {
272
301
  const scope: RpcScope = item.scope ?? 'call'
273
- const outcome = scope === 'hunk' ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1) : await revertCall(ctx, session, record)
302
+ const hunkIndex = scope === 'hunk' ? item.hunkIndex ?? -1 : -1
303
+ let existingIntent: ReturnType<typeof revertState>
304
+ try { existingIntent = grouped.has(index) ? null : revertState(cwd, record.callId, scope, hunkIndex) }
305
+ catch (error) {
306
+ results.push({ callId: item.callId, ok: false, error: '回滚状态读取失败,未修改文件:' + String(error) })
307
+ continue
308
+ }
309
+ if (existingIntent === 'started') {
310
+ results.push({ callId: item.callId, ok: false, error: '回滚结果不确定,请人工核对文件后处理;已阻止再次修改文件' })
311
+ continue
312
+ }
313
+ if (existingIntent === 'applied') {
314
+ markDecision(record, scope, item.hunkIndex, item.decision)
315
+ changed = true
316
+ touched.set(record.callId, record)
317
+ if (recordResolved(record)) resolved.push(record)
318
+ results.push({ callId: item.callId, ok: true, record: recView(record, ws) })
319
+ continue
320
+ }
321
+ if (!grouped.has(index)) {
322
+ let last = index + 1
323
+ if (scope === 'hunk' && !ws && record.toolName !== 'remote_ssh_write') {
324
+ while (last < items.length && items[last].callId === item.callId
325
+ && items[last].decision === 'rejected' && items[last].scope === 'hunk') last++
326
+ }
327
+ let groupReady = false
328
+ try {
329
+ groupReady = last > index + 1 && items.slice(index + 1, last).every((next) =>
330
+ revertState(cwd, record.callId, 'hunk', next.hunkIndex ?? -1) === null)
331
+ if (groupReady) startReverts(cwd, record.callId, items.slice(index, last).map((next) => next.hunkIndex ?? -1))
332
+ else setRevertState(cwd, record.callId, scope, hunkIndex, 'started')
333
+ } catch (error) {
334
+ results.push({ callId: item.callId, ok: false, error: '回滚状态无法登记,未修改文件:' + String(error) })
335
+ continue
336
+ }
337
+ if (groupReady) {
338
+ // #region debug log
339
+ const revertStart = traceId ? performance.now() : 0
340
+ // #endregion
341
+ const outcomes = await revertHunks(ctx, session, record, items.slice(index, last).map((next) => next.hunkIndex ?? -1), traceId ?? undefined)
342
+ // #region debug log
343
+ if (traceId) revertMs += performance.now() - revertStart
344
+ revertGroups++
345
+ // #endregion
346
+ outcomes.forEach((outcome, offset) => grouped.set(index + offset, outcome))
347
+ }
348
+ }
349
+ // #region debug log
350
+ const revertStart = traceId ? performance.now() : 0
351
+ // #endregion
352
+ const outcome = grouped.get(index) ?? (scope === 'hunk'
353
+ ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1, traceId ?? undefined)
354
+ : await revertCall(ctx, session, record))
355
+ // #region debug log
356
+ if (traceId && !grouped.has(index)) revertMs += performance.now() - revertStart
357
+ // #endregion
358
+ grouped.delete(index)
359
+ try {
360
+ if (outcome.ok || outcome.stale === true) setRevertState(cwd, record.callId, scope, hunkIndex, 'applied')
361
+ else if (outcome.error === '找不到该差异块' || outcome.error === '回滚失败:文件不存在'
362
+ || outcome.error === '回滚失败:文件过大,无法安全定位') {
363
+ clearRevertState(cwd, record.callId, scope, hunkIndex)
364
+ }
365
+ // 其余失败可能发生在写盘途中:保留 started,阻止不确定状态下重复回滚。
366
+ } catch (error) {
367
+ results.push({ callId: item.callId, ok: false, error: '回滚状态保存失败,请人工核对文件:' + String(error) })
368
+ continue
369
+ }
274
370
  if (!outcome.ok) {
275
371
  if (scope !== 'hunk' || outcome.stale !== true) {
276
372
  results.push({ callId: item.callId, ok: false, error: outcome.error })
@@ -282,16 +378,62 @@ async function applyDecisions(
282
378
  const wasResolved = recordResolved(record)
283
379
  markDecision(record, item.scope ?? 'call', item.hunkIndex, item.decision)
284
380
  changed = true
381
+ touched.set(record.callId, record)
285
382
  if (!wasResolved && recordResolved(record)) resolved.push(record)
286
383
  const result: DecideResult = { callId: item.callId, ok: true, record: recView(record, ws) }
287
384
  if (revertedStale) result.stale = true
288
385
  results.push(result)
289
386
  }
387
+ // #region debug log
388
+ const decisionMs = traceId ? performance.now() - started : 0
389
+ const archiveStart = traceId ? performance.now() : 0
390
+ // #endregion
290
391
  if (resolved.length) {
291
392
  const reason = items.some((item) => item.decision === 'rejected') ? '已处理(回滚)' : '已处理'
292
- await archiveRecords(ctx, session, cwd, bucket, resolved, reason, { deferSave: true })
393
+ try {
394
+ await archiveRecords(ctx, session, cwd, bucket, resolved, reason, { deferSave: true, traceId: traceId ?? undefined, changed: [...touched.values()] })
395
+ } catch (error) {
396
+ const failed = new Set(resolved.map((record) => record.callId))
397
+ for (const result of results) {
398
+ if (!failed.has(result.callId) || !result.ok) continue
399
+ result.ok = false
400
+ result.error = '归档失败:' + String(error)
401
+ delete result.record
402
+ }
403
+ // 决策保留于内存并尝试写回 sidecar,Undo 不再重复反替换。
404
+ if (changed) {
405
+ try { await saveBucket(ctx, cwd, bucket, session, { upserts: [...touched.values()], removed: [] }) }
406
+ catch (saveError) { log.error('归档失败后的 SQLite 补写失败:' + String(saveError)) }
407
+ }
408
+ return results
409
+ }
410
+ }
411
+ // #region debug log
412
+ const archiveMs = traceId ? performance.now() - archiveStart : 0
413
+ const saveStart = traceId ? performance.now() : 0
414
+ // #endregion
415
+ if (changed && !resolved.length) {
416
+ try {
417
+ await saveBucket(ctx, cwd, bucket, session, { upserts: [...touched.values()], removed: [] })
418
+ } catch (error) {
419
+ for (const result of results) {
420
+ if (!result.ok) continue
421
+ result.ok = false
422
+ result.error = '决策记录保存失败:' + String(error)
423
+ delete result.record
424
+ }
425
+ return results
426
+ }
293
427
  }
294
- if (changed) await saveBucket(ctx, cwd, bucket, session)
428
+ // #region debug log
429
+ try {
430
+ if (traceId) {
431
+ const saveMs = performance.now() - saveStart
432
+ const totalMs = performance.now() - started
433
+ debugRecord(ctx, cwd, `[DEBUG diffDecision] trace=${traceId} items=${items.length} rejected=${items.filter((item) => item.decision === 'rejected').length} ok=${results.filter((result) => result.ok).length} stale=${results.filter((result) => result.stale === true).length} grouped=${revertGroups} loopMs=${decisionMs.toFixed(1)} revertMs=${revertMs.toFixed(1)} archiveMs=${archiveMs.toFixed(1)} saveMs=${saveMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`)
434
+ }
435
+ } catch { /* 诊断不可影响决策 */ }
436
+ // #endregion
295
437
  return results
296
438
  }
297
439
 
@@ -447,9 +589,17 @@ export function buildHandlers(
447
589
  // edrv.dap.* 由 createDapRpc 提供(调试会话单例),这里并入。
448
590
  ...((dapHandlers ?? {}) as RpcHandlerMap),
449
591
  'edrv.list': async (args) => {
592
+ // #region debug log
593
+ const start = performance.now()
594
+ let staleMs = 0
595
+ let staleChecked = false
596
+ // #endregion
450
597
  const sc = await requireSession(ctx, args.sessionId)
451
598
  if ('err' in sc) return { ok: false, error: sc.err }
452
599
  const bucket = await bucketOf(registry, ctx, sc.cwd)
600
+ // #region debug log
601
+ const loadMs = performance.now() - start
602
+ // #endregion
453
603
  const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null
454
604
  // 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过);
455
605
  // 三个组件各自 5s 全量 list,stale 检查节流到 STALE_CHECK_MIN_MS 一次,
@@ -458,7 +608,14 @@ export function buildHandlers(
458
608
  const now = Date.now()
459
609
  if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
460
610
  staleCheckedAt.set(sc.cwd, now)
611
+ // #region debug log
612
+ staleChecked = true
613
+ const staleStart = performance.now()
614
+ // #endregion
461
615
  await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
616
+ // #region debug log
617
+ staleMs = performance.now() - staleStart
618
+ // #endregion
462
619
  }
463
620
  }
464
621
  const out: RecordView[] = []
@@ -470,6 +627,13 @@ export function buildHandlers(
470
627
  out.push(recView(rec, ws))
471
628
  }
472
629
  out.sort((a, b) => (a.at < b.at ? -1 : 1))
630
+ // #region debug log
631
+ try {
632
+ const totalMs = performance.now() - start
633
+ const trace = diffTraceUntil.get(sc.cwd)
634
+ if (trace && trace.until > Date.now() && (totalMs >= 20 || staleMs >= 10)) debugRecord(ctx, sc.cwd, `[DEBUG diffListHost] trace=${trace.id} count=${out.length} bucket=${bucket.size} checked=${staleChecked} loadMs=${loadMs.toFixed(1)} staleMs=${staleMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`)
635
+ } catch { /* 诊断不可影响列表 */ }
636
+ // #endregion
473
637
  return { ok: true, records: out }
474
638
  },
475
639
  /**
@@ -715,38 +879,38 @@ export function buildHandlers(
715
879
  'edrv.archiveList': async (args) => {
716
880
  const sc = await requireSession(ctx, args.sessionId)
717
881
  if ('err' in sc) return { ok: false, error: sc.err }
718
- const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd)
719
- const ws = findRemoteWs(sc.cwd)
720
- const entries = batches.map((b) => {
721
- const recs = Array.isArray(b.records) ? b.records : []
722
- const sum = recs.reduce((s, r) => {
723
- const sm = r.summary || { accepted: 0, rejected: 0, pending: 0, superseded: false }
724
- s.accepted += sm.accepted || 0
725
- s.rejected += sm.rejected || 0
726
- s.pending += sm.pending || 0
727
- if (sm.superseded) s.superseded++
728
- return s
729
- }, { accepted: 0, rejected: 0, pending: 0, superseded: 0 })
730
- return {
731
- at: b.at,
732
- lastAt: b.lastAt || b.at,
733
- path: b.path,
734
- ...(ws ? { displayPath: remoteDisplayOf(b.path, ws) ?? undefined } : {}),
735
- batch: b.batch ?? null,
736
- reason: b.reason ?? null,
737
- nRecords: recs.length,
738
- summary: sum,
739
- }
740
- })
741
- entries.sort((a, b) => (Number(b.batch ?? -1) - Number(a.batch ?? -1)) || (a.at < b.at ? 1 : -1))
742
- return { ok: true, entries }
882
+ try {
883
+ const ws = findRemoteWs(sc.cwd)
884
+ const entries = listArchives(sc.cwd).map((item) => ({
885
+ ...item, ...(ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? undefined } : {}),
886
+ }))
887
+ return { ok: true, entries }
888
+ } catch (error) {
889
+ return { ok: false, error: '归档列表读取失败:' + String(error) }
890
+ }
891
+ },
892
+ 'edrv.archivePage': async (args) => {
893
+ const sc = await requireSession(ctx, args.sessionId)
894
+ if ('err' in sc) return { ok: false, error: sc.err }
895
+ try {
896
+ const ws = findRemoteWs(sc.cwd)
897
+ const page = pageArchives(sc.cwd, args.cursor, args.limit)
898
+ return { ok: true, entries: page.entries.map((item) => ({
899
+ ...item, ...(ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? undefined } : {}),
900
+ })), nextCursor: page.nextCursor }
901
+ } catch (error) {
902
+ return { ok: false, error: '归档分页读取失败:' + String(error) }
903
+ }
743
904
  },
744
905
  'edrv.archiveRead': async (args) => {
745
906
  const sc = await requireSession(ctx, args.sessionId)
746
907
  if ('err' in sc) return { ok: false, error: sc.err }
747
- const batchPath = args.path
748
- const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && (!batchPath || b.path === batchPath))
749
- return { ok: true, batches }
908
+ try {
909
+ if (args.batch !== undefined && (!args.path || (args.batch !== null && !Number.isInteger(args.batch)))) return { ok: false, error: '归档批次参数无效' }
910
+ return { ok: true, batches: readArchives(sc.cwd, args.path, args.batch) }
911
+ } catch (error) {
912
+ return { ok: false, error: '归档详情读取失败:' + String(error) }
913
+ }
750
914
  },
751
915
  'edrv.rollback': async (args) => {
752
916
  const sc = await requireSession(ctx, args.sessionId)
@@ -755,37 +919,41 @@ export function buildHandlers(
755
919
  if (!fs) return { ok: false, error: '缺少 fs' }
756
920
  const batch = args.batch
757
921
  const bucket = await bucketOf(registry, ctx, sc.cwd)
758
- let affected: DiffRecord[] = []
759
- let beforeRec: DiffRecord | null = null
760
- if (batch !== undefined) {
761
- const all = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path)
762
- const recs: DiffRecord[] = []
763
- for (const b of all) if (b.batch === batch) for (const r of (b.records || [])) recs.push(r as unknown as DiffRecord)
764
- if (!recs.length) return { ok: false, error: '归档中找不到该批次' }
765
- recs.sort((a, b2) => (a.at < b2.at ? -1 : 1))
766
- beforeRec = recs[0]
767
- affected = recs
768
- } else {
769
- for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r)
770
- if (!affected.length) return { ok: false, error: '该文件没有可回滚的差异' }
771
- affected.sort((a, b2) => (a.at < b2.at ? -1 : 1))
772
- beforeRec = affected[0]
773
- }
774
- const rerr = await restoreFile(ctx, sc.session, beforeRec)
775
- if (rerr) return { ok: false, error: rerr }
776
- if (batch === undefined) {
777
- await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, '已回滚')
778
- } else {
779
- const all = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path && b.batch === batch)
780
- const sumRecs = all.reduce((s, b) => s + ((b.records || []).length), 0)
781
- const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ';' : '') + '回滚至本批次前(批次 ' + batch + ')' })
782
- await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, '已回滚(批次 ' + batch + ',' + sumRecs + ' 条)')], sc.session)
783
- // 批次回滚恢复的是旧内容,文件当前活跃差异已失效,一并归档
784
- const activeRecs: DiffRecord[] = []
785
- for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r)
786
- if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, '已回滚(批次回滚覆盖)')
922
+ try {
923
+ let affected: DiffRecord[] = []
924
+ let beforeRec: DiffRecord | null = null
925
+ if (batch !== undefined) {
926
+ const all = readArchives(sc.cwd, args.path, batch)
927
+ const recs: DiffRecord[] = []
928
+ for (const b of all) if (b.batch === batch) for (const r of (b.records || [])) recs.push(r as unknown as DiffRecord)
929
+ if (!recs.length) return { ok: false, error: '归档中找不到该批次' }
930
+ recs.sort((a, b2) => (a.at < b2.at ? -1 : 1))
931
+ beforeRec = recs[0]
932
+ affected = recs
933
+ } else {
934
+ for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r)
935
+ if (!affected.length) return { ok: false, error: '该文件没有可回滚的差异' }
936
+ affected.sort((a, b2) => (a.at < b2.at ? -1 : 1))
937
+ beforeRec = affected[0]
938
+ }
939
+ const rerr = await restoreFile(ctx, sc.session, beforeRec)
940
+ if (rerr) return { ok: false, error: rerr }
941
+ if (batch === undefined) {
942
+ await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, '已回滚')
943
+ } else {
944
+ const all = readArchives(sc.cwd, args.path, batch)
945
+ const sumRecs = all.reduce((s, b) => s + ((b.records || []).length), 0)
946
+ const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ';' : '') + '回滚至本批次前(批次 ' + batch + ')' })
947
+ await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, '已回滚(批次 ' + batch + ',' + sumRecs + ' 条)')], sc.session)
948
+ // 批次回滚恢复的是旧内容,文件当前活跃差异已失效,一并归档
949
+ const activeRecs: DiffRecord[] = []
950
+ for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r)
951
+ if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, '已回滚(批次回滚覆盖)')
952
+ }
953
+ return { ok: true, path: args.path, batch: batch ?? null }
954
+ } catch (error) {
955
+ return { ok: false, error: '回滚/归档失败:' + String(error) }
787
956
  }
788
- return { ok: true, path: args.path, batch: batch ?? null }
789
957
  },
790
958
  'edrv.debug': async (args) => {
791
959
  // 诊断日志:client 上报 → debugLog 模块缓冲批量落盘 ~/.dsh/dsh-vscode-mode/logs/
@@ -798,6 +966,12 @@ export function buildHandlers(
798
966
  debugRecord(ctx, UI_AUDIT_CWD, text, args.level)
799
967
  return { ok: true }
800
968
  }
969
+ // #region debug log
970
+ try {
971
+ const match = /^\[DEBUG diffAction\] stage=start trace=(\d+-\d+) /.exec(text)
972
+ if (match) diffTraceUntil.set(sc.cwd, { until: Date.now() + 15_000, id: match[1] })
973
+ } catch { /* 诊断不可影响日志路由 */ }
974
+ // #endregion
801
975
  debugRecord(ctx, sc.cwd, text, args.level)
802
976
  return { ok: true }
803
977
  },
@@ -991,6 +1165,7 @@ export function buildHandlers(
991
1165
  return { ok: true, from: r.from, to: r.to }
992
1166
  },
993
1167
  'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
1168
+ 'mcp.snapshot': async () => ({ ok: true, ...mcpSnapshot(ctx) }),
994
1169
  'mcp.save': async (args) => {
995
1170
  try { return { ok: true, server: await saveMcp(ctx, args.config) } }
996
1171
  catch (error) { return { ok: false, error: String(error) } }