dsh-manage-session 0.5.4 → 0.5.7

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.
@@ -1,368 +1,432 @@
1
- /**
2
- * Non-destructive index rebuild ("整理索引") for the independent index.
3
- *
4
- * A shadow index file is built from scratch beside the active one; the active
5
- * engine keeps serving queries the whole time. When the shadow is complete the
6
- * swap is three synchronous renames (active → archive, shadow → active), then
7
- * the engine reopens. Old archives are kept (bounded) and stay readable.
8
- */
9
- import { existsSync } from 'node:fs'
10
- import { mkdir, readdir, rename, rm, unlink } from 'node:fs/promises'
11
- import { homedir } from 'node:os'
12
- import { join } from 'node:path'
13
- import { SwitchIndexEngine } from './engine.ts'
14
- import { extractTitleFromEvents, type SwitchRawEvent } from './extract.ts'
15
- import type { SwitchArchiveSource } from './sync.ts'
16
-
17
- /** The corpus reader a rebuild needs (same faces as the syncer). */
18
- export interface SwitchRebuildSessionQuery {
19
- listSessions(): Promise<readonly { header: { id: string; version: number; createdAt?: number; cwd?: string } }[]>
20
- readSession(sessionId: string): Promise<{
21
- session: { id: string; version: number; createdAt?: number; cwd?: string }
22
- events: readonly SwitchRawEvent[]
23
- }>
24
- }
25
-
26
- /** Live rebuild progress reported to the status endpoint. */
27
- export interface SwitchRebuildState {
28
- state: 'idle' | 'building' | 'swapping' | 'error'
29
- /** Sessions written into the shadow index so far. */
30
- done: number
31
- /** Sessions seen in the corpus when the build started. */
32
- total: number
33
- /** Epoch ms when the build started. */
34
- startedAt: number
35
- /** Epoch ms when the last build finished. */
36
- finishedAt: number
37
- /** Per-session failures during the last build. */
38
- failures: { sessionId: string; error: string }[]
39
- error?: string
40
- }
41
-
42
- /** Filesystem layout of one index directory. */
43
- export interface SwitchIndexLayout {
44
- /** Directory holding every index file. */
45
- dir: string
46
- /** Active index file name. */
47
- active: string
48
- /** Shadow file name used while building. */
49
- building: string
50
- /** Archive file name prefix. */
51
- archivePrefix: string
52
- }
53
-
54
- /** Default layout names. */
55
- export const DEFAULT_INDEX_LAYOUT: SwitchIndexLayout = {
56
- dir: '.',
57
- active: 'index.sqlite',
58
- building: 'index.building.sqlite',
59
- archivePrefix: 'index.archive-',
60
- }
61
-
62
- /**
63
- * Inspect the index directory for half-built leftovers from an abnormally
64
- * terminated rebuild and recover:
65
- * - shadow present + active present: the build never finished — the shadow
66
- * is garbage (the active index kept serving) and is discarded.
67
- * - shadow present + active missing: the crash hit the rename window — the
68
- * newest archive is restored as the active index, the shadow discarded.
69
- * Runs at host activation, before the engine opens (opening would create a
70
- * fresh empty active file and mask the swap-window case).
71
- */
72
- export async function recoverIndex(
73
- layout: SwitchIndexLayout,
74
- log?: (msg: string) => void,
75
- ): Promise<string[]> {
76
- const actions: string[] = []
77
- await mkdir(layout.dir, { recursive: true })
78
- const activePath = join(layout.dir, layout.active)
79
- const buildingPath = join(layout.dir, layout.building)
80
- const activeExists = existsSync(activePath)
81
- if (!existsSync(buildingPath)) return actions
82
- const discardShadow = async (): Promise<void> => {
83
- for (const suffix of ['', '-wal', '-shm']) {
84
- await rm(`${buildingPath}${suffix}`, { force: true })
85
- }
86
- }
87
- if (activeExists) {
88
- await discardShadow()
89
- actions.push(`discarded stale shadow index (a previous rebuild did not finish; the active index was never at risk)`)
90
- } else {
91
- const archives = await listArchives(layout)
92
- if (archives.length > 0) {
93
- const newest = archives[archives.length - 1]
94
- await rename(join(layout.dir, newest), activePath)
95
- actions.push(`active index was missing (crash during the swap window); restored "${newest}" as the active index`)
96
- } else {
97
- actions.push('no active index and no archive: the first build crashed mid-way; starting from a fresh index')
98
- }
99
- await discardShadow()
100
- }
101
- for (const action of actions) log?.(`index recovery: ${action}`)
102
- return actions
103
- }
104
-
105
- /** List existing archive files, oldest first. */
106
- export async function listArchives(layout: SwitchIndexLayout): Promise<string[]> {
107
- if (!existsSync(layout.dir)) return []
108
- const entries = await readdir(layout.dir)
109
- return entries.filter(name => name.startsWith(layout.archivePrefix) && name.endsWith('.sqlite')).sort()
110
- }
111
-
112
- /**
113
- * Build a fresh index into the shadow file, then swap it in atomically.
114
- *
115
- * During the build the caller's active engine stays open and queryable; only
116
- * the final swap briefly reopens the handle.
117
- * @param activeEngine - the currently-serving engine (its file is replaced).
118
- * @param layout - filesystem layout of the index directory.
119
- * @param sessionQuery - corpus reader for the full rebuild.
120
- * @param keepArchives - how many archive files to retain (oldest pruned).
121
- * @param onProgress - optional progress callback after each session.
122
- * @returns the rebuild state snapshot after completion.
123
- */
124
- /** Optional observability callbacks for rebuildIndex. */
125
- export interface SwitchRebuildHooks {
126
- /** Progress log line sink (cordis logger bridge). */
127
- log?: (msg: string) => void
128
- /** State-mutation sink: called after every change so index-status sees
129
- * live progress (the "0/?" bug was state cloned only at completion). */
130
- onState?: (state: SwitchRebuildState) => void
131
- /**
132
- * 文件级兜底读取器(与同步器同语义):readSession 对归档/服务面退化会话
133
- * 失败时改读转录文件;返回 undefined 表示文件也不可用(记失败)。
134
- */
135
- readSessionFromFile?: (sessionId: string) => Promise<{
136
- session: { id: string; version: number; createdAt?: number; cwd?: string }
137
- events: readonly SwitchRawEvent[]
138
- } | undefined>
139
- }
140
-
141
- /** Sessions per batched transaction (one fsync checkpoint per chunk). */
142
- const REBUILD_CHUNK = 50
143
-
144
- export async function rebuildIndex(
145
- activeEngine: SwitchIndexEngine,
146
- layout: SwitchIndexLayout,
147
- sessionQuery: SwitchRebuildSessionQuery,
148
- keepArchives: number,
149
- onProgress?: (done: number, total: number) => void,
150
- archiveSource?: () => SwitchArchiveSource | undefined,
151
- hooks?: SwitchRebuildHooks,
152
- ): Promise<SwitchRebuildState> {
153
- const startedMs = Date.now()
154
- let docsWritten = 0
155
- const emit = (): void => { hooks?.onState?.({ ...state }) }
156
- const rate = (): string => {
157
- const secs = Math.max(0.001, (Date.now() - startedMs) / 1000)
158
- return `${(state.done / secs).toFixed(1)} sess/s`
159
- }
160
- const eta = (): string => {
161
- if (state.total <= 0 || state.done === 0) return '?'
162
- const secs = (Date.now() - startedMs) / 1000
163
- return `${Math.max(0, Math.round((state.total - state.done) / (state.done / secs)))}s`
164
- }
165
- const state: SwitchRebuildState = {
166
- state: 'building',
167
- done: 0,
168
- total: 0,
169
- startedAt: Date.now(),
170
- finishedAt: 0,
171
- failures: [],
172
- }
173
- try {
174
- hooks?.log?.(`rebuild started: dir=${layout.dir} keep=${keepArchives}`)
175
- emit()
176
- await mkdir(layout.dir, { recursive: true })
177
- const buildingPath = join(layout.dir, layout.building)
178
- // A stale shadow from a crashed run is discarded.
179
- if (existsSync(buildingPath)) await unlink(buildingPath)
180
- const shadow = new SwitchIndexEngine({ path: buildingPath })
181
- await shadow.open()
182
- try {
183
- const records = await sessionQuery.listSessions()
184
- state.total = records.length
185
- hooks?.log?.(`rebuild corpus listed: ${state.total} sessions (archived included, full content)`)
186
- emit()
187
- // R1 合表语义:归档会话**同样全量读内容**入影子索引(readSession 对归档是
188
- // replay-validate 不激活);构建完成后按官方归档集合统一翻 flag。
189
- const archivedSet = new Set(archiveSource?.()?.archivedSessionIds ?? [])
190
- const readLog = async (header: { id: string; version: number; createdAt?: number; cwd?: string }) => {
191
- let log: { session: { id: string; version: number; createdAt?: number; cwd?: string }; events: readonly SwitchRawEvent[] }
192
- try {
193
- log = await sessionQuery.readSession(header.id)
194
- } catch (serviceError) {
195
- if (hooks?.readSessionFromFile === undefined) throw serviceError
196
- const fromFile = await hooks.readSessionFromFile(header.id)
197
- if (fromFile === undefined) throw serviceError
198
- log = fromFile
199
- }
200
- return {
201
- sessionId: header.id,
202
- version: log.session.version,
203
- // 标题基础层随重建落行(beta.5 教训:重建不抽标题 + 版本对齐后同步
204
- // 永不重读 = 全库标题永久空缺)。
205
- title: extractTitleFromEvents(log.events),
206
- cwd: log.session.cwd ?? '',
207
- updatedAt: log.session.createdAt ?? 0,
208
- archived: archivedSet.has(header.id),
209
- events: log.events,
210
- }
211
- }
212
- for (let i = 0; i < records.length; i += REBUILD_CHUNK) {
213
- const chunk = records.slice(i, i + REBUILD_CHUNK)
214
- const chunkStart = Date.now()
215
- const reads: Array<{ sessionId: string; version: number; cwd: string; updatedAt: number; archived: boolean; events: readonly SwitchRawEvent[] }> = []
216
- for (const record of chunk) {
217
- try {
218
- reads.push(await readLog(record.header))
219
- } catch (err) {
220
- state.failures.push({ sessionId: record.header.id, error: String(err instanceof Error ? err.message : err) })
221
- }
222
- }
223
- // One transaction per chunk = one checkpoint; a chunk-level failure
224
- // replays session-by-session to keep the bad session isolated.
225
- try {
226
- shadow.runBatched(() => {
227
- for (const item of reads) {
228
- shadow.upsertSession(item)
229
- docsWritten += item.events.length
230
- }
231
- })
232
- } catch (err) {
233
- hooks?.log?.(`rebuild chunk txn failed, replaying individually: ${String(err instanceof Error ? err.message : err)}`)
234
- for (const item of reads) {
235
- try { shadow.upsertSession(item); docsWritten += item.events.length }
236
- catch (e2) { state.failures.push({ sessionId: item.sessionId, error: String(e2 instanceof Error ? e2.message : e2) }) }
237
- }
238
- }
239
- state.done = Math.min(state.total, i + chunk.length)
240
- onProgress?.(state.done, state.total)
241
- emit()
242
- hooks?.log?.(`rebuild ${state.done}/${state.total} (${Math.round((state.done / Math.max(1, state.total)) * 100)}%) ${rate()} elapsed ${Math.round((Date.now() - startedMs) / 1000)}s eta ${eta()} chunk ${Date.now() - chunkStart}ms`)
243
- }
244
- // 归档标记统一翻转(upsertSession 不动已有行的 flag,此处是影子库的最终真值)。
245
- shadow.setArchived(archivedSet)
246
- shadow.close()
247
- } catch (error) {
248
- shadow.close()
249
- await unlink(buildingPath).catch(() => {})
250
- throw error
251
- }
252
-
253
- state.state = 'swapping'
254
- emit()
255
- hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`)
256
- // The swap window is three synchronous renames; queries fail only inside it.
257
- const activePath = join(layout.dir, layout.active)
258
- if (existsSync(activePath)) {
259
- activeEngine.close()
260
- await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`))
261
- }
262
- await rename(buildingPath, activePath)
263
- await activeEngine.open()
264
- await pruneArchives(layout, keepArchives)
265
- state.finishedAt = Date.now()
266
- state.state = 'idle'
267
- emit()
268
- hooks?.log?.(`rebuild done: total ${((state.finishedAt - startedMs) / 1000).toFixed(1)}s, archives pruned to ${keepArchives}`)
269
- } catch (err) {
270
- state.state = 'error'
271
- state.error = String(err instanceof Error ? err.message : err)
272
- hooks?.log?.(`rebuild FAILED at done=${state.done}: ${state.error}`)
273
- emit()
274
- // Leave the active engine usable if the swap itself failed before rename.
275
- if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
276
- }
277
- return state
278
- }
279
-
280
- /** One doc-level record the snapshot importer feeds in. */
281
- export interface SwitchImportRecord {
282
- sessionId: string
283
- version: number
284
- title?: string
285
- /** 归档标记(旧快照缺省活跃)。 */
286
- archived?: boolean
287
- docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
288
- }
289
-
290
- /** Import doc-level records into the shadow file and swap it in (same swap path). */
291
- export async function importIntoIndex(
292
- activeEngine: SwitchIndexEngine,
293
- layout: SwitchIndexLayout,
294
- records: readonly SwitchImportRecord[],
295
- keepArchives: number,
296
- ): Promise<SwitchRebuildState> {
297
- const state: SwitchRebuildState = {
298
- state: 'building',
299
- done: 0,
300
- total: records.length,
301
- startedAt: Date.now(),
302
- finishedAt: 0,
303
- failures: [],
304
- }
305
- try {
306
- await mkdir(layout.dir, { recursive: true })
307
- const buildingPath = join(layout.dir, layout.building)
308
- if (existsSync(buildingPath)) await unlink(buildingPath)
309
- const shadow = new SwitchIndexEngine({ path: buildingPath })
310
- await shadow.open()
311
- try {
312
- for (const record of records) {
313
- try {
314
- shadow.importSessionDocs({
315
- sessionId: record.sessionId,
316
- version: record.version,
317
- title: record.title ?? '',
318
- archived: record.archived === true,
319
- docs: record.docs,
320
- })
321
- } catch (err) {
322
- state.failures.push({ sessionId: record.sessionId, error: String(err instanceof Error ? err.message : err) })
323
- }
324
- state.done += 1
325
- }
326
- shadow.close()
327
- } catch (error) {
328
- shadow.close()
329
- await unlink(buildingPath).catch(() => {})
330
- throw error
331
- }
332
- state.state = 'swapping'
333
- const activePath = join(layout.dir, layout.active)
334
- if (existsSync(activePath)) {
335
- activeEngine.close()
336
- await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`))
337
- }
338
- await rename(buildingPath, activePath)
339
- await activeEngine.open()
340
- await pruneArchives(layout, keepArchives)
341
- state.finishedAt = Date.now()
342
- state.state = 'idle'
343
- } catch (err) {
344
- state.state = 'error'
345
- state.error = String(err instanceof Error ? err.message : err)
346
- if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
347
- }
348
- return state
349
- }
350
-
351
- /** Remove the oldest archives beyond the retention bound. */
352
- async function pruneArchives(layout: SwitchIndexLayout, keep: number): Promise<void> {
353
- const archives = await listArchives(layout)
354
- const excess = archives.length - Math.max(0, keep)
355
- for (let i = 0; i < excess; i += 1) {
356
- await unlink(join(layout.dir, archives[i])).catch(() => {})
357
- }
358
- }
359
-
360
- /**
361
- * Resolve the index directory that hosts the independent index files:
362
- * an explicit override wins, otherwise a plugin-owned directory under the
363
- * user's home (never the official index path).
364
- */
365
- export function resolveIndexDir(preferred?: string): string {
366
- if (preferred !== undefined && preferred.trim() !== '') return preferred
367
- return join(homedir(), '.dsh-switch-search')
368
- }
1
+ /**
2
+ * Non-destructive index rebuild ("整理索引") for the independent index.
3
+ *
4
+ * A shadow index file is built from scratch beside the active one; the active
5
+ * engine keeps serving queries the whole time. When the shadow is complete the
6
+ * swap is three synchronous renames (active → archive, shadow → active), then
7
+ * the engine reopens. Old archives are kept (bounded) and stay readable.
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { mkdir, readdir, rename, rm, unlink } from 'node:fs/promises'
11
+ import { homedir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { SwitchIndexEngine } from './engine.ts'
14
+ import { extractTitleFromEvents, type SwitchRawEvent } from './extract.ts'
15
+ import type { SwitchArchiveSource } from './sync.ts'
16
+
17
+ /** The corpus reader a rebuild needs (same faces as the syncer). */
18
+ export interface SwitchRebuildSessionQuery {
19
+ listSessions(): Promise<readonly { header: { id: string; version: number; createdAt?: number; cwd?: string } }[]>
20
+ readSession(sessionId: string): Promise<{
21
+ session: { id: string; version: number; createdAt?: number; cwd?: string }
22
+ events: readonly SwitchRawEvent[]
23
+ }>
24
+ }
25
+
26
+ /** Live rebuild progress reported to the status endpoint. */
27
+ export interface SwitchRebuildState {
28
+ state: 'idle' | 'building' | 'swapping' | 'error'
29
+ /** Sessions written into the shadow index so far. */
30
+ done: number
31
+ /** Sessions seen in the corpus when the build started. */
32
+ total: number
33
+ /** Epoch ms when the build started. */
34
+ startedAt: number
35
+ /** Epoch ms when the last build finished. */
36
+ finishedAt: number
37
+ /** Per-session failures during the last build. */
38
+ failures: { sessionId: string; error: string }[]
39
+ error?: string
40
+ }
41
+
42
+ /** Filesystem layout of one index directory. */
43
+ export interface SwitchIndexLayout {
44
+ /** Directory holding every index file. */
45
+ dir: string
46
+ /** Active index file name. */
47
+ active: string
48
+ /** Shadow file name used while building. */
49
+ building: string
50
+ /** Archive file name prefix. */
51
+ archivePrefix: string
52
+ }
53
+
54
+ /** Default layout names. */
55
+ export const DEFAULT_INDEX_LAYOUT: SwitchIndexLayout = {
56
+ dir: '.',
57
+ active: 'index.sqlite',
58
+ building: 'index.building.sqlite',
59
+ archivePrefix: 'index.archive-',
60
+ }
61
+
62
+ /**
63
+ * Inspect the index directory for half-built leftovers from an abnormally
64
+ * terminated rebuild and recover:
65
+ * - shadow present + active present: the build never finished — the shadow
66
+ * is garbage (the active index kept serving) and is discarded.
67
+ * - shadow present + active missing: the crash hit the rename window — the
68
+ * newest archive is restored as the active index, the shadow discarded.
69
+ * Runs at host activation, before the engine opens (opening would create a
70
+ * fresh empty active file and mask the swap-window case).
71
+ */
72
+ export async function recoverIndex(
73
+ layout: SwitchIndexLayout,
74
+ log?: (msg: string) => void,
75
+ ): Promise<string[]> {
76
+ const actions: string[] = []
77
+ await mkdir(layout.dir, { recursive: true })
78
+ const activePath = join(layout.dir, layout.active)
79
+ const buildingPath = join(layout.dir, layout.building)
80
+ const activeExists = existsSync(activePath)
81
+ if (!existsSync(buildingPath)) return actions
82
+ const discardShadow = async (): Promise<void> => {
83
+ for (const suffix of ['', '-wal', '-shm']) {
84
+ await rm(`${buildingPath}${suffix}`, { force: true })
85
+ }
86
+ }
87
+ if (activeExists) {
88
+ await discardShadow()
89
+ actions.push(`discarded stale shadow index (a previous rebuild did not finish; the active index was never at risk)`)
90
+ } else {
91
+ const archives = await listArchives(layout)
92
+ if (archives.length > 0) {
93
+ const newest = archives[archives.length - 1]
94
+ await rename(join(layout.dir, newest), activePath)
95
+ actions.push(`active index was missing (crash during the swap window); restored "${newest}" as the active index`)
96
+ } else {
97
+ actions.push('no active index and no archive: the first build crashed mid-way; starting from a fresh index')
98
+ }
99
+ await discardShadow()
100
+ }
101
+ for (const action of actions) log?.(`index recovery: ${action}`)
102
+ return actions
103
+ }
104
+
105
+ /** List existing archive files, oldest first. */
106
+ export async function listArchives(layout: SwitchIndexLayout): Promise<string[]> {
107
+ if (!existsSync(layout.dir)) return []
108
+ const entries = await readdir(layout.dir)
109
+ return entries.filter(name => name.startsWith(layout.archivePrefix) && name.endsWith('.sqlite')).sort()
110
+ }
111
+
112
+ /**
113
+ * Build a fresh index into the shadow file, then swap it in atomically.
114
+ *
115
+ * During the build the caller's active engine stays open and queryable; only
116
+ * the final swap briefly reopens the handle.
117
+ * @param activeEngine - the currently-serving engine (its file is replaced).
118
+ * @param layout - filesystem layout of the index directory.
119
+ * @param sessionQuery - corpus reader for the full rebuild.
120
+ * @param keepArchives - how many archive files to retain (oldest pruned).
121
+ * @param onProgress - optional progress callback after each session.
122
+ * @returns the rebuild state snapshot after completion.
123
+ */
124
+ /** Optional observability callbacks for rebuildIndex. */
125
+ export interface SwitchRebuildHooks {
126
+ /** Progress log line sink (cordis logger bridge). */
127
+ log?: (msg: string) => void
128
+ /** State-mutation sink: called after every change so index-status sees
129
+ * live progress (the "0/?" bug was state cloned only at completion). */
130
+ onState?: (state: SwitchRebuildState) => void
131
+ /**
132
+ * 文件级兜底读取器(与同步器同语义):readSession 对归档/服务面退化会话
133
+ * 失败时改读转录文件;返回 undefined 表示文件也不可用(记失败)。
134
+ */
135
+ readSessionFromFile?: (sessionId: string) => Promise<{
136
+ session: { id: string; version: number; createdAt?: number; cwd?: string }
137
+ events: readonly SwitchRawEvent[]
138
+ } | undefined>
139
+ }
140
+
141
+ /** Sessions per batched transaction (one fsync checkpoint per chunk). */
142
+ const REBUILD_CHUNK = 50
143
+
144
+ /**
145
+ * 主文件旁的 WAL sidecar(`-wal`/`-shm`)随主文件一并迁走;无目的地时
146
+ * (孤儿残留)直接 force rm。rename 失败降级删除——sidecar 清理永远不能
147
+ * 阻断 swap 本身。
148
+ */
149
+ async function moveOrDeleteSidecars(basePath: string, destBasePath: string | null): Promise<void> {
150
+ for (const suffix of ['-wal', '-shm']) {
151
+ const src = basePath + suffix
152
+ if (!existsSync(src)) continue
153
+ if (destBasePath !== null) {
154
+ try {
155
+ await rename(src, destBasePath + suffix)
156
+ continue
157
+ } catch { /* 降级删除 */ }
158
+ }
159
+ await rm(src, { force: true }).catch(() => {})
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The swap window: synchronous renames only; queries fail only inside it.
165
+ *
166
+ * issue #1 评论的损坏根因:只搬主文件时,旧库残留的 -wal/-shm 会被同名顶替
167
+ * 的新库当自己的 WAL 重放,页号错配 → malformed。因此 sidecar 必须随主文件
168
+ * 一并迁 archive(干净 close 本会 checkpoint 自删,这里是异常关闭的兜底)。
169
+ * swap 后跑 integrity_check:非 ok 则把损坏库改名 .corrupt、从刚落的 archive
170
+ * 回滚重开,并抛错让调用方置 error(把「索引损坏」降级为「回到上次快照」)。
171
+ * 导出供测试直接驱动 swap 窗口(多连接场景在 Windows 上 rename 必 EBUSY,
172
+ * 无法经 rebuildIndex 全链路确定性复现)。
173
+ */
174
+ export async function swapShadowIntoActive(
175
+ activeEngine: SwitchIndexEngine,
176
+ layout: SwitchIndexLayout,
177
+ log?: (msg: string) => void,
178
+ ): Promise<void> {
179
+ const activePath = join(layout.dir, layout.active)
180
+ const buildingPath = join(layout.dir, layout.building)
181
+ let archivePath: string | null = null
182
+ if (existsSync(activePath)) {
183
+ activeEngine.close()
184
+ archivePath = join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`)
185
+ await rename(activePath, archivePath)
186
+ await moveOrDeleteSidecars(activePath, archivePath)
187
+ } else {
188
+ // 主文件不在但 sidecar 残留(崩溃窗口):新库开在孤儿 WAL 上同样错配。
189
+ await moveOrDeleteSidecars(activePath, null)
190
+ }
191
+ await rename(buildingPath, activePath)
192
+ await activeEngine.open()
193
+ const integrity = activeEngine.integrityCheck()
194
+ if (integrity === 'ok') return
195
+ log?.(`post-swap integrity FAILED (${integrity}); rolling back to the newest archive`)
196
+ activeEngine.close()
197
+ const corruptBase = join(layout.dir, `index.corrupt-${Date.now()}.sqlite`)
198
+ try {
199
+ await rename(activePath, corruptBase)
200
+ await moveOrDeleteSidecars(activePath, corruptBase)
201
+ if (archivePath !== null) {
202
+ await rename(archivePath, activePath)
203
+ for (const suffix of ['-wal', '-shm']) {
204
+ if (existsSync(archivePath + suffix)) await rename(archivePath + suffix, activePath + suffix).catch(() => {})
205
+ }
206
+ await activeEngine.open()
207
+ }
208
+ } catch (rollbackErr) {
209
+ log?.(`swap rollback itself failed: ${String(rollbackErr instanceof Error ? rollbackErr.message : rollbackErr)}`)
210
+ }
211
+ throw new Error(`switch-search: post-swap integrity check failed: ${integrity}`)
212
+ }
213
+
214
+ export async function rebuildIndex(
215
+ activeEngine: SwitchIndexEngine,
216
+ layout: SwitchIndexLayout,
217
+ sessionQuery: SwitchRebuildSessionQuery,
218
+ keepArchives: number,
219
+ onProgress?: (done: number, total: number) => void,
220
+ archiveSource?: () => SwitchArchiveSource | undefined,
221
+ hooks?: SwitchRebuildHooks,
222
+ ): Promise<SwitchRebuildState> {
223
+ const startedMs = Date.now()
224
+ let docsWritten = 0
225
+ const emit = (): void => { hooks?.onState?.({ ...state }) }
226
+ const rate = (): string => {
227
+ const secs = Math.max(0.001, (Date.now() - startedMs) / 1000)
228
+ return `${(state.done / secs).toFixed(1)} sess/s`
229
+ }
230
+ const eta = (): string => {
231
+ if (state.total <= 0 || state.done === 0) return '?'
232
+ const secs = (Date.now() - startedMs) / 1000
233
+ return `${Math.max(0, Math.round((state.total - state.done) / (state.done / secs)))}s`
234
+ }
235
+ const state: SwitchRebuildState = {
236
+ state: 'building',
237
+ done: 0,
238
+ total: 0,
239
+ startedAt: Date.now(),
240
+ finishedAt: 0,
241
+ failures: [],
242
+ }
243
+ try {
244
+ hooks?.log?.(`rebuild started: dir=${layout.dir} keep=${keepArchives}`)
245
+ emit()
246
+ await mkdir(layout.dir, { recursive: true })
247
+ const buildingPath = join(layout.dir, layout.building)
248
+ // A stale shadow from a crashed run is discarded.
249
+ if (existsSync(buildingPath)) await unlink(buildingPath)
250
+ const shadow = new SwitchIndexEngine({ path: buildingPath })
251
+ await shadow.open()
252
+ try {
253
+ const records = await sessionQuery.listSessions()
254
+ state.total = records.length
255
+ hooks?.log?.(`rebuild corpus listed: ${state.total} sessions (archived included, full content)`)
256
+ emit()
257
+ // R1 合表语义:归档会话**同样全量读内容**入影子索引(readSession 对归档是
258
+ // replay-validate 不激活);构建完成后按官方归档集合统一翻 flag。
259
+ const archivedSet = new Set(archiveSource?.()?.archivedSessionIds ?? [])
260
+ const readLog = async (header: { id: string; version: number; createdAt?: number; cwd?: string }) => {
261
+ let log: { session: { id: string; version: number; createdAt?: number; cwd?: string }; events: readonly SwitchRawEvent[] }
262
+ try {
263
+ log = await sessionQuery.readSession(header.id)
264
+ } catch (serviceError) {
265
+ if (hooks?.readSessionFromFile === undefined) throw serviceError
266
+ const fromFile = await hooks.readSessionFromFile(header.id)
267
+ if (fromFile === undefined) throw serviceError
268
+ log = fromFile
269
+ }
270
+ return {
271
+ sessionId: header.id,
272
+ version: log.session.version,
273
+ // 标题基础层随重建落行(beta.5 教训:重建不抽标题 + 版本对齐后同步
274
+ // 永不重读 = 全库标题永久空缺)。
275
+ title: extractTitleFromEvents(log.events),
276
+ cwd: log.session.cwd ?? '',
277
+ updatedAt: log.session.createdAt ?? 0,
278
+ archived: archivedSet.has(header.id),
279
+ events: log.events,
280
+ }
281
+ }
282
+ for (let i = 0; i < records.length; i += REBUILD_CHUNK) {
283
+ const chunk = records.slice(i, i + REBUILD_CHUNK)
284
+ const chunkStart = Date.now()
285
+ const reads: Array<{ sessionId: string; version: number; cwd: string; updatedAt: number; archived: boolean; events: readonly SwitchRawEvent[] }> = []
286
+ for (const record of chunk) {
287
+ try {
288
+ reads.push(await readLog(record.header))
289
+ } catch (err) {
290
+ state.failures.push({ sessionId: record.header.id, error: String(err instanceof Error ? err.message : err) })
291
+ }
292
+ }
293
+ // One transaction per chunk = one checkpoint; a chunk-level failure
294
+ // replays session-by-session to keep the bad session isolated.
295
+ try {
296
+ shadow.runBatched(() => {
297
+ for (const item of reads) {
298
+ shadow.upsertSession(item)
299
+ docsWritten += item.events.length
300
+ }
301
+ })
302
+ } catch (err) {
303
+ hooks?.log?.(`rebuild chunk txn failed, replaying individually: ${String(err instanceof Error ? err.message : err)}`)
304
+ for (const item of reads) {
305
+ try { shadow.upsertSession(item); docsWritten += item.events.length }
306
+ catch (e2) { state.failures.push({ sessionId: item.sessionId, error: String(e2 instanceof Error ? e2.message : e2) }) }
307
+ }
308
+ }
309
+ state.done = Math.min(state.total, i + chunk.length)
310
+ onProgress?.(state.done, state.total)
311
+ emit()
312
+ hooks?.log?.(`rebuild ${state.done}/${state.total} (${Math.round((state.done / Math.max(1, state.total)) * 100)}%) ${rate()} elapsed ${Math.round((Date.now() - startedMs) / 1000)}s eta ${eta()} chunk ${Date.now() - chunkStart}ms`)
313
+ }
314
+ // 归档标记统一翻转(upsertSession 不动已有行的 flag,此处是影子库的最终真值)。
315
+ shadow.setArchived(archivedSet)
316
+ shadow.close()
317
+ } catch (error) {
318
+ shadow.close()
319
+ await unlink(buildingPath).catch(() => {})
320
+ throw error
321
+ }
322
+
323
+ state.state = 'swapping'
324
+ emit()
325
+ hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`)
326
+ await swapShadowIntoActive(activeEngine, layout, hooks?.log)
327
+ await pruneArchives(layout, keepArchives)
328
+ state.finishedAt = Date.now()
329
+ state.state = 'idle'
330
+ emit()
331
+ hooks?.log?.(`rebuild done: total ${((state.finishedAt - startedMs) / 1000).toFixed(1)}s, archives pruned to ${keepArchives}`)
332
+ } catch (err) {
333
+ state.state = 'error'
334
+ state.error = String(err instanceof Error ? err.message : err)
335
+ hooks?.log?.(`rebuild FAILED at done=${state.done}: ${state.error}`)
336
+ emit()
337
+ // Leave the active engine usable if the swap itself failed before rename.
338
+ if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
339
+ }
340
+ return state
341
+ }
342
+
343
+ /** One doc-level record the snapshot importer feeds in. */
344
+ export interface SwitchImportRecord {
345
+ sessionId: string
346
+ version: number
347
+ title?: string
348
+ /** 归档标记(旧快照缺省活跃)。 */
349
+ archived?: boolean
350
+ docs: readonly { seq: number; type: string; surface: string; time: number; text: string }[]
351
+ }
352
+
353
+ /** Import doc-level records into the shadow file and swap it in (same swap path).
354
+ * @param log - 可选日志汇(空 docs 记录告警,issue #1 写入侧可观测)。 */
355
+ export async function importIntoIndex(
356
+ activeEngine: SwitchIndexEngine,
357
+ layout: SwitchIndexLayout,
358
+ records: readonly SwitchImportRecord[],
359
+ keepArchives: number,
360
+ log?: (msg: string) => void,
361
+ ): Promise<SwitchRebuildState> {
362
+ const state: SwitchRebuildState = {
363
+ state: 'building',
364
+ done: 0,
365
+ total: records.length,
366
+ startedAt: Date.now(),
367
+ finishedAt: 0,
368
+ failures: [],
369
+ }
370
+ try {
371
+ await mkdir(layout.dir, { recursive: true })
372
+ const buildingPath = join(layout.dir, layout.building)
373
+ if (existsSync(buildingPath)) await unlink(buildingPath)
374
+ const shadow = new SwitchIndexEngine({ path: buildingPath })
375
+ await shadow.open()
376
+ try {
377
+ for (const record of records) {
378
+ try {
379
+ if (record.docs.length === 0) {
380
+ // 快照往返空正文是合法输入,不硬拒;修复后这样的行下一轮 poll 即补录。
381
+ log?.(`snapshot import: session ${record.sessionId} has zero docs (hollow header, sync will backfill)`)
382
+ }
383
+ shadow.importSessionDocs({
384
+ sessionId: record.sessionId,
385
+ version: record.version,
386
+ title: record.title ?? '',
387
+ archived: record.archived === true,
388
+ docs: record.docs,
389
+ })
390
+ } catch (err) {
391
+ state.failures.push({ sessionId: record.sessionId, error: String(err instanceof Error ? err.message : err) })
392
+ }
393
+ state.done += 1
394
+ }
395
+ shadow.close()
396
+ } catch (error) {
397
+ shadow.close()
398
+ await unlink(buildingPath).catch(() => {})
399
+ throw error
400
+ }
401
+ state.state = 'swapping'
402
+ await swapShadowIntoActive(activeEngine, layout, log)
403
+ await pruneArchives(layout, keepArchives)
404
+ state.finishedAt = Date.now()
405
+ state.state = 'idle'
406
+ } catch (err) {
407
+ state.state = 'error'
408
+ state.error = String(err instanceof Error ? err.message : err)
409
+ if (!activeEngine.isOpen) await activeEngine.open().catch(() => {})
410
+ }
411
+ return state
412
+ }
413
+
414
+ /** Remove the oldest archives beyond the retention bound (sidecars too). */
415
+ async function pruneArchives(layout: SwitchIndexLayout, keep: number): Promise<void> {
416
+ const archives = await listArchives(layout)
417
+ const excess = archives.length - Math.max(0, keep)
418
+ for (let i = 0; i < excess; i += 1) {
419
+ await unlink(join(layout.dir, archives[i])).catch(() => {})
420
+ await moveOrDeleteSidecars(join(layout.dir, archives[i]), null)
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Resolve the index directory that hosts the independent index files:
426
+ * an explicit override wins, otherwise a plugin-owned directory under the
427
+ * user's home (never the official index path).
428
+ */
429
+ export function resolveIndexDir(preferred?: string): string {
430
+ if (preferred !== undefined && preferred.trim() !== '') return preferred
431
+ return join(homedir(), '.dsh-switch-search')
432
+ }