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.
@@ -127,18 +127,43 @@ export function lineBefore(text: string, index: number): number {
127
127
  return n
128
128
  }
129
129
 
130
- /** 找出文本的全部非重叠出现位置。 */
131
- function allMatches(content: string, needle: string): TextRange[] {
132
- if (!needle.length) return []
133
- const out: TextRange[] = []
134
- let from = 0
135
- while (from <= content.length - needle.length) {
136
- const at = content.indexOf(needle, from)
137
- if (at < 0) break
138
- out.push({ start: at, end: at + needle.length })
139
- from = at + Math.max(1, needle.length)
130
+ /** 每种搜索串只推进一次,保持原来从头开始的非重叠匹配顺序。 */
131
+ interface MatchState {
132
+ ranges: TextRange[]
133
+ next: number
134
+ done: boolean
135
+ }
136
+
137
+ /**
138
+ * 懒扫描候选:先找 cursor 后未占用项,找不到再回退至首个未占用项。
139
+ * @author ddj 2026年09月29号
140
+ * @param content 当前内容
141
+ * @param needle 搜索串
142
+ * @param cursor 前一块的结束位置
143
+ * @param occupied 已选区间
144
+ * @param state 此搜索串已扫描的候选
145
+ * @returns 与完整候选列表双轮查找相同的匹配
146
+ */
147
+ function findCandidate(content: string, needle: string, cursor: number, occupied: TextRange[], state: MatchState): TextRange | undefined {
148
+ if (!needle.length) return undefined
149
+ let fallback: TextRange | undefined
150
+ for (let i = 0; i < state.ranges.length; i++) {
151
+ const range = state.ranges[i]
152
+ if (occupied.some((used) => overlaps(range, used))) continue
153
+ if (range.start >= cursor) return range
154
+ if (!fallback) fallback = range
155
+ }
156
+ while (!state.done) {
157
+ const at = content.indexOf(needle, state.next)
158
+ if (at < 0) { state.done = true; break }
159
+ const range = { start: at, end: at + needle.length }
160
+ state.ranges.push(range)
161
+ state.next = range.end
162
+ if (occupied.some((used) => overlaps(range, used))) continue
163
+ if (range.start >= cursor) return range
164
+ if (!fallback) fallback = range
140
165
  }
141
- return out
166
+ return fallback
142
167
  }
143
168
 
144
169
  /**
@@ -153,6 +178,7 @@ export function locateHunks(content: string, hunks: Hunk[]): HunkLocation[] {
153
178
  const locations: HunkLocation[] = []
154
179
  let cursor = 0
155
180
  const occupied: TextRange[] = []
181
+ const matches = new Map<string, MatchState>()
156
182
  for (let idx = 0; idx < hunks.length; idx++) {
157
183
  const hunk = hunks[idx]
158
184
  const newText = hunk.newText
@@ -163,9 +189,14 @@ export function locateHunks(content: string, hunks: Hunk[]): HunkLocation[] {
163
189
  && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText)
164
190
  ? snapshot
165
191
  : null
166
- const candidates = snapshotMatches ? [snapshotMatches] : allMatches(content, newText)
167
- const candidate = candidates.find((range) => range.start >= cursor && !occupied.some((used) => overlaps(range, used)))
168
- ?? candidates.find((range) => !occupied.some((used) => overlaps(range, used)))
192
+ let candidate: TextRange | undefined
193
+ if (snapshotMatches) {
194
+ if (!occupied.some((used) => overlaps(snapshotMatches, used))) candidate = snapshotMatches
195
+ } else if (newText.length) {
196
+ let state = matches.get(newText)
197
+ if (!state) { state = { ranges: [], next: 0, done: false }; matches.set(newText, state) }
198
+ candidate = findCandidate(content, newText, cursor, occupied, state)
199
+ }
169
200
  if (candidate) {
170
201
  locations.push({ idx, hunk, ...candidate, matched: true })
171
202
  occupied.push(candidate)
@@ -205,15 +236,33 @@ export function replaceRange(content: string, location: HunkLocation, reverse =
205
236
  return content.slice(0, location.start) + replacement + content.slice(location.end)
206
237
  }
207
238
 
208
- /** 按当前内容中出现的位置从后向前应用 hunk,避免前块长度变化影响后块。 */
239
+ /**
240
+ * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
241
+ * @author ddj 2026年09月29号
242
+ * @param content 当前文件内容
243
+ * @param locations 已定位区间
244
+ * @param reverse 是否反向应用
245
+ * @returns 合成内容及失效块索引
246
+ */
209
247
  export function applyLocations(content: string, locations: HunkLocation[], reverse = false): { content: string; stale: number[] } {
210
248
  const stale = locations.filter((location) => !location.matched).map((location) => location.idx)
211
- let out = content
212
249
  const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start)
250
+ const parts: string[] = []
251
+ let end = content.length
213
252
  for (const location of matched) {
214
- const next = replaceRange(out, location, reverse)
215
- if (next === null) stale.push(location.idx)
216
- else out = next
253
+ if (location.start < 0 || location.end > end) {
254
+ let out = content
255
+ for (const item of matched) {
256
+ const next = replaceRange(out, item, reverse)
257
+ if (next === null) stale.push(item.idx)
258
+ else out = next
259
+ }
260
+ return { content: out, stale: stale.sort((a, b) => a - b) }
261
+ }
262
+ parts.push(content.slice(location.end, end), reverse ? (location.hunk.oldText ?? '') : location.hunk.newText)
263
+ end = location.start
217
264
  }
218
- return { content: out, stale: stale.sort((a, b) => a - b) }
265
+ parts.push(content.slice(0, end))
266
+ parts.reverse()
267
+ return { content: parts.join(''), stale: stale.sort((a, b) => a - b) }
219
268
  }
@@ -0,0 +1,30 @@
1
+ import type { MpcServer } from './mcp.js'
2
+
3
+ type ServerStatus = Pick<MpcServer, 'enabled' | 'status'>
4
+ export type McpTone = 'connected' | 'connecting' | 'disabled' | 'error' | 'neutral'
5
+
6
+ /**
7
+ * @public
8
+ * @author ddj 2026年10月08号
9
+ * @description Display lifecycle evidence; enabled only expresses configuration intent, not connectivity.
10
+ * @param server Server lifecycle snapshot.
11
+ * @returns User-facing status label.
12
+ */
13
+ export function statusOf(server: ServerStatus): string {
14
+ if (!server.enabled || server.status === 'disabled') return '已禁用'
15
+ const labels = { connected: '在线', connecting: '装配中', configured: '已配置,等待会话', unverified: '已加载,连接未确认', error: '错误' }
16
+ return labels[server.status] ?? '状态未知'
17
+ }
18
+
19
+ /**
20
+ * @public
21
+ * @author ddj 2026年10月08号
22
+ * @description Use neutral styling unless lifecycle evidence explicitly identifies online, loading or error.
23
+ * @param server Server lifecycle snapshot.
24
+ * @returns CSS tone, independent of configuration-enabled state.
25
+ */
26
+ export function statusTone(server: ServerStatus): McpTone {
27
+ if (!server.enabled || server.status === 'disabled') return 'disabled'
28
+ if (server.status === 'connected' || server.status === 'connecting' || server.status === 'error') return server.status
29
+ return 'neutral'
30
+ }
package/src/shared/rpc.ts CHANGED
@@ -275,7 +275,12 @@ export interface PerfMoveFailure {
275
275
  export interface SidecarPerfSummary {
276
276
  active: number
277
277
  pendingByFile: { path: string; pending: number }[]
278
+ /** 新版 SQLite 主库 + WAL 文件字节数。 */
278
279
  archiveBytes: number
280
+ /** 旧 JSON 历史体积;不计入新归档。 */
281
+ legacyArchiveBytes: number
282
+ /** 旧活跃 JSON 只读快照体积。 */
283
+ legacyActiveBytes: number
279
284
  }
280
285
  // --endregion
281
286
 
@@ -362,7 +367,8 @@ export interface RpcRequestMap {
362
367
  'edrv.save': { sessionId?: string; path: string; content: string; rev?: string }
363
368
  'edrv.saveBinary': { sessionId?: string; path: string; content: string; encoding: 'base64' }
364
369
  'edrv.archiveList': { sessionId?: string }
365
- 'edrv.archiveRead': { sessionId?: string; path?: string }
370
+ 'edrv.archivePage': { sessionId?: string; cursor?: string; limit?: number }
371
+ 'edrv.archiveRead': { sessionId?: string; path?: string; batch?: number | null }
366
372
  'edrv.rollback': { sessionId?: string; path: string; batch?: number }
367
373
  'edrv.debug': { sessionId?: string; text: string; level?: LoggerLevel }
368
374
  'edrv.dlog.list': { sessionId?: string }
@@ -380,6 +386,7 @@ export interface RpcRequestMap {
380
386
  'edrv.fsCopy': { sessionId?: string; from: string; toDir: string }
381
387
  'edrv.fsMove': { sessionId?: string; from: string; toDir: string }
382
388
  'mcp.list': {}
389
+ 'mcp.snapshot': {}
383
390
  'mcp.save': { config: MpcConfig }
384
391
  'mcp.remove': { id: string }
385
392
  'mcp.toggle': { id: string; enabled: boolean }
@@ -520,6 +527,7 @@ export interface RpcOkMap {
520
527
  'edrv.save': { rev?: string; conflict?: boolean }
521
528
  'edrv.saveBinary': object
522
529
  'edrv.archiveList': { entries: ArchiveEntry[] }
530
+ 'edrv.archivePage': { entries: ArchiveEntry[]; nextCursor: string | null }
523
531
  'edrv.archiveRead': { batches: ArchiveBatch[] }
524
532
  'edrv.rollback': { path: string; batch: number | null }
525
533
  'edrv.debug': object
@@ -538,6 +546,7 @@ export interface RpcOkMap {
538
546
  'edrv.fsCopy': { from: string; to: string }
539
547
  'edrv.fsMove': { from: string; to: string }
540
548
  'mcp.list': { servers: MpcServer[] }
549
+ 'mcp.snapshot': { servers: MpcServer[]; projects: MpcProject[] }
541
550
  'mcp.save': { server: MpcServer }
542
551
  'mcp.remove': object
543
552
  'mcp.toggle': { server: MpcServer }
package/src/store.ts CHANGED
@@ -3,17 +3,21 @@
3
3
  * 迁移自原 src/index.ts 的 fs/ctx IO 部分,语义一字不改。
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
- import type { ArchiveBatch, ArchiveData, DiffRecord, SidecarData } from './shared/types.js'
6
+ import type { ArchiveBatch, DiffRecord, SidecarData } from './shared/types.js'
7
7
  import type { ReadState } from './shared/diff.js'
8
8
  import { fingerprint, isNoopHunk, locateHunks, normalizeForCompare, normalizeHunk, preciseHunk } from './shared/diff.js'
9
- import { archiveEntryFor, groupByBatch, normalizeRecord } from './model.js'
9
+ import { archiveEntryFor, normalizeRecord } from './model.js'
10
10
  import { SIDECAR, SIDECAR_ARCHIVE } from './paths.js'
11
11
  import { log } from './log.js'
12
+ import { debugRecord } from './debugLog.js'
13
+ import { activeReady, appendArchive, checkActiveSource, commitActive, committedRecord, migrateActive, restoreReverts, type ActiveChange } from './archiveDb.js'
12
14
 
13
15
  export { SIDECAR, SIDECAR_ARCHIVE }
14
16
  export const READ_CAP = 8 * 1024 * 1024
15
17
  /** 二进制预览/写回上限(PDF 等整文件 base64 传输;文本侧仍用 READ_CAP)。 */
16
18
  export const BINARY_READ_CAP = 32 * 1024 * 1024
19
+ /** 旧文件首次全量指纹核验后,每次操作优先检查 DSH fs stat 身份。 */
20
+ const sourceStats = new Map<string, string>()
17
21
 
18
22
  /** DSH 上下文与会话类型较宽松:本地无 dsh 类型声明,显式 any + 文档约束。 */
19
23
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -76,33 +80,88 @@ export function parseSidecar(text: string | null): SidecarData | null {
76
80
  }
77
81
  }
78
82
 
79
- /** 加载某工作区的记录桶(内存 Map;缺失 → 空)。 */
83
+ /**
84
+ * 严格读取旧 sidecar:仅确认确实不存在时才允许从空记录迁移。
85
+ * @author ddj 2026年09月30号
86
+ * @param ctx DSH 上下文
87
+ * @param cwd 工作区
88
+ * @returns 旧文件内容或确认缺失
89
+ */
90
+ async function migrationText(ctx: Ctx, cwd: string): Promise<string | null> {
91
+ const fs = ctx.get('fs')
92
+ if (!fs) throw new Error('无法迁移差异记录:缺少 fs')
93
+ const target = await fs.resolve(SIDECAR, { cwd })
94
+ const info = await fs.stat(target)
95
+ if (!info) return null
96
+ if (info.type !== 'file') throw new Error('旧活跃 sidecar 非文件')
97
+ return fs.readText(target)
98
+ }
99
+
100
+ /**
101
+ * 读取旧 sidecar 文件的版本身份;缺少可靠版本令牌时返回 null 走全文核验。
102
+ * @author ddj 2026年09月30号
103
+ * @param ctx DSH 上下文
104
+ * @param cwd 工作区
105
+ * @returns 文件身份或 null
106
+ */
107
+ async function sourceStat(ctx: Ctx, cwd: string): Promise<string | null> {
108
+ const fs = ctx.get('fs')
109
+ if (!fs) throw new Error('无法核验旧 sidecar:缺少 fs')
110
+ const info = await fs.stat(await fs.resolve(SIDECAR, { cwd }))
111
+ if (!info) return 'absent'
112
+ const version = info.version ?? info.rev
113
+ return typeof version === 'string' && version.length ? version : null
114
+ }
115
+
116
+ /**
117
+ * 首次读取旧文件全文确认数据库来源,后续只在 stat 版本变动时重新核验。
118
+ * @author ddj 2026年09月30号
119
+ * @param ctx DSH 上下文
120
+ * @param cwd 工作区
121
+ */
122
+ async function checkSource(ctx: Ctx, cwd: string): Promise<void> {
123
+ const now = await sourceStat(ctx, cwd)
124
+ if (now !== null && sourceStats.get(cwd) === now) return
125
+ checkActiveSource(cwd, await migrationText(ctx, cwd))
126
+ if (now !== null) sourceStats.set(cwd, now)
127
+ }
128
+
129
+ /** 加载某工作区的记录桶(内存 Map;首次在隔离切换窗口严格迁移)。 */
80
130
  export async function loadBucket(ctx: Ctx, cwd: string): Promise<Map<string, DiffRecord>> {
81
- const text = await readSidecarText(ctx, cwd)
82
- const data = parseSidecar(text)
83
- const map = new Map<string, DiffRecord>()
84
- if (data && data.workspaces[cwd] && typeof data.workspaces[cwd].records === 'object') {
85
- for (const rec of Object.values(data.workspaces[cwd].records)) {
86
- const n = normalizeRecord(rec)
87
- if (n) map.set(n.callId, n)
131
+ const source = await migrationText(ctx, cwd)
132
+ const map = await migrateActive(cwd, source, () => migrationText(ctx, cwd))
133
+ await checkSource(ctx, cwd)
134
+ for (const rec of map.values()) {
135
+ if (rec.archived) continue
136
+ const previous = JSON.stringify(rec)
137
+ restoreReverts(cwd, rec)
138
+ const committed = committedRecord(cwd, rec.callId, rec.path, rec.batch ?? null)
139
+ if (committed) {
140
+ rec.decisions = committed.decisions
141
+ rec.superseded = committed.superseded
142
+ rec.archived = true
88
143
  }
144
+ if (JSON.stringify(rec) !== previous) commitActive(cwd, { upserts: [rec], removed: [] })
89
145
  }
90
146
  return map
91
147
  }
92
148
 
93
- /** 保存某工作区的记录桶(写前合并,不覆盖其他工作区)。 */
94
- export async function saveBucket(ctx: Ctx, cwd: string, recsMap: Map<string, DiffRecord>, session?: Session): Promise<void> {
95
- const fs = ctx.get('fs')
96
- if (!fs || !cwd) return
149
+ /**
150
+ * 仅把明确变动的记录行写入 SQLite;旧 sidecar 不再写入。
151
+ * @author ddj 2026年09月30号
152
+ * @param ctx DSH 上下文
153
+ * @param cwd 工作区
154
+ * @param recsMap 供当前调用点核对的记录桶
155
+ * @param session 会话(保留旧 API)
156
+ * @param changes 明确的更新和删除集合
157
+ */
158
+ export async function saveBucket(ctx: Ctx, cwd: string, recsMap: Map<string, DiffRecord>, session?: Session, changes?: ActiveChange): Promise<void> {
159
+ if (!changes) throw new Error('增量保存缺少变更集合')
160
+ for (const rec of changes.upserts) if (recsMap.get(rec.callId) !== rec) throw new Error('增量记录不在工作区桶内')
97
161
  try {
98
- const target = await fs.resolve(SIDECAR, { cwd })
99
- const existing = parseSidecar(await readSidecarText(ctx, cwd)) ?? { version: 2, updatedAt: '', workspaces: {} } as SidecarData
100
- existing.workspaces[cwd] = { at: Date.now(), records: Object.fromEntries(recsMap) }
101
- existing.updatedAt = new Date().toISOString()
102
- await fs.writeText(target, JSON.stringify(existing), void 0, void 0, policyOf(ctx, session))
103
- } catch (error) {
104
- log.error('saveBucket failed: ' + String(error))
105
- }
162
+ await checkSource(ctx, cwd)
163
+ commitActive(cwd, changes)
164
+ } catch (error) { log.error('saveBucket failed: ' + String(error)); throw error }
106
165
  }
107
166
 
108
167
  /**
@@ -172,8 +231,18 @@ export async function autoArchiveStale(ctx: Ctx, session: Session, cwd: string,
172
231
  if (await recordIsStale(ctx, session, cache, rec)) stale.push(rec)
173
232
  }
174
233
  if (!stale.length) return 0
234
+ const original = stale.map((rec) => ({ rec, at: rec.at, superseded: rec.superseded }))
175
235
  for (const r of stale) { r.superseded = true; r.at = new Date().toISOString() }
176
- await archiveRecords(ctx, session, cwd, bucket, stale, '差异无法定位(已被后续修改覆盖),自动归档')
236
+ try {
237
+ await archiveRecords(ctx, session, cwd, bucket, stale, '差异无法定位(已被后续修改覆盖),自动归档')
238
+ } catch (error) {
239
+ for (const item of original) {
240
+ if (item.rec.archived) continue
241
+ item.rec.superseded = item.superseded
242
+ item.rec.at = item.at
243
+ }
244
+ throw error
245
+ }
177
246
  return stale.length
178
247
  }
179
248
 
@@ -198,40 +267,49 @@ export function parseArchive(text: string | null): ArchiveBatch[] {
198
267
  return []
199
268
  }
200
269
 
201
- /** 追加归档条目(按 cwd+path+batch 合并,同 callId 去重)。 */
202
- export async function appendArchiveEntries(ctx: Ctx, cwd: string, entries: Array<ReturnType<typeof archiveEntryFor>>, session?: Session): Promise<void> {
203
- const fs = ctx.get('fs')
204
- if (!fs || !cwd) return
270
+ /**
271
+ * 追加归档条目(按 cwd+path+batch 合并,同 callId 去重)。
272
+ * @author ddj 2026年09月30号
273
+ * @param ctx DSH 上下文
274
+ * @param cwd 工作区
275
+ * @param entries 待归档批次
276
+ * @param session 会话
277
+ * @param traceId 可选诊断追踪标识;有值时输出分段耗时
278
+ */
279
+ export async function appendArchiveEntries(ctx: Ctx, cwd: string, entries: Array<ReturnType<typeof archiveEntryFor>>, session?: Session, traceId?: string): Promise<void> {
280
+ if (!cwd || !entries.length) return
281
+ // #region debug log
282
+ const started = traceId ? performance.now() : 0
283
+ // #endregion
205
284
  try {
206
- const target = await fs.resolve(SIDECAR_ARCHIVE, { cwd })
207
- const existing = parseArchive(await readArchiveText(ctx, cwd))
208
- for (const e of entries) {
209
- const idx = existing.findIndex((x) => x.cwd === e.cwd && x.path === e.path && x.batch === e.batch)
210
- if (idx >= 0) {
211
- const exist = existing[idx]
212
- const seen = new Set((exist.records || []).map((r) => r.callId))
213
- for (const rec of e.records) {
214
- if (!seen.has(String(rec.callId))) {
215
- exist.records.push(rec as unknown as ArchiveBatch['records'][number])
216
- seen.add(String(rec.callId))
217
- }
218
- }
219
- exist.lastAt = e.at
220
- exist.reason = e.reason
221
- } else {
222
- existing.push(e as unknown as ArchiveBatch)
223
- }
224
- }
225
- const data: ArchiveData = { version: 1, updatedAt: new Date().toISOString(), batches: existing }
226
- await fs.writeText(target, JSON.stringify(data), void 0, void 0, policyOf(ctx, session))
285
+ await checkSource(ctx, cwd)
286
+ const added = appendArchive(cwd, entries as unknown as ArchiveBatch[])
287
+ // #region debug log
288
+ try {
289
+ if (traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${traceId} stage=done mode=sqlite entries=${entries.length} insertedBatches=${added} dbMs=${(performance.now() - started).toFixed(1)} totalMs=${(performance.now() - started).toFixed(1)}`)
290
+ } catch { /* 诊断不可影响归档 */ }
291
+ // #endregion
227
292
  } catch (error) {
293
+ // #region debug log
294
+ try {
295
+ if (traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${traceId} stage=error mode=sqlite totalMs=${(performance.now() - started).toFixed(1)}`)
296
+ } catch { /* 诊断不可影响归档 */ }
297
+ // #endregion
228
298
  log.error('appendArchiveEntries failed: ' + String(error))
299
+ throw error
229
300
  }
230
301
  }
231
302
 
232
303
  /**
233
304
  * 归档记录:标记 archived、按批次写入归档、落盘工作区桶。
234
- * @param opts.deferSave 为 true 时只写归档、不落盘桶(由批量调用方统一 saveBucket 一次)
305
+ * @author ddj 2026年09月30号
306
+ * @param ctx DSH 上下文
307
+ * @param session 会话
308
+ * @param cwd 工作区
309
+ * @param bucket 当前工作区记录桶
310
+ * @param recs 待归档记录
311
+ * @param reason 归档原因
312
+ * @param opts deferSave 为 true 时只写归档、不落盘桶;traceId 用于可选分段计时
235
313
  */
236
314
  export async function archiveRecords(
237
315
  ctx: Ctx,
@@ -240,13 +318,34 @@ export async function archiveRecords(
240
318
  bucket: Map<string, DiffRecord>,
241
319
  recs: DiffRecord[],
242
320
  reason: string,
243
- opts?: { deferSave?: boolean },
321
+ opts?: { deferSave?: boolean; traceId?: string; changed?: DiffRecord[] },
244
322
  ): Promise<void> {
245
323
  if (!recs.length) return
246
324
  const fresh = recs.filter((r) => !r.archived)
247
- for (const r of recs) r.archived = true
248
- const entries: Array<ReturnType<typeof archiveEntryFor>> = []
249
- for (const list of groupByBatch(recs).values()) entries.push(archiveEntryFor(list, cwd, reason))
250
- await appendArchiveEntries(ctx, cwd, entries, session)
251
- if (fresh.length && !opts?.deferSave) await saveBucket(ctx, cwd, bucket, session)
325
+ const byFile = new Map<string, DiffRecord[]>()
326
+ for (const rec of recs) {
327
+ const key = JSON.stringify([rec.path, Number.isInteger(rec.batch) ? rec.batch : null])
328
+ const list = byFile.get(key) ?? []
329
+ list.push(rec)
330
+ byFile.set(key, list)
331
+ }
332
+ const entries = [...byFile.values()].map((list) => archiveEntryFor(list, cwd, reason))
333
+ const changed = new Map((opts?.changed ?? []).map((rec) => [rec.callId, rec]))
334
+ for (const rec of recs) changed.set(rec.callId, rec)
335
+ const start = opts?.traceId ? performance.now() : 0
336
+ await checkSource(ctx, cwd)
337
+ const original = recs.map((rec) => rec.archived)
338
+ for (const rec of recs) rec.archived = true
339
+ try {
340
+ const added = commitActive(cwd, { upserts: [...changed.values()], removed: [] }, entries as unknown as ArchiveBatch[])
341
+ // #region debug log
342
+ try {
343
+ if (opts?.traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${opts.traceId} stage=done mode=sqlite entries=${entries.length} insertedBatches=${added} dbMs=${(performance.now() - start).toFixed(1)} totalMs=${(performance.now() - start).toFixed(1)}`)
344
+ } catch { /* 诊断不可影响归档 */ }
345
+ // #endregion
346
+ } catch (error) {
347
+ recs.forEach((rec, idx) => { rec.archived = original[idx] })
348
+ throw error
349
+ }
350
+ if (fresh.length && !opts?.deferSave) return
252
351
  }