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.
- package/CHANGELOG.ja.md +22 -0
- package/CHANGELOG.ko.md +22 -0
- package/CHANGELOG.md +22 -0
- package/README.en.md +13 -11
- package/README.md +11 -10
- package/dsh.plugin.json +2 -2
- package/lib/client.js +900 -386
- package/lib/index.d.ts +241 -16
- package/lib/index.js +526 -71
- package/package.json +15 -9
- package/src/client/card.tsx +14 -10
- package/src/client/index.ts +53 -15
- package/src/client/locales.ts +36 -0
- package/src/client/panel.tsx +26 -36
- package/src/client/search/host-api.ts +5 -0
- package/src/client/search/locales.ts +81 -0
- package/src/client/search/locate.ts +330 -0
- package/src/client/search/manage.tsx +6 -2
- package/src/client/search/panel.tsx +154 -29
- package/src/client/session-menu-legacy.ts +249 -0
- package/src/client/session-menu.tsx +96 -0
- package/src/config.ts +17 -0
- package/src/host/history/tombstones.ts +100 -0
- package/src/host/index/drs-source.ts +211 -0
- package/src/host/index/engine.ts +699 -571
- package/src/host/index/rebuild.ts +432 -368
- package/src/host/index/sync.ts +413 -286
- package/src/host/settings-bridge.ts +27 -0
- package/src/index.ts +1499 -1421
- package/src/client/history-panel.tsx +0 -319
package/src/host/index/sync.ts
CHANGED
|
@@ -1,286 +1,413 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Watermark-driven incremental sync between the live-preferred sessionQuery
|
|
3
|
-
* corpus and the independent switch-search index.
|
|
4
|
-
*
|
|
5
|
-
* One pass lists the logical corpus, diffs stored `version` watermarks, and
|
|
6
|
-
* re-reads only new or changed sessions. Per-session failures are isolated and
|
|
7
|
-
* reported; one broken log never stalls the whole sync.
|
|
8
|
-
*/
|
|
9
|
-
import type { SwitchIndexEngine } from './engine.ts'
|
|
10
|
-
import { extractTitleFromEvents, type SwitchRawEvent } from './extract.ts'
|
|
11
|
-
|
|
12
|
-
/** 标题折叠的分块大小(一次 readTitleSnapshots 的 id 数上限,防整批一坏全坏)。 */
|
|
13
|
-
const TITLE_CHUNK = 50
|
|
14
|
-
|
|
15
|
-
/** The sessionQuery faces the sync reads (structural mirrors). */
|
|
16
|
-
export interface SwitchSyncSessionQuery {
|
|
17
|
-
listSessions(): Promise<readonly { header: { id: string; version: number; createdAt?: number; cwd?: string } }[]>
|
|
18
|
-
readSession(sessionId: string): Promise<{
|
|
19
|
-
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
20
|
-
events: readonly SwitchRawEvent[]
|
|
21
|
-
}>
|
|
22
|
-
readTitleSnapshots?(sessionIds: readonly string[]): Promise<readonly {
|
|
23
|
-
status: 'fulfilled' | 'rejected'
|
|
24
|
-
value?: { session: { id: string }; title?: { title: string } }
|
|
25
|
-
}[]>
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Live sync progress reported to the status endpoint. */
|
|
29
|
-
export interface SwitchSyncState {
|
|
30
|
-
/** What the syncer is doing right now. */
|
|
31
|
-
state: 'idle' | 'syncing' | 'error'
|
|
32
|
-
/** Epoch ms of the last completed pass. */
|
|
33
|
-
lastSyncAt: number
|
|
34
|
-
/** Sessions currently in the index. */
|
|
35
|
-
indexed: number
|
|
36
|
-
/** Sessions seen in the corpus at the last pass. */
|
|
37
|
-
total: number
|
|
38
|
-
/** Sessions re-read in the last pass. */
|
|
39
|
-
updated: number
|
|
40
|
-
/** Per-session failures from the last pass. */
|
|
41
|
-
failures: { sessionId: string; error: string }[]
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
} catch (
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Watermark-driven incremental sync between the live-preferred sessionQuery
|
|
3
|
+
* corpus and the independent switch-search index.
|
|
4
|
+
*
|
|
5
|
+
* One pass lists the logical corpus, diffs stored `version` watermarks, and
|
|
6
|
+
* re-reads only new or changed sessions. Per-session failures are isolated and
|
|
7
|
+
* reported; one broken log never stalls the whole sync.
|
|
8
|
+
*/
|
|
9
|
+
import type { SwitchIndexEngine } from './engine.ts'
|
|
10
|
+
import { extractTitleFromEvents, type SwitchRawEvent } from './extract.ts'
|
|
11
|
+
|
|
12
|
+
/** 标题折叠的分块大小(一次 readTitleSnapshots 的 id 数上限,防整批一坏全坏)。 */
|
|
13
|
+
const TITLE_CHUNK = 50
|
|
14
|
+
|
|
15
|
+
/** The sessionQuery faces the sync reads (structural mirrors). */
|
|
16
|
+
export interface SwitchSyncSessionQuery {
|
|
17
|
+
listSessions(): Promise<readonly { header: { id: string; version: number; createdAt?: number; cwd?: string } }[]>
|
|
18
|
+
readSession(sessionId: string): Promise<{
|
|
19
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
20
|
+
events: readonly SwitchRawEvent[]
|
|
21
|
+
}>
|
|
22
|
+
readTitleSnapshots?(sessionIds: readonly string[]): Promise<readonly {
|
|
23
|
+
status: 'fulfilled' | 'rejected'
|
|
24
|
+
value?: { session: { id: string }; title?: { title: string } }
|
|
25
|
+
}[]>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Live sync progress reported to the status endpoint. */
|
|
29
|
+
export interface SwitchSyncState {
|
|
30
|
+
/** What the syncer is doing right now. */
|
|
31
|
+
state: 'idle' | 'syncing' | 'error'
|
|
32
|
+
/** Epoch ms of the last completed pass. */
|
|
33
|
+
lastSyncAt: number
|
|
34
|
+
/** Sessions currently in the index. */
|
|
35
|
+
indexed: number
|
|
36
|
+
/** Sessions seen in the corpus at the last pass. */
|
|
37
|
+
total: number
|
|
38
|
+
/** Sessions re-read in the last pass. */
|
|
39
|
+
updated: number
|
|
40
|
+
/** Per-session failures from the last pass. */
|
|
41
|
+
failures: { sessionId: string; error: string }[]
|
|
42
|
+
/** 补录后仍「有表头无正文」的行数(hollow 体检面,含合法真空会话)。 */
|
|
43
|
+
hollow: number
|
|
44
|
+
/** Last pass error (whole-pass abort), if any. */
|
|
45
|
+
error?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The official archive-set face (workspaceRegistry mirror): read-only.
|
|
50
|
+
* Resolved lazily per pass — the registry may mount after this plugin.
|
|
51
|
+
*/
|
|
52
|
+
export interface SwitchArchiveSource {
|
|
53
|
+
readonly archivedSessionIds: readonly string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** DRS 语料头(依赖倒置端口;真身 = drs-source.ts 的 DrsCorpusSource)。 */
|
|
57
|
+
export interface SwitchDrsCorpusHead {
|
|
58
|
+
sessionId: string
|
|
59
|
+
/** 水位代理 = DRS sessions.artifact_mtime_ms(日志重写必 bump)。 */
|
|
60
|
+
version: number
|
|
61
|
+
title: string
|
|
62
|
+
cwd: string
|
|
63
|
+
updatedAt: number
|
|
64
|
+
archived: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** DRS 检索臂文档(surface 已折算引擎字符串域;切分由引擎重算)。 */
|
|
68
|
+
export interface SwitchDrsCorpusDoc {
|
|
69
|
+
seq: number
|
|
70
|
+
type: string
|
|
71
|
+
surface: string
|
|
72
|
+
time: number
|
|
73
|
+
text: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* DRS 语料源端口(T7.1):corpusSource='drs' 时语料改从
|
|
78
|
+
* dsh-reference-session-collector 投影库取,docs 直插引擎;不可达时整轮回退原生
|
|
79
|
+
* sessionQuery + 文件兜底链路。单测注入普通对象。
|
|
80
|
+
*/
|
|
81
|
+
export interface SwitchDrsCorpusSource {
|
|
82
|
+
listSessions(): Promise<readonly SwitchDrsCorpusHead[]>
|
|
83
|
+
readSession(sessionId: string): Promise<{ head: SwitchDrsCorpusHead; docs: readonly SwitchDrsCorpusDoc[] } | undefined>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One watermark syncer bound to one open engine. `poll()` is re-entrant-safe:
|
|
88
|
+
* overlapping calls collapse into the running pass.
|
|
89
|
+
*/
|
|
90
|
+
export class SwitchWatermarkSync {
|
|
91
|
+
private running: Promise<SwitchSyncState> | undefined
|
|
92
|
+
/**
|
|
93
|
+
* 本进程已确认「重读过且产出仍为 0 docs」的会话(键 id@version)。
|
|
94
|
+
* 无可索引文本是合法终态——没有这份缓存,hollow 补录判据会把真空会话
|
|
95
|
+
* 每轮完整重读一次(热循环)。宿主重启后缓存归零,最多各重读一次。
|
|
96
|
+
*/
|
|
97
|
+
private readonly confirmedEmpty = new Set<string>()
|
|
98
|
+
private static emptyKey(sessionId: string, version: number): string {
|
|
99
|
+
return `${sessionId}@${String(version)}`
|
|
100
|
+
}
|
|
101
|
+
private readonly state: SwitchSyncState = {
|
|
102
|
+
state: 'idle',
|
|
103
|
+
lastSyncAt: 0,
|
|
104
|
+
indexed: 0,
|
|
105
|
+
total: 0,
|
|
106
|
+
updated: 0,
|
|
107
|
+
failures: [],
|
|
108
|
+
hollow: 0,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param readSessionFromFile - 可选的**文件级兜底读取器**(管家 health 侧的
|
|
113
|
+
* 多帧 zstd 读取器):sessionQuery.readSession 对某些会话(典型:归档会话,
|
|
114
|
+
* 或服务面退化)失败时改读转录文件。返回 undefined 表示文件也不可用。
|
|
115
|
+
*/
|
|
116
|
+
constructor(
|
|
117
|
+
private readonly engine: SwitchIndexEngine,
|
|
118
|
+
private readonly sessionQuery: SwitchSyncSessionQuery,
|
|
119
|
+
private readonly readArchiveSource?: () => SwitchArchiveSource | undefined,
|
|
120
|
+
private readonly log?: (msg: string) => void,
|
|
121
|
+
private readonly readSessionFromFile?: (sessionId: string) => Promise<{
|
|
122
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
123
|
+
events: readonly SwitchRawEvent[]
|
|
124
|
+
} | undefined>,
|
|
125
|
+
/**
|
|
126
|
+
* 第三标题源(可选):官方投影缓存里的 title 行。事件流没有 session/title
|
|
127
|
+
* 的老会话,快照服务又整片不可用时,这是最后兜底。
|
|
128
|
+
*/
|
|
129
|
+
private readonly readProjectionTitle?: (sessionId: string) => string,
|
|
130
|
+
/**
|
|
131
|
+
* DRS 语料源(可选,T7.1):提供时每轮先走 DRS 轮(docs 直插);DRS 整轮
|
|
132
|
+
* 失败(网络/路由/包络)自动回退原生 sessionQuery + 文件兜底。
|
|
133
|
+
*/
|
|
134
|
+
private readonly drsSource?: SwitchDrsCorpusSource,
|
|
135
|
+
) {
|
|
136
|
+
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 读取一个会话的日志:服务面优先,失败落文件兜底。两条路都失败时抛最后
|
|
141
|
+
* 一个错误,由调用方按会话隔离记失败。
|
|
142
|
+
*/
|
|
143
|
+
private async readSessionLog(header: { id: string; version: number; createdAt?: number; cwd?: string }): Promise<{
|
|
144
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
145
|
+
events: readonly SwitchRawEvent[]
|
|
146
|
+
}> {
|
|
147
|
+
try {
|
|
148
|
+
return await this.sessionQuery.readSession(header.id)
|
|
149
|
+
} catch (serviceError) {
|
|
150
|
+
if (this.readSessionFromFile === undefined) throw serviceError
|
|
151
|
+
const fromFile = await this.readSessionFromFile(header.id)
|
|
152
|
+
if (fromFile !== undefined) {
|
|
153
|
+
this.log?.(`session ${header.id}: sessionQuery read failed, served from transcript file (${String(serviceError instanceof Error ? serviceError.message : serviceError)})`)
|
|
154
|
+
return fromFile
|
|
155
|
+
}
|
|
156
|
+
throw serviceError
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Current progress snapshot (cloned). */
|
|
161
|
+
snapshot(): SwitchSyncState {
|
|
162
|
+
return { ...this.state, failures: [...this.state.failures] }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fold titles for an explicit id set without running a full pass.
|
|
167
|
+
*
|
|
168
|
+
* Used by the `session/title` event listener so a rename lands immediately
|
|
169
|
+
* rather than at the next poll. It deliberately leaves watermarks alone: a
|
|
170
|
+
* title-only refresh can never make the index claim content it has not read,
|
|
171
|
+
* and the next poll still re-ingests the session off its bumped version.
|
|
172
|
+
* Safe for unknown ids — the header write is a no-op when no row exists.
|
|
173
|
+
* @param sessionIds - sessions whose titles should be re-folded.
|
|
174
|
+
*/
|
|
175
|
+
async refreshTitles(sessionIds: readonly string[]): Promise<void> {
|
|
176
|
+
await this.backfillTitles(sessionIds)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run one incremental pass (or await the running one).
|
|
181
|
+
* @returns the state after the pass completes.
|
|
182
|
+
*/
|
|
183
|
+
poll(): Promise<SwitchSyncState> {
|
|
184
|
+
if (this.running !== undefined) return this.running
|
|
185
|
+
this.running = this.runPass().finally(() => {
|
|
186
|
+
this.running = undefined
|
|
187
|
+
})
|
|
188
|
+
return this.running
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async runPass(): Promise<SwitchSyncState> {
|
|
192
|
+
this.state.state = 'syncing'
|
|
193
|
+
const passStart = Date.now()
|
|
194
|
+
try {
|
|
195
|
+
// DRS 语料轮(T7.1):corpusSource='drs' 时语料从 dsh-reference-session-collector
|
|
196
|
+
// 投影库取(docs 直插)。整轮失败(网络/路由/包络)→ 回退原生链路,一次
|
|
197
|
+
// 轮询永远有产出;水位语义切换的首轮全量重灌是有意的(mode switch 代价)。
|
|
198
|
+
if (this.drsSource !== undefined) {
|
|
199
|
+
try {
|
|
200
|
+
return await this.runDrsPass(passStart)
|
|
201
|
+
} catch (drsError) {
|
|
202
|
+
this.log?.(`DRS corpus pass failed, falling back to native sessionQuery: ${String(drsError instanceof Error ? drsError.message : drsError)}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const records = await this.sessionQuery.listSessions()
|
|
206
|
+
this.state.total = records.length
|
|
207
|
+
// 官方归档集合:纯 flag 翻转(R1 合表复用语义——归档保留 docs,恢复不重灌)。
|
|
208
|
+
// Registry absent -> no-op。
|
|
209
|
+
const archiveSource = this.readArchiveSource?.()
|
|
210
|
+
const archivedSet = new Set(archiveSource?.archivedSessionIds ?? [])
|
|
211
|
+
this.engine.setArchived(archivedSet)
|
|
212
|
+
const failures: { sessionId: string; error: string }[] = []
|
|
213
|
+
let updated = 0
|
|
214
|
+
const changedIds: string[] = []
|
|
215
|
+
for (const record of records) {
|
|
216
|
+
const header = record.header
|
|
217
|
+
const existing = this.engine.getSession(header.id)
|
|
218
|
+
// 归档与活跃同一读取通路:readSession 对归档会话是 replay-validate 不激活
|
|
219
|
+
// (宿主 session-query 保证)。归档正文因此入索引(可被 archived 域检索)。
|
|
220
|
+
// issue #1:hollow 行(有表头无正文)不被 version 对齐豁免——除非已确认
|
|
221
|
+
// 重读过且产出就是空(confirmedEmpty)。
|
|
222
|
+
const emptyKey = SwitchWatermarkSync.emptyKey(header.id, header.version)
|
|
223
|
+
if (existing !== undefined && existing.version === header.version
|
|
224
|
+
&& (this.confirmedEmpty.has(emptyKey) || this.engine.hasSessionDocs(header.id))) continue
|
|
225
|
+
changedIds.push(header.id)
|
|
226
|
+
try {
|
|
227
|
+
const log = await this.readSessionLog(header)
|
|
228
|
+
const produced = this.engine.upsertSession({
|
|
229
|
+
sessionId: header.id,
|
|
230
|
+
version: header.version,
|
|
231
|
+
cwd: log.session.cwd ?? header.cwd ?? '',
|
|
232
|
+
updatedAt: log.session.createdAt ?? header.createdAt ?? 0,
|
|
233
|
+
// 标题基础层:事件流白拿(归档 id 也能拿到);快照折叠只做精修。
|
|
234
|
+
title: extractTitleFromEvents(log.events),
|
|
235
|
+
events: log.events,
|
|
236
|
+
// 新行插入时带上归档标记(ON CONFLICT 不动旧行的 flag,翻转仍归
|
|
237
|
+
// setArchived)—— 免掉"插行要等下一轮 setArchived 才翻 flag"的单轮滞后。
|
|
238
|
+
archived: archivedSet.has(header.id),
|
|
239
|
+
})
|
|
240
|
+
if (produced === 0) this.confirmedEmpty.add(emptyKey)
|
|
241
|
+
else this.confirmedEmpty.delete(emptyKey)
|
|
242
|
+
updated += 1
|
|
243
|
+
} catch (err) {
|
|
244
|
+
failures.push({ sessionId: header.id, error: String(err instanceof Error ? err.message : err) })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// 归档覆盖:某些宿主线的 listSessions 可能不含归档会话 —— 归档集合里的
|
|
248
|
+
// id 若还没有索引行,这里显式补齐(readSession 对归档是 replay-validate,
|
|
249
|
+
// 失败时走文件兜底)。
|
|
250
|
+
for (const archivedId of archivedSet) {
|
|
251
|
+
const existingArchived = this.engine.getSession(archivedId)
|
|
252
|
+
// 同主循环判据:header-only 的 legacy 归档行(upsertArchivedHeader/旧快照
|
|
253
|
+
// 产物)不在 confirmedEmpty 里就要重读补正文(R1:归档正文入索引)。
|
|
254
|
+
if (existingArchived !== undefined
|
|
255
|
+
&& (this.confirmedEmpty.has(SwitchWatermarkSync.emptyKey(archivedId, existingArchived.version))
|
|
256
|
+
|| this.engine.hasSessionDocs(archivedId))) continue
|
|
257
|
+
try {
|
|
258
|
+
const log = await this.readSessionLog({ id: archivedId, version: -1 })
|
|
259
|
+
const produced = this.engine.upsertSession({
|
|
260
|
+
sessionId: archivedId,
|
|
261
|
+
version: log.session.version,
|
|
262
|
+
cwd: log.session.cwd ?? '',
|
|
263
|
+
updatedAt: log.session.createdAt ?? 0,
|
|
264
|
+
title: extractTitleFromEvents(log.events),
|
|
265
|
+
events: log.events,
|
|
266
|
+
archived: true,
|
|
267
|
+
})
|
|
268
|
+
const archivedKey = SwitchWatermarkSync.emptyKey(archivedId, log.session.version)
|
|
269
|
+
if (produced === 0) this.confirmedEmpty.add(archivedKey)
|
|
270
|
+
else this.confirmedEmpty.delete(archivedKey)
|
|
271
|
+
updated += 1
|
|
272
|
+
} catch (err) {
|
|
273
|
+
failures.push({ sessionId: archivedId, error: `archived: ${String(err instanceof Error ? err.message : err)}` })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Drop sessions that vanished from the corpus —— 但**归档集合里的行不删**:
|
|
277
|
+
// 某些宿主线的 listSessions 可能过滤归档,归档集合才是删除真值(purge 会把
|
|
278
|
+
// id 从归档数组里摘掉,彼时下一轮自然清行)。
|
|
279
|
+
const corpusIds = new Set(records.map(record => record.header.id))
|
|
280
|
+
for (const indexed of this.engine.listIndexedSessions()) {
|
|
281
|
+
if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) {
|
|
282
|
+
this.engine.removeSession(indexed.sessionId)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
await this.backfillTitles(changedIds)
|
|
286
|
+
// 空标题扫尾:重建行/老会话可能没有 session/title 事件,快照精修后仍空
|
|
287
|
+
// 的行用投影缓存兜底。每轮封顶 100 行,标题落地后集合自然收缩。
|
|
288
|
+
const emptyTitles = this.engine.listIndexedSessions()
|
|
289
|
+
.filter(session => session.title.trim() === '')
|
|
290
|
+
.map(session => session.sessionId)
|
|
291
|
+
.slice(0, 100)
|
|
292
|
+
if (emptyTitles.length > 0) {
|
|
293
|
+
await this.backfillTitles(emptyTitles)
|
|
294
|
+
let filled = 0
|
|
295
|
+
for (const id of emptyTitles) {
|
|
296
|
+
const row = this.engine.getSession(id)
|
|
297
|
+
if (row !== undefined && row.title.trim() !== '') continue
|
|
298
|
+
const fromProjection = this.readProjectionTitle?.(id) ?? ''
|
|
299
|
+
if (fromProjection.trim() !== '') {
|
|
300
|
+
this.engine.updateSessionHeader({ sessionId: id, title: fromProjection })
|
|
301
|
+
filled += 1
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (filled > 0) this.log?.(`title sweep: ${filled}/${emptyTitles.length} filled from projection cache`)
|
|
305
|
+
}
|
|
306
|
+
this.state.updated = updated
|
|
307
|
+
this.state.failures = failures
|
|
308
|
+
this.state.indexed = this.engine.countSessions()
|
|
309
|
+
this.state.hollow = this.engine.listHollowSessions().length
|
|
310
|
+
this.state.lastSyncAt = Date.now()
|
|
311
|
+
this.state.state = 'idle'
|
|
312
|
+
this.state.error = undefined
|
|
313
|
+
this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} hollow=${this.state.hollow} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`)
|
|
314
|
+
} catch (err) {
|
|
315
|
+
this.state.state = 'error'
|
|
316
|
+
this.state.error = String(err instanceof Error ? err.message : err)
|
|
317
|
+
this.log?.(`sync pass FAILED: ${this.state.error}`)
|
|
318
|
+
}
|
|
319
|
+
return this.snapshot()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* DRS 语料轮(T7.1):语料头从 DRS `op=sessions` 分页拉取,水位
|
|
324
|
+
* diff(version=artifact_mtime_ms)后按会话 `op=session&fields=search`
|
|
325
|
+
* 取检索臂 docs 直插引擎(upsertSessionDocs)。归档集合改用 DRS 行的
|
|
326
|
+
* archived 旗标(DRS sync 本就只读跟随 workspace.json,同源);标题直接
|
|
327
|
+
* 采 DRS 行(投影侧同一 extractTitleFromEvents 视界),不再走快照精修。
|
|
328
|
+
* 每会话失败隔离语义与原生轮一致;无可索引文本仍进 confirmedEmpty 缓存。
|
|
329
|
+
*/
|
|
330
|
+
private async runDrsPass(passStart: number): Promise<SwitchSyncState> {
|
|
331
|
+
const source = this.drsSource
|
|
332
|
+
if (source === undefined) throw new Error('DRS corpus source unavailable')
|
|
333
|
+
const heads = await source.listSessions()
|
|
334
|
+
this.state.total = heads.length
|
|
335
|
+
const archivedSet = new Set(heads.filter(head => head.archived).map(head => head.sessionId))
|
|
336
|
+
this.engine.setArchived(archivedSet)
|
|
337
|
+
const failures: { sessionId: string; error: string }[] = []
|
|
338
|
+
let updated = 0
|
|
339
|
+
for (const head of heads) {
|
|
340
|
+
// T7.3 直连语义:DRS 轮只维护 sessions 头水位(标题/目录/末时间/归档)——
|
|
341
|
+
// 内容检索走 op=search&group=session 直连,本地 docs/docs_fts 退役不新增。
|
|
342
|
+
const existing = this.engine.getSession(head.sessionId)
|
|
343
|
+
if (existing !== undefined && existing.version === head.version) continue
|
|
344
|
+
try {
|
|
345
|
+
this.engine.upsertSessionHead({
|
|
346
|
+
sessionId: head.sessionId,
|
|
347
|
+
version: head.version,
|
|
348
|
+
title: head.title,
|
|
349
|
+
cwd: head.cwd,
|
|
350
|
+
updatedAt: head.updatedAt,
|
|
351
|
+
archived: head.archived,
|
|
352
|
+
})
|
|
353
|
+
updated += 1
|
|
354
|
+
} catch (err) {
|
|
355
|
+
failures.push({ sessionId: head.sessionId, error: `drs: ${String(err instanceof Error ? err.message : err)}` })
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// 删除扫尾与原生轮同款:DRS 语料集里消失且未归档的行移除(DRS 侧保留
|
|
359
|
+
// 已删目录的行时,这里自然不触发——删除真值仍以语料集为准)。
|
|
360
|
+
const corpusIds = new Set(heads.map(head => head.sessionId))
|
|
361
|
+
for (const indexed of this.engine.listIndexedSessions()) {
|
|
362
|
+
if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) {
|
|
363
|
+
this.engine.removeSession(indexed.sessionId)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
this.state.updated = updated
|
|
367
|
+
this.state.failures = failures
|
|
368
|
+
this.state.indexed = this.engine.countSessions()
|
|
369
|
+
this.state.hollow = this.engine.listHollowSessions().length
|
|
370
|
+
this.state.lastSyncAt = Date.now()
|
|
371
|
+
this.state.state = 'idle'
|
|
372
|
+
this.state.error = undefined
|
|
373
|
+
this.log?.(`sync pass(DRS): scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} hollow=${this.state.hollow} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`)
|
|
374
|
+
return this.snapshot()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Fold latest titles for changed sessions into the index header rows.
|
|
379
|
+
*
|
|
380
|
+
* beta.4 教训:整批一次调用,任何一个坏 id(典型:归档 id)让整个 promise
|
|
381
|
+
* reject,catch 一吞就是**全部**标题丢失。改为分块 + 块失败时逐 id 重试 ——
|
|
382
|
+
* 单点坏 id 最多损失它自己的精修标题(入索引时的 extractTitleFromEvents
|
|
383
|
+
* 基础层仍然在)。
|
|
384
|
+
*/
|
|
385
|
+
private async backfillTitles(sessionIds: readonly string[]): Promise<void> {
|
|
386
|
+
const readTitles = this.sessionQuery.readTitleSnapshots
|
|
387
|
+
if (readTitles === undefined || sessionIds.length === 0) return
|
|
388
|
+
const ids = [...new Set(sessionIds)]
|
|
389
|
+
const foldOne = (observation: { status: 'fulfilled' | 'rejected'; value?: { session: { id: string }; title?: { title: string } } }): void => {
|
|
390
|
+
if (observation.status !== 'fulfilled' || observation.value === undefined) return
|
|
391
|
+
const title = observation.value.title?.title
|
|
392
|
+
// 归档行的标题同样折叠(归档会话仍可改名,索引标题要跟上)。
|
|
393
|
+
if (typeof title === 'string' && title.trim().length > 0) {
|
|
394
|
+
this.engine.updateSessionHeader({ sessionId: observation.value.session.id, title })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (let start = 0; start < ids.length; start += TITLE_CHUNK) {
|
|
398
|
+
const chunk = ids.slice(start, start + TITLE_CHUNK)
|
|
399
|
+
try {
|
|
400
|
+
const observations = await readTitles(chunk)
|
|
401
|
+
observations.forEach(foldOne)
|
|
402
|
+
} catch {
|
|
403
|
+
// 整块失败 → 逐 id 重试,坏 id 只损失自己。
|
|
404
|
+
for (const id of chunk) {
|
|
405
|
+
try {
|
|
406
|
+
const observations = await readTitles([id])
|
|
407
|
+
observations.forEach(foldOne)
|
|
408
|
+
} catch { /* 该 id 的精修标题放弃,基础层标题仍在 */ }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|