dsh-manage-session 0.4.8 → 0.5.3
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.md +36 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/cordis.patch.yml +6 -1
- package/dsh.plugin.json +22 -22
- package/lib/client.js +3848 -202
- package/lib/index.d.ts +759 -29
- package/lib/index.js +2604 -93
- package/locale/de.json +6 -0
- package/locale/en.json +6 -0
- package/locale/es.json +6 -0
- package/locale/fr.json +6 -0
- package/locale/it.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/ko.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +43 -32
- package/src/client/bridge-scope.ts +213 -0
- package/src/client/index.ts +154 -26
- package/src/client/locales.ts +857 -223
- package/src/client/panel.tsx +139 -118
- package/src/client/search/card.tsx +563 -0
- package/src/client/search/host-api.ts +208 -0
- package/src/client/search/locales.ts +780 -0
- package/src/client/search/manage.tsx +400 -0
- package/src/client/search/panel.tsx +815 -0
- package/src/client/search/platform.ts +136 -0
- package/src/config.ts +54 -5
- package/src/host/history/archive-source.ts +48 -0
- package/src/host/history/archive.ts +56 -1
- package/src/host/history/favorites.ts +71 -0
- package/src/host/index/archive-source.ts +90 -0
- package/src/host/index/engine.ts +571 -0
- package/src/host/index/extract.ts +272 -0
- package/src/host/index/peers.ts +62 -0
- package/src/host/index/rebuild.ts +368 -0
- package/src/host/index/schema.ts +136 -0
- package/src/host/index/snapshot.ts +135 -0
- package/src/host/index/sync.ts +286 -0
- package/src/host/registry/archive-registry.ts +317 -0
- package/src/host/settings-bridge.ts +237 -0
- package/src/index.ts +1421 -532
package/src/index.ts
CHANGED
|
@@ -1,532 +1,1421 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-manage-session
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from '
|
|
23
|
-
import {
|
|
24
|
-
import
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
} from './host/health/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
} from './host/
|
|
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
|
-
export
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return
|
|
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
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-manage-session 主机半身(合并包):两条 fenced HTTP 路由 ——
|
|
3
|
+
*
|
|
4
|
+
* - `/session-steward/api`:管家子域,方法名一律 `session-*`;
|
|
5
|
+
* - `/switch-search/api`:搜索索引子域(历史前缀,浏览器旧 bundle 依赖),方法名 `index-*`/`list-sessions` 等。
|
|
6
|
+
*
|
|
7
|
+
* 三个子域开关(feature gate,关掉不注册对应方法,调用返回显式 disabled 错误,不留空壳):
|
|
8
|
+
* - `historyFiles`(会话历史文件 / 养老院):`session-history-*`;
|
|
9
|
+
* - `healthCheck`(健康检查 / 体检):`session-health-*`;
|
|
10
|
+
* - `search`(搜索索引):`index-*` 及侧栏搜索入口。
|
|
11
|
+
* 另有插件总开关 `enabled`:关闭后不注册任何路由。
|
|
12
|
+
*
|
|
13
|
+
* 搜索子域自带独立索引(node:sqlite FTS5,本插件自有的文件,绝非宿主官方
|
|
14
|
+
* session-query 索引):`list-sessions` 标题语料、`content-search` 按会话聚合的
|
|
15
|
+
* 内容搜索、`index-status/rebuild/export/import` 索引生命周期面。
|
|
16
|
+
*
|
|
17
|
+
* 只读诊断 + 可逆处置;不改会话日志、不改历史数据、不静默丢弃字段。
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from 'cordis'
|
|
20
|
+
import z from '@deepseek-ai/schemastery'
|
|
21
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_CONFIG,
|
|
27
|
+
STEWARD_API_PREFIX,
|
|
28
|
+
STEWARD_SETTINGS_NAMESPACE,
|
|
29
|
+
SWITCH_API_PREFIX,
|
|
30
|
+
SWITCH_DEFAULT_CONFIG,
|
|
31
|
+
SWITCH_SEARCH_SETTINGS_NAMESPACE,
|
|
32
|
+
type StewardConfig,
|
|
33
|
+
type SwitchSearchConfig,
|
|
34
|
+
} from './config.ts'
|
|
35
|
+
import { archiveHistory, listHistory, pruneHistory, storagePathsFor } from './host/history/archive.ts'
|
|
36
|
+
import { listFavorites, setFavoriteState } from './host/history/favorites.ts'
|
|
37
|
+
import type { StewardRegistryFace } from './host/history/archive-source.ts'
|
|
38
|
+
import { locateSessionUsage, purgeHistory } from './host/history/purge.ts'
|
|
39
|
+
import {
|
|
40
|
+
installLegacySettingsBridge,
|
|
41
|
+
settingsDescribe,
|
|
42
|
+
settingsMutate,
|
|
43
|
+
type BridgeState,
|
|
44
|
+
} from './host/settings-bridge.ts'
|
|
45
|
+
import { buildProjectionOwnerIndex, createAttributor, defaultProfileNodeModules } from './host/health/attribution.ts'
|
|
46
|
+
import { HealthCache } from './host/health/cache.ts'
|
|
47
|
+
import { buildSessionReport, type SessionHealthReport } from './host/health/gates.ts'
|
|
48
|
+
import { decodeSessionLogFile } from './host/health/decode.ts'
|
|
49
|
+
import { assessRepair, prescribe, quarantineProjectionCache } from './host/health/repair.ts'
|
|
50
|
+
import { countCorpus, findSession, findSessionLog, scanSessions } from './host/health/scan.ts'
|
|
51
|
+
import { gateSourceKind, migrateSessionSourceKind, readSourceKindFacts } from './host/health/source-kind.ts'
|
|
52
|
+
import { SwitchIndexEngine, type SwitchIndexContentType, type SwitchSearchSort } from './host/index/engine.ts'
|
|
53
|
+
import { SwitchWatermarkSync, type SwitchSyncState } from './host/index/sync.ts'
|
|
54
|
+
import { createArchiveSource, type SwitchArchiveDiagnostics } from './host/index/archive-source.ts'
|
|
55
|
+
import { detectSteward } from './host/index/peers.ts'
|
|
56
|
+
// 归档集合的写方(prune)在管家子域:搜索子域只读归档集合把归档会话折进索引
|
|
57
|
+
// 状态(单一写方,一个 owner —— 合并后 owner 就在本包 history 子域)。
|
|
58
|
+
export { createArchiveSource, type SwitchArchiveDiagnostics } from './host/index/archive-source.ts'
|
|
59
|
+
import {
|
|
60
|
+
DEFAULT_INDEX_LAYOUT,
|
|
61
|
+
importIntoIndex,
|
|
62
|
+
listArchives,
|
|
63
|
+
recoverIndex,
|
|
64
|
+
rebuildIndex,
|
|
65
|
+
resolveIndexDir,
|
|
66
|
+
type SwitchIndexLayout,
|
|
67
|
+
type SwitchRebuildState,
|
|
68
|
+
} from './host/index/rebuild.ts'
|
|
69
|
+
import { exportSnapshot, parseSnapshot } from './host/index/snapshot.ts'
|
|
70
|
+
import { extractTitleFromEvents, type SwitchRawEvent } from './host/index/extract.ts'
|
|
71
|
+
|
|
72
|
+
export { DEFAULT_CONFIG, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, SWITCH_API_PREFIX, SWITCH_DEFAULT_CONFIG } from './config.ts'
|
|
73
|
+
export type { StewardConfig, SwitchSearchConfig } from './config.ts'
|
|
74
|
+
export { readArchiveSet, pruneArchiveFile, archiveArchiveFile, editWorkspaceDocument } from './host/history/archive-source.ts'
|
|
75
|
+
export { listHistory, pruneHistory, archiveHistory } from './host/history/archive.ts'
|
|
76
|
+
export { purgeHistory, locateSessionUsage, indexSessionDirs, isSafeChild, dirSize, sessionsRootFor, projCacheRootFor, isSourceMigrateBackupName, backupFilesIn } from './host/history/purge.ts'
|
|
77
|
+
export { buildSessionReport, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, readProjectionCache, readTailFacts } from './host/health/gates.ts'
|
|
78
|
+
export { gateSourceKind, migrateSessionSourceKind, migrateLegacySource, readSourceKindFacts, SOURCE_MIGRATE_BACKUP_SUFFIX, V4_HOST_MIN } from './host/health/source-kind.ts'
|
|
79
|
+
export { firstLosslessViolation, isLossless } from './host/health/lossless.ts'
|
|
80
|
+
export { decodeSessionLogBytes, decodeSessionLogFile, scanZstdFrames } from './host/health/decode.ts'
|
|
81
|
+
export {
|
|
82
|
+
classifyGenerationFilename,
|
|
83
|
+
generationLogFilename,
|
|
84
|
+
isMigrationStagingFilename,
|
|
85
|
+
latestArtifactMtime,
|
|
86
|
+
parseGenerationLogFilename,
|
|
87
|
+
readSessionGenerations,
|
|
88
|
+
sessionPriority,
|
|
89
|
+
} from './host/health/generation.ts'
|
|
90
|
+
export type {
|
|
91
|
+
GenerationArtifact,
|
|
92
|
+
LogArtifact,
|
|
93
|
+
LogCompression,
|
|
94
|
+
SessionGenerations,
|
|
95
|
+
SessionPriority,
|
|
96
|
+
} from './host/health/generation.ts'
|
|
97
|
+
export { buildProjectionOwnerIndex, createAttributor } from './host/health/attribution.ts'
|
|
98
|
+
export { prescribe, quarantineProjectionCache } from './host/health/repair.ts'
|
|
99
|
+
export { countCorpus, discoverSessions, findSession, findSessionLog, scanSessions } from './host/health/scan.ts'
|
|
100
|
+
export type { DiscoveredSession } from './host/health/scan.ts'
|
|
101
|
+
export { HealthCache } from './host/health/cache.ts'
|
|
102
|
+
export type { HealthCacheEntry } from './host/health/cache.ts'
|
|
103
|
+
export { assessRepair } from './host/health/repair.ts'
|
|
104
|
+
export type { RepairAssessment, RepairVerdict } from './host/health/repair.ts'
|
|
105
|
+
export { SwitchIndexEngine } from './host/index/engine.ts'
|
|
106
|
+
export { extractTitleFromEvents } from './host/index/extract.ts'
|
|
107
|
+
export { SwitchWatermarkSync } from './host/index/sync.ts'
|
|
108
|
+
export { rebuildIndex, importIntoIndex, recoverIndex, DEFAULT_INDEX_LAYOUT } from './host/index/rebuild.ts'
|
|
109
|
+
export { exportSnapshot, parseSnapshot } from './host/index/snapshot.ts'
|
|
110
|
+
export { detectSteward, probePeer } from './host/index/peers.ts'
|
|
111
|
+
|
|
112
|
+
/** 本插件声明的宿主服务(与 toggle 相同的注入面)。0.2.1-a.2 起 webRuntime 更名 webStartup,读取面走双名回退链。 */
|
|
113
|
+
export const inject = ['webServer', 'webStartup']
|
|
114
|
+
|
|
115
|
+
/** webServer 服务面(结构化镜像,零 value import)。 */
|
|
116
|
+
interface StewardWebServer {
|
|
117
|
+
register(route: {
|
|
118
|
+
kind: 'exact' | 'prefix'
|
|
119
|
+
path: string
|
|
120
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
121
|
+
}): () => void
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** web runtime 服务面:绑定派生的可信 authority。 */
|
|
125
|
+
interface StewardWebRuntime {
|
|
126
|
+
trustedHosts: readonly string[]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Live reference the 0.1.7 loader hands `apply` for `.volatile()` config fields. */
|
|
130
|
+
interface VolatileRef<T> {
|
|
131
|
+
get(): T
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Resolve one possibly-volatile field: a live ref on 0.1.7+, a plain value otherwise. */
|
|
135
|
+
function readVolatileValue<T>(value: T | VolatileRef<T> | undefined): T | undefined {
|
|
136
|
+
if (value !== null && typeof value === 'object' && typeof (value as VolatileRef<T>).get === 'function') {
|
|
137
|
+
return (value as VolatileRef<T>).get()
|
|
138
|
+
}
|
|
139
|
+
return value as T | undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `.volatile()` 探针回退(preset-manager 的 volatile breakpoint 同款):宿主
|
|
144
|
+
* schemastery 只在 0.1.7+ 线提供 `.volatile()`,老线上该方法是 undefined ——
|
|
145
|
+
* 在模块加载期链式调用直接 TypeError,插件整包起不来。这里探测到才链,探测
|
|
146
|
+
* 不到就返回普通字段(老线的配置只来自组合入口,无设置表单,行为正确)。
|
|
147
|
+
*/
|
|
148
|
+
function withVolatile<T extends { volatile?: unknown }>(field: T): T {
|
|
149
|
+
if (field !== null && typeof field === 'object' && typeof field.volatile === 'function') {
|
|
150
|
+
return (field as { volatile: () => T }).volatile()
|
|
151
|
+
}
|
|
152
|
+
return field
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 运行时配置 schema(与 src/config.ts 的形状保持一致)。0.1.7+:volatile 字段即设置表单。 */
|
|
156
|
+
export const Config = z.object({
|
|
157
|
+
enabled: withVolatile(z.boolean().default(true)),
|
|
158
|
+
historyFiles: withVolatile(z.boolean().default(true)),
|
|
159
|
+
healthCheck: withVolatile(z.boolean().default(true)),
|
|
160
|
+
search: withVolatile(z.boolean().default(true)),
|
|
161
|
+
defaultMode: withVolatile(z.union(['title', 'content']).default('title')),
|
|
162
|
+
autoSync: withVolatile(z.boolean().default(true)),
|
|
163
|
+
syncIntervalMs: withVolatile(z.number().default(30_000)),
|
|
164
|
+
archiveKeep: withVolatile(z.number().default(2)),
|
|
165
|
+
indexDir: withVolatile(z.string().default('')),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
/** 单次请求体的上限(防御无界读取)。 */
|
|
169
|
+
const MAX_BODY_BYTES = 16 << 20
|
|
170
|
+
|
|
171
|
+
/** content-search 单次返回的默认会话数上限。 */
|
|
172
|
+
const DEFAULT_LIMIT = 20
|
|
173
|
+
|
|
174
|
+
/** 独立索引目录的环境变量覆盖。 */
|
|
175
|
+
const INDEX_DIR_ENV = 'DSH_SWITCH_SEARCH_DIR'
|
|
176
|
+
|
|
177
|
+
/** 改名/自动标题落盘的 log-only 事件(追加进日志,会推 version,水位轮询本可迟到一轮兜住)。 */
|
|
178
|
+
const TITLE_EVENT_TYPE = 'session/title'
|
|
179
|
+
|
|
180
|
+
/** 改名风暴合并为一次标题折叠(自动标题一轮会连发多条)。 */
|
|
181
|
+
const TITLE_FLUSH_MS = 250
|
|
182
|
+
|
|
183
|
+
/** 归一化 Host authority,无法解析时为 undefined。 */
|
|
184
|
+
function parseAuthority(authority: string): URL | undefined {
|
|
185
|
+
try {
|
|
186
|
+
return new URL(`http://${authority}`)
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 主机名是否本机回环。 */
|
|
193
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
194
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
195
|
+
const parts = hostname.split('.')
|
|
196
|
+
return parts.length === 4
|
|
197
|
+
&& parts[0] === '127'
|
|
198
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Host 是否命中 trustedHosts(精确或省略端口)。 */
|
|
202
|
+
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
|
|
203
|
+
return trustedHosts.some((entry) => {
|
|
204
|
+
const entryUrl = parseAuthority(entry)
|
|
205
|
+
if (entryUrl === undefined) return false
|
|
206
|
+
const canonical = entryUrl.port === '' ? entryUrl.hostname : entryUrl.host
|
|
207
|
+
return canonical === hostUrl.host
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 浏览器可信围栏(与 /api 网关行为一致):DNS 重绑定/跨站防御,不是鉴权。 */
|
|
212
|
+
function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
213
|
+
const host = req.headers.host
|
|
214
|
+
if (host === undefined) return false
|
|
215
|
+
const hostUrl = parseAuthority(host)
|
|
216
|
+
if (hostUrl === undefined) return false
|
|
217
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
|
218
|
+
const fetchSite = req.headers['sec-fetch-site']
|
|
219
|
+
if (typeof fetchSite === 'string' && fetchSite === 'cross-site') return false
|
|
220
|
+
const origin = req.headers.origin
|
|
221
|
+
if (origin === undefined) return true
|
|
222
|
+
try {
|
|
223
|
+
return new URL(origin).host === hostUrl.host
|
|
224
|
+
} catch {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 读原始请求体(有界)。 */
|
|
230
|
+
async function readRawBody(req: IncomingMessage): Promise<string> {
|
|
231
|
+
const chunks: Buffer[] = []
|
|
232
|
+
let total = 0
|
|
233
|
+
for await (const chunk of req) {
|
|
234
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)
|
|
235
|
+
total += buffer.length
|
|
236
|
+
if (total > MAX_BODY_BYTES) throw new Error('request body too large')
|
|
237
|
+
chunks.push(buffer)
|
|
238
|
+
}
|
|
239
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 读 JSON 请求体(空体视为 {})。 */
|
|
243
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
244
|
+
const text = await readRawBody(req)
|
|
245
|
+
if (text.trim() === '') return {}
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(text) as unknown
|
|
248
|
+
} catch {
|
|
249
|
+
throw new Error('request body is not JSON')
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 写 JSON 响应。 */
|
|
254
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
255
|
+
const text = JSON.stringify(body)
|
|
256
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
257
|
+
res.end(text)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 写原始文本响应。 */
|
|
261
|
+
function writeRaw(res: ServerResponse, status: number, contentType: string, body: string): void {
|
|
262
|
+
res.writeHead(status, { 'content-type': contentType, 'cache-control': 'no-cache' })
|
|
263
|
+
res.end(body)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 会话 id 形状校验(拒绝任意字符串进入路径拼接)。 */
|
|
267
|
+
function asSessionId(value: unknown): string | undefined {
|
|
268
|
+
if (typeof value !== 'string') return undefined
|
|
269
|
+
const trimmed = value.trim()
|
|
270
|
+
if (trimmed === '' || trimmed.length > 200) return undefined
|
|
271
|
+
if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return undefined
|
|
272
|
+
return trimmed
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 单会话体检的最大扫描上限。 */
|
|
276
|
+
const MAX_SCAN_LIMIT = 200
|
|
277
|
+
|
|
278
|
+
/** 运行时依赖。 */
|
|
279
|
+
export interface StewardRuntime {
|
|
280
|
+
config: () => Required<StewardConfig>
|
|
281
|
+
dshHome: string
|
|
282
|
+
registry: () => StewardRegistryFace | undefined
|
|
283
|
+
projectionStateFor: (sessionId: string) => Record<string, unknown> | undefined
|
|
284
|
+
attribute: (projection: string) => { projection?: string; package?: string; field?: string } | undefined
|
|
285
|
+
log: (message: string) => void
|
|
286
|
+
/**
|
|
287
|
+
* 体检结果缓存(进程内,随 fiber 存活)。
|
|
288
|
+
*
|
|
289
|
+
* 可选:缺省表示本次装配不启用缓存(只影响「关面板重开」是否零延迟,
|
|
290
|
+
* 不影响任何判定结果)。`apply()` 总是提供实例。
|
|
291
|
+
*/
|
|
292
|
+
cache?: HealthCache
|
|
293
|
+
/**
|
|
294
|
+
* Legacy 设置桥(≤0.1.5;可选):老宿主 settings 服务的 register/get/update
|
|
295
|
+
* 租约。缺省(单测/modern 宿主)= describe 回空投影、mutate 显式拒绝。
|
|
296
|
+
*/
|
|
297
|
+
settingsBridge?: BridgeState
|
|
298
|
+
/**
|
|
299
|
+
* 搜索索引联动钩子(可选):管家写侧操作成功后同步翻索引的归档标记 /
|
|
300
|
+
* 删除索引行,消灭"搜索还挂着 30s 前的幽灵"的窗口。缺省(单测/降级)为无联动。
|
|
301
|
+
*/
|
|
302
|
+
index?: {
|
|
303
|
+
/** 批量归档成功 → 翻 archived=true。 */
|
|
304
|
+
onArchive: (sessionIds: readonly string[]) => void
|
|
305
|
+
/** 取消归档成功 → 翻 archived=false。 */
|
|
306
|
+
onUnarchive: (sessionIds: readonly string[]) => void
|
|
307
|
+
/** 清理成功(物理删除)→ 索引行整个移除。 */
|
|
308
|
+
onPurged: (sessionIds: readonly string[]) => void
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 支持的路由方法(按子域分组;用于对外声明与测试断言)。
|
|
314
|
+
*
|
|
315
|
+
* `session-history-prune` 与 `session-history-purge` 是**两件事**,不可合并:
|
|
316
|
+
* prune = 取消归档状态(可逆,会话回到侧边栏);purge = 清理归档文件(不可逆,真删实体)。
|
|
317
|
+
*/
|
|
318
|
+
export const HISTORY_METHODS = [
|
|
319
|
+
'session-history-list',
|
|
320
|
+
'session-history-archive',
|
|
321
|
+
'session-history-prune',
|
|
322
|
+
'session-history-purge',
|
|
323
|
+
'session-history-favorites-list',
|
|
324
|
+
'session-history-favorite-set',
|
|
325
|
+
] as const
|
|
326
|
+
export const HEALTH_METHODS = [
|
|
327
|
+
'session-health-status',
|
|
328
|
+
'session-health-scan',
|
|
329
|
+
'session-health-session',
|
|
330
|
+
'session-health-repair',
|
|
331
|
+
'session-health-source-migrate',
|
|
332
|
+
] as const
|
|
333
|
+
/** 搜索索引子域方法(`/switch-search/api`;index-export/import 走原始体,其余 JSON)。 */
|
|
334
|
+
export const INDEX_METHODS = [
|
|
335
|
+
'list-sessions',
|
|
336
|
+
'content-search',
|
|
337
|
+
'search-status',
|
|
338
|
+
'index-status',
|
|
339
|
+
'index-rebuild',
|
|
340
|
+
'index-export',
|
|
341
|
+
'index-import',
|
|
342
|
+
] as const
|
|
343
|
+
|
|
344
|
+
/** 依据开关判定某方法是否启用。 */
|
|
345
|
+
export function methodEnabled(method: string, config: Required<StewardConfig>): boolean {
|
|
346
|
+
if ((HISTORY_METHODS as readonly string[]).includes(method)) return config.historyFiles !== false
|
|
347
|
+
if ((HEALTH_METHODS as readonly string[]).includes(method)) return config.healthCheck !== false
|
|
348
|
+
if ((INDEX_METHODS as readonly string[]).includes(method)) return config.search !== false
|
|
349
|
+
return false
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** ------------------------------------------------------------------ 搜索子域 handlers */
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* One session header shape the query service returns (structural subset).
|
|
356
|
+
*/
|
|
357
|
+
interface SwitchSessionHeader {
|
|
358
|
+
id: string
|
|
359
|
+
version: number
|
|
360
|
+
createdAt: number
|
|
361
|
+
cwd?: string
|
|
362
|
+
parentSession?: string
|
|
363
|
+
seedLength?: number
|
|
364
|
+
delegationDepth?: number
|
|
365
|
+
agentPreset?: string
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** One logical-session record (structural subset). */
|
|
369
|
+
interface SwitchSessionRecord {
|
|
370
|
+
header: SwitchSessionHeader
|
|
371
|
+
live: boolean
|
|
372
|
+
persisted: boolean
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** One title observation result (structural subset). */
|
|
376
|
+
interface SwitchTitleObservationResult {
|
|
377
|
+
status: 'fulfilled' | 'rejected'
|
|
378
|
+
value?: { session: SwitchSessionHeader; title?: { title: string } }
|
|
379
|
+
reason?: unknown
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The session-query service face: corpus reads, title folding, FTS5 search. */
|
|
383
|
+
interface SwitchSessionQuery {
|
|
384
|
+
listSessions(signal?: AbortSignal): Promise<readonly SwitchSessionRecord[]>
|
|
385
|
+
readSession?(sessionId: string): Promise<{
|
|
386
|
+
session: SwitchSessionHeader
|
|
387
|
+
events: readonly SwitchRawEvent[]
|
|
388
|
+
}>
|
|
389
|
+
readTitleSnapshots(
|
|
390
|
+
sessionIds: readonly string[],
|
|
391
|
+
signal?: AbortSignal,
|
|
392
|
+
): Promise<readonly SwitchTitleObservationResult[]>
|
|
393
|
+
searchSessions?(
|
|
394
|
+
request: { query: string; eventFilters?: readonly unknown[]; limit?: number },
|
|
395
|
+
exec?: { signal?: AbortSignal },
|
|
396
|
+
): Promise<{ items: readonly unknown[]; nextCursor?: string }>
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The workspace registry face the search side reads (structural mirror): the
|
|
401
|
+
* official archive set. Read lazily — the registry may mount after plugins.
|
|
402
|
+
*/
|
|
403
|
+
interface SwitchWorkspaceRegistry {
|
|
404
|
+
readonly archivedSessionIds: readonly string[]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Fold titles for a set of sessions into a sessionId → title map. */
|
|
408
|
+
async function titleMap(
|
|
409
|
+
sessionQuery: SwitchSessionQuery,
|
|
410
|
+
sessionIds: readonly string[],
|
|
411
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
412
|
+
if (sessionIds.length === 0) return new Map()
|
|
413
|
+
const observations = await sessionQuery.readTitleSnapshots([...new Set(sessionIds)])
|
|
414
|
+
const map = new Map<string, string>()
|
|
415
|
+
for (const observation of observations) {
|
|
416
|
+
if (observation.status !== 'fulfilled' || observation.value === undefined) continue
|
|
417
|
+
const title = observation.value.title?.title
|
|
418
|
+
if (typeof title === 'string' && title.trim().length > 0) map.set(observation.value.session.id, title)
|
|
419
|
+
}
|
|
420
|
+
return map
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** list-sessions: the title-search corpus (index-served, live fallback). */
|
|
424
|
+
async function listSessions(srt: SearchRuntime): Promise<{ ok: boolean; items?: unknown[]; error?: string }> {
|
|
425
|
+
const index = srt.index
|
|
426
|
+
const sessionQuery = srt.sessionQuery
|
|
427
|
+
// Fast path: the independent index caches every session header (title/cwd/
|
|
428
|
+
// updatedAt). Serving from it keeps the panel instant — the live-preferred
|
|
429
|
+
// corpus projection (readTitleSnapshots per session) is what used to blow
|
|
430
|
+
// the client's 10s timeout on large corpora. A refresh sync runs in the
|
|
431
|
+
// background so newly created sessions appear on the next open.
|
|
432
|
+
if (index.engine.isOpen && index.engine.countSessions() > 0) {
|
|
433
|
+
if (sessionQuery !== undefined && index.sync.snapshot().state !== 'syncing') {
|
|
434
|
+
void index.sync.poll().catch(() => {})
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
ok: true,
|
|
438
|
+
items: index.engine.listIndexedSessions().map(session => ({
|
|
439
|
+
sessionId: session.sessionId,
|
|
440
|
+
title: session.title,
|
|
441
|
+
cwd: session.cwd,
|
|
442
|
+
updatedAt: session.updatedAt,
|
|
443
|
+
archived: session.archived,
|
|
444
|
+
})),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (sessionQuery === undefined) {
|
|
448
|
+
return { ok: false, error: 'sessionQuery 服务不可用,且独立索引尚未建立' }
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const records = await sessionQuery.listSessions()
|
|
452
|
+
const titles = await titleMap(sessionQuery, records.map(record => record.header.id))
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
items: records.map(record => ({
|
|
456
|
+
sessionId: record.header.id,
|
|
457
|
+
title: titles.get(record.header.id) ?? '',
|
|
458
|
+
cwd: record.header.cwd ?? '',
|
|
459
|
+
updatedAt: record.header.createdAt,
|
|
460
|
+
archived: false,
|
|
461
|
+
})),
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
return { ok: false, error: String(err instanceof Error ? err.message : err) }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* content-search: session-grouped hits from the independent index.
|
|
470
|
+
* `sortBy: 'time'` orders by session recency (`updatedAt`), anything else by
|
|
471
|
+
* relevance; the host orders before truncating so the page is honest.
|
|
472
|
+
*/
|
|
473
|
+
async function contentSearch(
|
|
474
|
+
srt: SearchRuntime,
|
|
475
|
+
payload: unknown,
|
|
476
|
+
): Promise<{ ok: boolean; items?: unknown[]; error?: string }> {
|
|
477
|
+
const record = payload as { query?: unknown; limit?: unknown; types?: unknown; sortBy?: unknown; archived?: unknown; from?: unknown; to?: unknown } | null
|
|
478
|
+
const query = typeof record?.query === 'string' ? record.query.trim() : ''
|
|
479
|
+
if (query === '') return { ok: false, error: '缺少 query' }
|
|
480
|
+
// 检索域 chip(全部/活跃/归档);未知值回退 'all',新旧客户端互相兼容。
|
|
481
|
+
const archived: 'active' | 'archived' | 'all'
|
|
482
|
+
= record?.archived === 'active' ? 'active'
|
|
483
|
+
: record?.archived === 'archived' ? 'archived'
|
|
484
|
+
: 'all'
|
|
485
|
+
// 日期范围(R6):本地日界,结束日含当天;无效值静默忽略(新旧客户端兼容)。
|
|
486
|
+
const fromMs = dayBoundaryMs(record?.from)
|
|
487
|
+
const toMs = record?.to === undefined ? undefined : dayBoundaryMs(record?.to, true)
|
|
488
|
+
const requestedLimit = typeof record?.limit === 'number' && Number.isSafeInteger(record.limit)
|
|
489
|
+
? record.limit
|
|
490
|
+
: DEFAULT_LIMIT
|
|
491
|
+
const limit = Math.min(Math.max(1, requestedLimit), 100)
|
|
492
|
+
// Unknown or absent ordering degrades to relevance — never to an error, so an
|
|
493
|
+
// old client half talking to a new host half keeps working.
|
|
494
|
+
const sortBy: SwitchSearchSort = record?.sortBy === 'time' ? 'time' : 'relevance'
|
|
495
|
+
let types: readonly SwitchIndexContentType[]
|
|
496
|
+
if (Array.isArray(record?.types) && record.types.length > 0) {
|
|
497
|
+
types = record.types.filter((entry): entry is SwitchIndexContentType =>
|
|
498
|
+
entry === 'all' || entry === 'user' || entry === 'reply' || entry === 'tool')
|
|
499
|
+
} else {
|
|
500
|
+
types = ['user', 'reply']
|
|
501
|
+
}
|
|
502
|
+
const index = srt.index
|
|
503
|
+
if (index.engine.isOpen === false) {
|
|
504
|
+
return { ok: false, error: '独立索引未就绪:请在面板或设置中先建立索引(整理索引)' }
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
return {
|
|
508
|
+
ok: true,
|
|
509
|
+
items: index.engine.search({ query, types, limit, sortBy, archived, from: fromMs, to: toMs }),
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return { ok: false, error: String(err instanceof Error ? err.message : err) }
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** search-status: probe the independent index readiness and progress. */
|
|
517
|
+
async function searchStatus(srt: SearchRuntime): Promise<unknown> {
|
|
518
|
+
const index = srt.index
|
|
519
|
+
const sync = index.sync.snapshot()
|
|
520
|
+
return {
|
|
521
|
+
ok: true,
|
|
522
|
+
available: index.engine.isOpen && index.engine.countSessions() > 0,
|
|
523
|
+
reason: index.engine.isOpen ? undefined : 'not-open',
|
|
524
|
+
indexing: sync.state === 'syncing',
|
|
525
|
+
archivedSessions: index.engine.countArchived(),
|
|
526
|
+
archive: index.archiveReader.diagnostics(),
|
|
527
|
+
sync,
|
|
528
|
+
rebuild: index.rebuild,
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** index-status: full lifecycle surface for the settings row. */
|
|
533
|
+
async function indexStatus(srt: SearchRuntime): Promise<unknown> {
|
|
534
|
+
const index = srt.index
|
|
535
|
+
const sync = index.sync.snapshot()
|
|
536
|
+
let indexed = sync.indexed
|
|
537
|
+
if (index.engine.isOpen) indexed = index.engine.countSessions()
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
available: index.engine.isOpen && indexed > 0,
|
|
541
|
+
archivedSessions: index.engine.isOpen ? index.engine.countArchived() : 0,
|
|
542
|
+
// 归档子域的 owner 就是本包(history 子域);保留探针字段形状,旧客户端
|
|
543
|
+
// 的「会话管家负责」提示语在新包语境下依然为真。
|
|
544
|
+
steward: detectSteward(),
|
|
545
|
+
driver: index.engine.driverLabel,
|
|
546
|
+
archive: index.archiveReader.diagnostics() as SwitchArchiveDiagnostics,
|
|
547
|
+
dir: index.layout.dir,
|
|
548
|
+
archives: await listArchives(index.layout).catch(() => []),
|
|
549
|
+
sync: { ...sync, indexed } satisfies SwitchSyncState,
|
|
550
|
+
rebuild: index.rebuild,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* index-rebuild: start the non-destructive 整理 (shadow build → atomic swap →
|
|
556
|
+
* archives). Responds immediately; progress rides index-status.
|
|
557
|
+
*/
|
|
558
|
+
async function indexRebuild(srt: SearchRuntime): Promise<{ ok: boolean; started?: boolean; error?: string }> {
|
|
559
|
+
const index = srt.index
|
|
560
|
+
if (index.rebuild.state === 'building' || index.rebuild.state === 'swapping') {
|
|
561
|
+
return { ok: false, error: '整理已在进行中' }
|
|
562
|
+
}
|
|
563
|
+
const sessionQuery = srt.sessionQuery
|
|
564
|
+
if (sessionQuery === undefined || sessionQuery.readSession === undefined) {
|
|
565
|
+
return { ok: false, error: 'sessionQuery 服务不可用,无法读取会话日志' }
|
|
566
|
+
}
|
|
567
|
+
const config = srt.config()
|
|
568
|
+
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep)
|
|
569
|
+
void rebuildIndex(
|
|
570
|
+
index.engine,
|
|
571
|
+
index.layout,
|
|
572
|
+
{
|
|
573
|
+
listSessions: () => sessionQuery.listSessions(),
|
|
574
|
+
readSession: async (sessionId: string) => {
|
|
575
|
+
const snapshot = await sessionQuery.readSession!(sessionId)
|
|
576
|
+
return { session: snapshot.session, events: snapshot.events }
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
keepArchives,
|
|
580
|
+
undefined,
|
|
581
|
+
srt.registry,
|
|
582
|
+
{
|
|
583
|
+
log: srt.log,
|
|
584
|
+
onState: (live) => { index.rebuild = live },
|
|
585
|
+
readSessionFromFile: srt.readSessionFromFile,
|
|
586
|
+
},
|
|
587
|
+
).then((state) => {
|
|
588
|
+
index.rebuild = state
|
|
589
|
+
}).catch((err) => {
|
|
590
|
+
index.rebuild = {
|
|
591
|
+
state: 'error',
|
|
592
|
+
done: 0,
|
|
593
|
+
total: 0,
|
|
594
|
+
startedAt: Date.now(),
|
|
595
|
+
finishedAt: 0,
|
|
596
|
+
failures: [],
|
|
597
|
+
error: String(err instanceof Error ? err.message : err),
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
index.rebuild = { state: 'building', done: 0, total: 0, startedAt: Date.now(), finishedAt: 0, failures: [] }
|
|
601
|
+
return { ok: true, started: true }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Tombs for the two methods the search package used to own before the
|
|
606
|
+
* steward merge. A stale client bundle (browser refresh does not reload the
|
|
607
|
+
* host half) must fail LOUDLY and be told where the feature went — a silent
|
|
608
|
+
* 404 would read as "archiving is broken".
|
|
609
|
+
*/
|
|
610
|
+
const MOVED_TO_HISTORY: Record<string, string> = {
|
|
611
|
+
'list-archived': 'session-history-list',
|
|
612
|
+
'archive-prune': 'session-history-prune',
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Build the explicit "moved" error body for a tombstoned method. */
|
|
616
|
+
function movedToHistory(method: string): { ok: false; error: string } {
|
|
617
|
+
const replacement = MOVED_TO_HISTORY[method]
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
error: `"${method}" 已并入会话管家 history 子域:请改用 POST ${STEWARD_API_PREFIX}/${replacement}`,
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** index-export: dump the active index as JSON Lines. */
|
|
625
|
+
async function indexExport(srt: SearchRuntime, res: ServerResponse): Promise<void> {
|
|
626
|
+
const index = srt.index
|
|
627
|
+
if (index.engine.isOpen === false) {
|
|
628
|
+
writeJson(res, 200, { ok: false, error: '独立索引未就绪' })
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
writeRaw(res, 200, 'application/x-ndjson; charset=utf-8', exportSnapshot(index.engine))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** index-import: parse a JSON Lines snapshot and swap it in as the active index. */
|
|
635
|
+
async function indexImport(srt: SearchRuntime, text: string) {
|
|
636
|
+
const index = srt.index
|
|
637
|
+
if (index.rebuild.state === 'building' || index.rebuild.state === 'swapping') {
|
|
638
|
+
return { ok: false, error: '整理/导入已在进行中' }
|
|
639
|
+
}
|
|
640
|
+
// Body is either raw JSON Lines or a JSON envelope { snapshot: "..." }.
|
|
641
|
+
if (text.includes('"snapshot"')) {
|
|
642
|
+
try {
|
|
643
|
+
const envelope = JSON.parse(text) as { snapshot?: unknown }
|
|
644
|
+
if (typeof envelope.snapshot === 'string') text = envelope.snapshot
|
|
645
|
+
} catch { /* treat as plain JSONL */ }
|
|
646
|
+
}
|
|
647
|
+
const parsed = parseSnapshot(text)
|
|
648
|
+
if (parsed.records.length === 0) return { ok: false, error: `快照无可导入会话(跳过 ${parsed.skipped} 行)` }
|
|
649
|
+
const config = srt.config()
|
|
650
|
+
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep)
|
|
651
|
+
void importIntoIndex(index.engine, index.layout, parsed.records, keepArchives)
|
|
652
|
+
.then((state) => { index.rebuild = state })
|
|
653
|
+
.catch((err) => {
|
|
654
|
+
index.rebuild = {
|
|
655
|
+
state: 'error',
|
|
656
|
+
done: 0,
|
|
657
|
+
total: parsed.records.length,
|
|
658
|
+
startedAt: Date.now(),
|
|
659
|
+
finishedAt: 0,
|
|
660
|
+
failures: [],
|
|
661
|
+
error: String(err instanceof Error ? err.message : err),
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
index.rebuild = { state: 'building', done: 0, total: parsed.records.length, startedAt: Date.now(), finishedAt: 0, failures: [] }
|
|
665
|
+
return { ok: true, started: true, sessions: parsed.records.length, skipped: parsed.skipped }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** ------------------------------------------------------------------ index service */
|
|
669
|
+
|
|
670
|
+
/** The per-activation index service state, carried in the apply closure. */
|
|
671
|
+
export interface SwitchIndexServiceState {
|
|
672
|
+
engine: SwitchIndexEngine
|
|
673
|
+
sync: SwitchWatermarkSync
|
|
674
|
+
layout: SwitchIndexLayout
|
|
675
|
+
rebuild: SwitchRebuildState
|
|
676
|
+
/** Official archive-set reader (registry first, storage-hub file fallback). */
|
|
677
|
+
archiveReader: ReturnType<typeof createArchiveSource>
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Everything the search handlers need, captured from the apply closure: the
|
|
682
|
+
* optional live sessionQuery, the index service state, and the latest config.
|
|
683
|
+
* Handlers never touch the cordis context — arbitrary property writes on a
|
|
684
|
+
* Context are rejected ("cannot set property ... without provide").
|
|
685
|
+
*/
|
|
686
|
+
export interface SearchRuntime {
|
|
687
|
+
sessionQuery: SwitchSessionQuery | undefined
|
|
688
|
+
index: SwitchIndexServiceState
|
|
689
|
+
config: () => Required<StewardConfig> & SwitchSearchConfig
|
|
690
|
+
/** Lazy official archive-set source (registry first, file fallback). */
|
|
691
|
+
registry: () => { archivedSessionIds: readonly string[] }
|
|
692
|
+
/** Cordis logger bridge ([session-steward] prefixed). */
|
|
693
|
+
log: (msg: string) => void
|
|
694
|
+
/** 文件级兜底读取器(重建钩子透传;见 SwitchWatermarkSync 注释)。 */
|
|
695
|
+
readSessionFromFile: (sessionId: string) => Promise<{
|
|
696
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
697
|
+
events: readonly SwitchRawEvent[]
|
|
698
|
+
} | undefined>
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** JSON 面的搜索子域方法分发(index-export/import 走原始体,在路由层特判)。 */
|
|
702
|
+
export async function handleIndexMethod(method: string, payload: unknown, srt: SearchRuntime): Promise<unknown> {
|
|
703
|
+
if (method === 'list-sessions') return await listSessions(srt)
|
|
704
|
+
if (method === 'content-search') return await contentSearch(srt, payload)
|
|
705
|
+
if (method === 'search-status') return await searchStatus(srt)
|
|
706
|
+
if (method === 'index-status') return await indexStatus(srt)
|
|
707
|
+
if (method === 'index-rebuild') return await indexRebuild(srt)
|
|
708
|
+
return { ok: false, error: `未知的 switch-search API 方法 "${method}"` }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* 官方 workspaceRegistry 的内存归档面(结构化镜像)。0.1.7+ 双方法齐全,
|
|
713
|
+
* 0.1.5 只有 archiveSession;方法自带 setState 持久化 —— 调用即生效即落盘,
|
|
714
|
+
* **免重启**。探测到才走内存路径,否则降级文件编辑(requiresRestart)。
|
|
715
|
+
*/
|
|
716
|
+
interface MemoryRegistryFace {
|
|
717
|
+
archiveSession?(sessionId: string, options?: { stopActivity?: boolean }): Promise<void>
|
|
718
|
+
unarchiveSession?(sessionId: string): Promise<void>
|
|
719
|
+
readonly archivedSessionIds?: readonly string[]
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 逐 id 串行调用官方内存方法;失败按会话隔离收集(AM 批量语义)。 */
|
|
723
|
+
async function runMemoryArchiveOps(
|
|
724
|
+
registry: MemoryRegistryFace,
|
|
725
|
+
ids: readonly string[],
|
|
726
|
+
op: 'archive' | 'unarchive',
|
|
727
|
+
): Promise<{ succeeded: string[]; failures: { sessionId: string; reason: string }[] }> {
|
|
728
|
+
const fn = op === 'archive' ? registry.archiveSession : registry.unarchiveSession
|
|
729
|
+
const succeeded: string[] = []
|
|
730
|
+
const failures: { sessionId: string; reason: string }[] = []
|
|
731
|
+
for (const id of ids) {
|
|
732
|
+
try {
|
|
733
|
+
await fn!.call(registry, id)
|
|
734
|
+
succeeded.push(id)
|
|
735
|
+
} catch (err) {
|
|
736
|
+
failures.push({ sessionId: id, reason: String(err instanceof Error ? err.message : err) })
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return { succeeded, failures }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** 收藏文件候选(与管家其余域同一 dshHome 纪律)。 */
|
|
743
|
+
function favoritesPathsFor(dshHome: string): string[] {
|
|
744
|
+
return [join(dshHome, 'storages', 'session-steward-favorites.json')]
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 本地日界(YYYY-MM-DD → epoch ms;nextDay 为排他上界)。非法返回 undefined。 */
|
|
748
|
+
function dayBoundaryMs(value: unknown, nextDay = false): number | undefined {
|
|
749
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined
|
|
750
|
+
const [year, month, day] = value.split('-').map(Number)
|
|
751
|
+
const date = new Date(year, month - 1, day)
|
|
752
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return undefined
|
|
753
|
+
if (nextDay) date.setDate(date.getDate() + 1)
|
|
754
|
+
return date.getTime()
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** 从 payload 抽合法 sessionIds(与 prune/purge 的入参纪律一致);不合法返回 undefined。 */
|
|
758
|
+
function validSessionIds(value: unknown): string[] | undefined {
|
|
759
|
+
if (!Array.isArray(value) || value.length === 0) return undefined
|
|
760
|
+
const ids = [...new Set(value.filter((id): id is string => typeof id === 'string' && id !== ''))]
|
|
761
|
+
return ids.length > 0 ? ids : undefined
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* 处理一次管家子域 API 调用(导出以便单测直接驱动,不需要起 HTTP)。
|
|
766
|
+
* @param method - 路由方法名。
|
|
767
|
+
* @param payload - 已解析的请求体。
|
|
768
|
+
* @param runtime - 运行时依赖。
|
|
769
|
+
*/
|
|
770
|
+
/** 现代宿主的只读投影:把运行时配置拆成两命名空间形状(describe 用)。 */
|
|
771
|
+
function configSnapshotProjection(
|
|
772
|
+
config: Required<StewardConfig> & Partial<SwitchSearchConfig>,
|
|
773
|
+
): Record<string, Record<string, unknown>> {
|
|
774
|
+
return {
|
|
775
|
+
[STEWARD_SETTINGS_NAMESPACE]: {
|
|
776
|
+
enabled: config.enabled,
|
|
777
|
+
historyFiles: config.historyFiles,
|
|
778
|
+
healthCheck: config.healthCheck,
|
|
779
|
+
search: config.search,
|
|
780
|
+
},
|
|
781
|
+
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
782
|
+
enabled: config.enabled,
|
|
783
|
+
defaultMode: config.defaultMode ?? SWITCH_DEFAULT_CONFIG.defaultMode,
|
|
784
|
+
autoSync: config.autoSync ?? SWITCH_DEFAULT_CONFIG.autoSync,
|
|
785
|
+
syncIntervalMs: config.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
|
|
786
|
+
archiveKeep: config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
|
|
787
|
+
indexDir: config.indexDir ?? SWITCH_DEFAULT_CONFIG.indexDir,
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export async function handleMethod(
|
|
793
|
+
method: string,
|
|
794
|
+
payload: unknown,
|
|
795
|
+
runtime: StewardRuntime,
|
|
796
|
+
): Promise<unknown> {
|
|
797
|
+
const config = runtime.config()
|
|
798
|
+
// 设置桥方法不受子域闸管——关掉 enabled/historyFiles/healthCheck 后仍必须
|
|
799
|
+
// 能读配置(否则卡片永远读不到「把它打开」的开关)。
|
|
800
|
+
const settingsMethods = method === 'settings-describe' || method === 'settings-mutate'
|
|
801
|
+
const known = [...HISTORY_METHODS, ...HEALTH_METHODS, 'settings-describe', 'settings-mutate'] as readonly string[]
|
|
802
|
+
if (!known.includes(method)) {
|
|
803
|
+
return { ok: false, error: `未知的 session-steward API 方法 "${method}"` }
|
|
804
|
+
}
|
|
805
|
+
if (!settingsMethods && !methodEnabled(method, config)) {
|
|
806
|
+
const which = (HISTORY_METHODS as readonly string[]).includes(method) ? 'historyFiles' : 'healthCheck'
|
|
807
|
+
return { ok: false, error: `子域已关闭(${which}=false):${method} 未注册` }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (method === 'settings-describe') {
|
|
811
|
+
const state = runtime.settingsBridge
|
|
812
|
+
if (state !== undefined && state.bridge !== undefined) return settingsDescribe(state)
|
|
813
|
+
// modern 宿主(0.1.7+,无 legacy settings 服务):运行时配置只读投影。
|
|
814
|
+
return { ok: true, namespaces: configSnapshotProjection(config), writable: false, revision: state?.revision ?? 0 }
|
|
815
|
+
}
|
|
816
|
+
if (method === 'settings-mutate') {
|
|
817
|
+
const state = runtime.settingsBridge ?? { bridge: undefined, revision: 0 }
|
|
818
|
+
return await settingsMutate(state, payload)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (method === 'session-history-list') {
|
|
822
|
+
return await listHistory(runtime.registry, undefined, storagePathsFor(runtime.dshHome), runtime.dshHome)
|
|
823
|
+
}
|
|
824
|
+
if (method === 'session-history-archive') {
|
|
825
|
+
const request = (payload ?? {}) as { sessionIds?: unknown }
|
|
826
|
+
const ids = validSessionIds(request.sessionIds)
|
|
827
|
+
if (ids === undefined) return { ok: false, error: '缺少 sessionIds 数组' }
|
|
828
|
+
// 内存路径优先(0.1.7+ 双方法齐全):官方 archiveSession 自带 setState
|
|
829
|
+
// 持久化,即时生效免重启;活动回合/未知会话按会话隔离报失败。
|
|
830
|
+
const mem = runtime.registry() as MemoryRegistryFace | undefined
|
|
831
|
+
if (mem?.archiveSession !== undefined) {
|
|
832
|
+
const before = new Set(mem.archivedSessionIds ?? [])
|
|
833
|
+
const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, 'archive')
|
|
834
|
+
runtime.index?.onArchive(succeeded)
|
|
835
|
+
const total = (mem.archivedSessionIds ?? []).length
|
|
836
|
+
return {
|
|
837
|
+
ok: true,
|
|
838
|
+
added: ids.filter(id => !before.has(id)).length,
|
|
839
|
+
total,
|
|
840
|
+
requiresRestart: false,
|
|
841
|
+
...(failures.length === 0 ? {} : { failures }),
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const result = archiveHistory(payload, runtime.log, storagePathsFor(runtime.dshHome))
|
|
845
|
+
if (result.ok) runtime.index?.onArchive(ids)
|
|
846
|
+
return result
|
|
847
|
+
}
|
|
848
|
+
if (method === 'session-history-prune') {
|
|
849
|
+
const request = (payload ?? {}) as { sessionIds?: unknown }
|
|
850
|
+
const ids = validSessionIds(request.sessionIds)
|
|
851
|
+
// 内存路径优先(unarchiveSession 为 0.1.7+ 方法,0.1.5 无 → 文件降级)。
|
|
852
|
+
const mem = runtime.registry() as MemoryRegistryFace | undefined
|
|
853
|
+
if (mem?.unarchiveSession !== undefined && ids !== undefined) {
|
|
854
|
+
const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, 'unarchive')
|
|
855
|
+
runtime.index?.onUnarchive(succeeded)
|
|
856
|
+
const remaining = (mem.archivedSessionIds ?? []).length
|
|
857
|
+
return {
|
|
858
|
+
ok: true,
|
|
859
|
+
removed: succeeded.length,
|
|
860
|
+
remaining,
|
|
861
|
+
requiresRestart: false,
|
|
862
|
+
...(failures.length === 0 ? {} : { failures }),
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const result = pruneHistory(payload, runtime.log, storagePathsFor(runtime.dshHome))
|
|
866
|
+
if (result.ok && ids !== undefined) runtime.index?.onUnarchive(ids)
|
|
867
|
+
return result
|
|
868
|
+
}
|
|
869
|
+
if (method === 'session-history-purge') {
|
|
870
|
+
const request = (payload ?? {}) as { sessionIds?: unknown }
|
|
871
|
+
const ids = validSessionIds(request.sessionIds)
|
|
872
|
+
if (ids === undefined) return { ok: false, error: '缺少 sessionIds 数组' }
|
|
873
|
+
// 内存路径优先(P1:替换后的 registry 子类带 deleteSession 全痕删除,
|
|
874
|
+
// 官方 0.1.7/0.2.0 双线可用)—— 即时生效免重启;失败按会话隔离。
|
|
875
|
+
const mem = runtime.registry() as (MemoryRegistryFace & {
|
|
876
|
+
deleteSession?: (sessionId: string) => Promise<{ ok: boolean; skipped?: boolean; error?: string }>
|
|
877
|
+
dshHome?: string
|
|
878
|
+
}) | undefined
|
|
879
|
+
if (typeof mem?.deleteSession === 'function') {
|
|
880
|
+
mem.dshHome = runtime.dshHome
|
|
881
|
+
const succeeded: string[] = []
|
|
882
|
+
const failures: { sessionId: string; reason: string }[] = []
|
|
883
|
+
const usage = locateSessionUsage(ids, runtime.dshHome)
|
|
884
|
+
let freedBytes = 0
|
|
885
|
+
for (const id of ids) {
|
|
886
|
+
freedBytes += usage.get(id)?.bytes ?? 0
|
|
887
|
+
freedBytes += usage.get(id)?.cacheBytes ?? 0
|
|
888
|
+
}
|
|
889
|
+
for (const id of ids) {
|
|
890
|
+
try {
|
|
891
|
+
const outcome = await mem.deleteSession(id)
|
|
892
|
+
if (outcome.ok) succeeded.push(id)
|
|
893
|
+
else failures.push({ sessionId: id, reason: outcome.error ?? '删除失败' })
|
|
894
|
+
} catch (err) {
|
|
895
|
+
failures.push({ sessionId: id, reason: String(err instanceof Error ? err.message : err) })
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
runtime.index?.onPurged(succeeded)
|
|
899
|
+
return {
|
|
900
|
+
ok: true,
|
|
901
|
+
purged: succeeded.length,
|
|
902
|
+
freedBytes,
|
|
903
|
+
requiresRestart: false,
|
|
904
|
+
...(failures.length === 0 ? {} : { failures }),
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const result = purgeHistory(payload, runtime.log, {
|
|
908
|
+
dshHome: runtime.dshHome,
|
|
909
|
+
searchPaths: storagePathsFor(runtime.dshHome),
|
|
910
|
+
})
|
|
911
|
+
// 联动只在真删成功后:失败条目留在索引里(还能搜到,如实)。
|
|
912
|
+
if (result.ok && ids !== undefined) {
|
|
913
|
+
const failed = new Set((result.failures ?? []).map(f => f.sessionId))
|
|
914
|
+
runtime.index?.onPurged(ids.filter((id) => !failed.has(id)))
|
|
915
|
+
}
|
|
916
|
+
return result
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (method === 'session-history-favorites-list') {
|
|
920
|
+
return { ok: true, favorites: listFavorites(favoritesPathsFor(runtime.dshHome)) }
|
|
921
|
+
}
|
|
922
|
+
if (method === 'session-history-favorite-set') {
|
|
923
|
+
const request = (payload ?? {}) as { sessionId?: unknown; favorite?: unknown }
|
|
924
|
+
const sessionId = asSessionId(request.sessionId)
|
|
925
|
+
if (sessionId === undefined) return { ok: false, error: '缺少合法的 sessionId' }
|
|
926
|
+
if (typeof request.favorite !== 'boolean') return { ok: false, error: '缺少 favorite 布尔值' }
|
|
927
|
+
return setFavoriteState(sessionId, request.favorite, favoritesPathsFor(runtime.dshHome))
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (method === 'session-health-status') {
|
|
931
|
+
return {
|
|
932
|
+
ok: true,
|
|
933
|
+
namespace: STEWARD_SETTINGS_NAMESPACE,
|
|
934
|
+
prefix: STEWARD_API_PREFIX,
|
|
935
|
+
switches: { enabled: config.enabled, historyFiles: config.historyFiles, healthCheck: config.healthCheck },
|
|
936
|
+
historyMethods: [...HISTORY_METHODS],
|
|
937
|
+
healthMethods: [...HEALTH_METHODS],
|
|
938
|
+
home: runtime.dshHome,
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (method === 'session-health-scan') {
|
|
943
|
+
const request = (payload ?? {}) as { limit?: unknown; offset?: unknown; onlyProblems?: unknown; resume?: unknown }
|
|
944
|
+
const onlyProblems = request.onlyProblems !== false
|
|
945
|
+
|
|
946
|
+
// resume:**纯读缓存,不扫描**(面板挂载时零成本探一次)。
|
|
947
|
+
// 无缓存时如实回 cached:false 并捎带语料总数,让面板知道「有多少待体检」,
|
|
948
|
+
// 而不是偷偷跑一批——挂载就干重活是上一版被诟病的问题。
|
|
949
|
+
if (request.resume === true) {
|
|
950
|
+
const cached = runtime.cache?.read()
|
|
951
|
+
if (cached === undefined) {
|
|
952
|
+
return { ok: true, cached: false, scanned: 0, total: countCorpus(runtime.dshHome), offset: 0, findings: [] }
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
ok: true,
|
|
956
|
+
cached: true,
|
|
957
|
+
scanned: 0,
|
|
958
|
+
total: cached.total,
|
|
959
|
+
offset: 0,
|
|
960
|
+
findings: cached.findings,
|
|
961
|
+
generatedAt: cached.generatedAt,
|
|
962
|
+
// 当前语料总数:客户端据此提示「语料已变化,建议刷新」。
|
|
963
|
+
currentTotal: countCorpus(runtime.dshHome),
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const limit = typeof request.limit === 'number' && Number.isFinite(request.limit)
|
|
968
|
+
? Math.max(1, Math.min(MAX_SCAN_LIMIT, Math.floor(request.limit)))
|
|
969
|
+
: 30
|
|
970
|
+
// offset 支持分批扫描:客户端用小批次连续调用并自行累计进度。
|
|
971
|
+
const offset = typeof request.offset === 'number' && Number.isFinite(request.offset) && request.offset > 0
|
|
972
|
+
? Math.floor(request.offset)
|
|
973
|
+
: 0
|
|
974
|
+
const result = scanSessions({
|
|
975
|
+
dshHome: runtime.dshHome,
|
|
976
|
+
limit,
|
|
977
|
+
offset,
|
|
978
|
+
onlyProblems,
|
|
979
|
+
attribute: runtime.attribute,
|
|
980
|
+
projectionStateFor: runtime.projectionStateFor,
|
|
981
|
+
})
|
|
982
|
+
// 累积缓存:`offset: 0` 起一轮,走满语料才成型(半途而废不留半份缓存)。
|
|
983
|
+
// 只累积「只列问题」的视图;onlyProblems=false 的扫描不碰缓存。
|
|
984
|
+
if (result.ok && onlyProblems) {
|
|
985
|
+
if (offset === 0) runtime.cache?.begin(result.total)
|
|
986
|
+
runtime.cache?.append(result.findings, result.scanned)
|
|
987
|
+
}
|
|
988
|
+
return result
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const request = (payload ?? {}) as { sessionId?: unknown }
|
|
992
|
+
const sessionId = asSessionId(request.sessionId)
|
|
993
|
+
if (sessionId === undefined) return { ok: false, error: '缺少合法的 sessionId' }
|
|
994
|
+
// 按代次定位:会话存在但尚未发布当前代时 logPath 为 undefined,此时仍要出报告
|
|
995
|
+
// (generation / log-integrity 两门如实报出),不能笼统回「未找到会话日志」。
|
|
996
|
+
const session = findSession(sessionId, runtime.dshHome)
|
|
997
|
+
if (session === undefined) return { ok: false, error: `未找到会话日志:${sessionId}` }
|
|
998
|
+
|
|
999
|
+
const projectionState = runtime.projectionStateFor(sessionId)
|
|
1000
|
+
const reportFor = (): SessionHealthReport => buildSessionReport({
|
|
1001
|
+
sessionId,
|
|
1002
|
+
dshHome: runtime.dshHome,
|
|
1003
|
+
...(session.logPath === undefined ? {} : { logPath: session.logPath }),
|
|
1004
|
+
generations: session.generations,
|
|
1005
|
+
...(projectionState === undefined ? {} : { projectionState }),
|
|
1006
|
+
attribute: runtime.attribute,
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
if (method === 'session-health-session') {
|
|
1010
|
+
const report = reportFor()
|
|
1011
|
+
// 单条体检可能改变该会话的档位(如宿主已结算 open step):就地回写缓存,
|
|
1012
|
+
// 免得为了刷新一行而重扫整个语料。
|
|
1013
|
+
runtime.cache?.patch(report)
|
|
1014
|
+
return { ok: true, report, prescriptions: prescribe(report, runtime.dshHome) }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// session-health-source-migrate:把 v4 日志里的旧署名行(kind:'plugin' + plugin 字段)
|
|
1018
|
+
// 转换为 producer-owned kind。这是「禁止改写会话日志」红线的唯一显式例外:
|
|
1019
|
+
// 只动 source 署名字段、先整文件备份、v3 及更早版本线拒绝执行(见 source-kind.ts 的版本依赖表)。
|
|
1020
|
+
if (method === 'session-health-source-migrate') {
|
|
1021
|
+
if (session.logPath === undefined) {
|
|
1022
|
+
return { ok: false, error: '会话尚未发布当前代日志,无从转换(先用会话代次工具发布)' }
|
|
1023
|
+
}
|
|
1024
|
+
const log = decodeSessionLogFile(session.logPath)
|
|
1025
|
+
const outcome = migrateSessionSourceKind(sessionId, session.logPath, log, runtime.dshHome)
|
|
1026
|
+
// 转换后重出报告:source-kind 门应转 ok;失败时报告原样(便于面板对照)。
|
|
1027
|
+
const after = buildSessionReport({
|
|
1028
|
+
sessionId,
|
|
1029
|
+
dshHome: runtime.dshHome,
|
|
1030
|
+
logPath: session.logPath,
|
|
1031
|
+
generations: session.generations,
|
|
1032
|
+
...(projectionState === undefined ? {} : { projectionState }),
|
|
1033
|
+
attribute: runtime.attribute,
|
|
1034
|
+
})
|
|
1035
|
+
runtime.cache?.patch(after)
|
|
1036
|
+
runtime.log(
|
|
1037
|
+
`source-kind migrate ${sessionId}: ok=${outcome.ok} changed=${outcome.changedRows ?? 0}` +
|
|
1038
|
+
(outcome.error === undefined ? '' : ` error=${outcome.error}`),
|
|
1039
|
+
)
|
|
1040
|
+
return { ok: outcome.ok, outcome, after, report: after, error: outcome.error }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// session-health-repair:出院前的可逆处置 + before/after 对照 + 结果定性
|
|
1044
|
+
const before = reportFor()
|
|
1045
|
+
if (before.level === 'ok') {
|
|
1046
|
+
// 缓存里可能还留着这个会话的旧档位(扫描时是 warn,宿主后来结算了):
|
|
1047
|
+
// 就地回写把这个陈旧行摘掉。
|
|
1048
|
+
runtime.cache?.patch(before)
|
|
1049
|
+
const clean = assessRepair({ before, after: before, repair: { ok: false, action: 'quarantine-projection-cache', sessionId, from: '' } })
|
|
1050
|
+
return {
|
|
1051
|
+
ok: true,
|
|
1052
|
+
changed: false,
|
|
1053
|
+
before,
|
|
1054
|
+
after: before,
|
|
1055
|
+
repair: null,
|
|
1056
|
+
verdict: clean.verdict,
|
|
1057
|
+
explanation: clean.explanation,
|
|
1058
|
+
residual: clean.residual,
|
|
1059
|
+
prescriptions: ['# 全部检查通过:无需处置'],
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const repair = quarantineProjectionCache(sessionId, runtime.dshHome)
|
|
1063
|
+
const after = reportFor()
|
|
1064
|
+
// 处置只隔离投影缓存记录;异常若来自别处(如 open step),处置必然无变化。
|
|
1065
|
+
// 定性结论把这个事实讲清楚,否则用户只看到「异常 → 异常」会以为功能坏了。
|
|
1066
|
+
const assessment = assessRepair({ before, after, repair })
|
|
1067
|
+
// 处置本就重算了 after:把它写回缓存,面板退回列表时该行即是最新档位。
|
|
1068
|
+
runtime.cache?.patch(after)
|
|
1069
|
+
runtime.log(
|
|
1070
|
+
`health repair ${sessionId}: verdict=${assessment.verdict} quarantine=${repair.ok ? 'ok' : repair.error ?? 'skipped'} before=${before.level} after=${after.level}`,
|
|
1071
|
+
)
|
|
1072
|
+
return {
|
|
1073
|
+
ok: true,
|
|
1074
|
+
changed: repair.ok,
|
|
1075
|
+
repair,
|
|
1076
|
+
before,
|
|
1077
|
+
after,
|
|
1078
|
+
verdict: assessment.verdict,
|
|
1079
|
+
explanation: assessment.explanation,
|
|
1080
|
+
residual: assessment.residual,
|
|
1081
|
+
prescriptions: prescribe(after, runtime.dshHome),
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 插件主体:装配运行时、挂载两条 fenced 路由与索引生命周期。
|
|
1087
|
+
* @param ctx - host 插件上下文(webServer / webStartup(旧名 webRuntime)/ 可选 sessionQuery、workspaceRegistry)。
|
|
1088
|
+
* @param config - 组合条目(0.1.7:`.volatile()` 字段为 live ref)。
|
|
1089
|
+
*/
|
|
1090
|
+
export function apply(ctx: Context, config: Partial<StewardConfig & SwitchSearchConfig> = {}): void {
|
|
1091
|
+
// 0.1.7:volatile 字段每次读取解引出最新快照(开关即时生效),不再有任何
|
|
1092
|
+
// settings 注册调用。
|
|
1093
|
+
const current = (): Required<StewardConfig> & Required<SwitchSearchConfig> => ({
|
|
1094
|
+
enabled: readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled,
|
|
1095
|
+
historyFiles: readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles,
|
|
1096
|
+
healthCheck: readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck,
|
|
1097
|
+
search: readVolatileValue(config.search) ?? DEFAULT_CONFIG.search,
|
|
1098
|
+
defaultMode: readVolatileValue(config.defaultMode) ?? SWITCH_DEFAULT_CONFIG.defaultMode,
|
|
1099
|
+
autoSync: readVolatileValue(config.autoSync) ?? SWITCH_DEFAULT_CONFIG.autoSync,
|
|
1100
|
+
syncIntervalMs: readVolatileValue(config.syncIntervalMs) ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
|
|
1101
|
+
archiveKeep: readVolatileValue(config.archiveKeep) ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
|
|
1102
|
+
indexDir: readVolatileValue(config.indexDir) ?? SWITCH_DEFAULT_CONFIG.indexDir,
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
const log = (message: string): void => {
|
|
1106
|
+
try {
|
|
1107
|
+
const logger = (ctx as unknown as { logger?: { info?: (m: string) => void; warn?: (m: string) => void } }).logger
|
|
1108
|
+
logger?.info?.(`[session-steward] ${message}`)
|
|
1109
|
+
} catch { /* 日志绝不允许破坏宿主 */ }
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const homeEnv = process.env['DSH_HOME']
|
|
1113
|
+
const resolvedHome = homeEnv !== undefined && homeEnv !== '' ? homeEnv : join(homedir(), '.dsh')
|
|
1114
|
+
|
|
1115
|
+
// 结构镜像取宿主服务(本插件不 value-import 官方包,也不假设 Context 有这些属性)。
|
|
1116
|
+
const webServer = (ctx as unknown as { webServer?: StewardWebServer }).webServer
|
|
1117
|
+
// 双名回退链:a.2 起宿主服务 webRuntime 更名 webStartup;能力探测不分派,两代宿主通吃。
|
|
1118
|
+
const webRuntime = (ctx as unknown as { webStartup?: StewardWebRuntime }).webStartup
|
|
1119
|
+
?? ctx.get?.("webRuntime") as StewardWebRuntime | undefined
|
|
1120
|
+
if (webServer === undefined || webRuntime === undefined) {
|
|
1121
|
+
log('webServer / webStartup(webRuntime) 不可用:不注册路由')
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let ownerIndex: ReturnType<typeof buildProjectionOwnerIndex> | undefined
|
|
1126
|
+
const attributor = (projection: string): { projection?: string; package?: string } | undefined => {
|
|
1127
|
+
try {
|
|
1128
|
+
ownerIndex ??= buildProjectionOwnerIndex(defaultProfileNodeModules(resolvedHome))
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
log(`attribution index build failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
1131
|
+
ownerIndex = []
|
|
1132
|
+
}
|
|
1133
|
+
return createAttributor(ownerIndex)(projection)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 热态投影状态:宿主暴露 sessionProjections.checkpoint(session) 时取得逐行状态(真实判据来源)。
|
|
1137
|
+
const projectionStateFor = (sessionId: string): Record<string, unknown> | undefined => {
|
|
1138
|
+
try {
|
|
1139
|
+
const sessions = ctx.get('sessions') as { get?: (id: string) => unknown } | undefined
|
|
1140
|
+
const projections = ctx.get('sessionProjections') as
|
|
1141
|
+
| { checkpoint?: (session: unknown) => Record<string, unknown> }
|
|
1142
|
+
| undefined
|
|
1143
|
+
if (sessions?.get === undefined || projections?.checkpoint === undefined) return undefined
|
|
1144
|
+
const session = sessions.get(sessionId)
|
|
1145
|
+
if (session === undefined || session === null) return undefined
|
|
1146
|
+
return projections.checkpoint(session)
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
log(`projection checkpoint unavailable for ${sessionId}: ${String(err instanceof Error ? err.message : err)}`)
|
|
1149
|
+
return undefined
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const registry = (): StewardRegistryFace | undefined =>
|
|
1154
|
+
ctx.get('workspaceRegistry') as StewardRegistryFace | undefined
|
|
1155
|
+
|
|
1156
|
+
// Legacy 设置桥(≤0.1.5):同步建 holder,桥面在 ctx.inject(['settings'])
|
|
1157
|
+
// 回调里咬合(0.1.7+ 无 register → 永远空桥 = describe 只读投影)。
|
|
1158
|
+
const settingsBridge: BridgeState = installLegacySettingsBridge(ctx)
|
|
1159
|
+
|
|
1160
|
+
const runtime: StewardRuntime = {
|
|
1161
|
+
config: () => current(),
|
|
1162
|
+
settingsBridge,
|
|
1163
|
+
dshHome: resolvedHome,
|
|
1164
|
+
registry,
|
|
1165
|
+
projectionStateFor,
|
|
1166
|
+
attribute: attributor,
|
|
1167
|
+
log,
|
|
1168
|
+
cache: new HealthCache(),
|
|
1169
|
+
// 管家写侧 → 索引联动:引擎未开(search=false)时为 no-op。
|
|
1170
|
+
index: {
|
|
1171
|
+
onArchive: (ids) => {
|
|
1172
|
+
if (!engine.isOpen) return
|
|
1173
|
+
try { for (const id of ids) engine.setArchivedOne(id, true) } catch (err) { log(`index onArchive failed: ${String(err instanceof Error ? err.message : err)}`) }
|
|
1174
|
+
},
|
|
1175
|
+
onUnarchive: (ids) => {
|
|
1176
|
+
if (!engine.isOpen) return
|
|
1177
|
+
try { for (const id of ids) engine.setArchivedOne(id, false) } catch (err) { log(`index onUnarchive failed: ${String(err instanceof Error ? err.message : err)}`) }
|
|
1178
|
+
},
|
|
1179
|
+
onPurged: (ids) => {
|
|
1180
|
+
if (!engine.isOpen) return
|
|
1181
|
+
try { for (const id of ids) engine.removeSession(id) } catch (err) { log(`index onPurged failed: ${String(err instanceof Error ? err.message : err)}`) }
|
|
1182
|
+
},
|
|
1183
|
+
},
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ── 搜索子域运行时:独立索引生命周期(引擎打开、水位同步、后台轮询)。
|
|
1187
|
+
// 全部是后台工作;HTTP 保持即时。
|
|
1188
|
+
const initial = current()
|
|
1189
|
+
const sessionQuery = ctx.get('sessionQuery') as SwitchSessionQuery | undefined
|
|
1190
|
+
const archiveReader = createArchiveSource(() => ctx.get('workspaceRegistry') as SwitchWorkspaceRegistry | undefined)
|
|
1191
|
+
const indexLayout: SwitchIndexLayout = {
|
|
1192
|
+
...DEFAULT_INDEX_LAYOUT,
|
|
1193
|
+
dir: resolveIndexDir(initial.indexDir || process.env[INDEX_DIR_ENV]),
|
|
1194
|
+
}
|
|
1195
|
+
const engine = new SwitchIndexEngine({ path: `${indexLayout.dir}/${indexLayout.active}` })
|
|
1196
|
+
// 文件级兜底读取器:health 侧的多帧 zstd 读取器直接读转录文件,映射成
|
|
1197
|
+
// SwitchRawEvent 流(version 置 0 —— 语料头版本由调用方钉,不依赖文件)。
|
|
1198
|
+
const readSessionFromFile = async (sessionId: string): Promise<{
|
|
1199
|
+
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
1200
|
+
events: readonly SwitchRawEvent[]
|
|
1201
|
+
} | undefined> => {
|
|
1202
|
+
try {
|
|
1203
|
+
const logPath = findSessionLog(sessionId, resolvedHome)
|
|
1204
|
+
if (logPath === undefined) return undefined
|
|
1205
|
+
const decoded = decodeSessionLogFile(logPath)
|
|
1206
|
+
return {
|
|
1207
|
+
session: { id: sessionId, version: 0 },
|
|
1208
|
+
events: decoded.events.map(event => ({
|
|
1209
|
+
seq: event.seq,
|
|
1210
|
+
type: event.type,
|
|
1211
|
+
time: event.time,
|
|
1212
|
+
surfaceOp: event.surfaceOp,
|
|
1213
|
+
data: event.data,
|
|
1214
|
+
})),
|
|
1215
|
+
}
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
log(`file fallback read failed for ${sessionId}: ${String(err instanceof Error ? err.message : err)}`)
|
|
1218
|
+
return undefined
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// 投影缓存标题第三源:官方逐会话缓存文件 record.rows.title.val(与管家
|
|
1223
|
+
// history 子域同一形状;读不到/畸形一律空串,绝不抛进同步)。
|
|
1224
|
+
const readProjectionTitle = (sessionId: string): string => {
|
|
1225
|
+
try {
|
|
1226
|
+
const path = join(resolvedHome, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`)
|
|
1227
|
+
if (!existsSync(path)) return ''
|
|
1228
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
1229
|
+
record?: { rows?: { title?: { val?: unknown } } }
|
|
1230
|
+
}
|
|
1231
|
+
const value = parsed.record?.rows?.title?.val
|
|
1232
|
+
return typeof value === 'string' ? value : ''
|
|
1233
|
+
} catch {
|
|
1234
|
+
return ''
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const indexState: SwitchIndexServiceState = {
|
|
1239
|
+
engine,
|
|
1240
|
+
archiveReader,
|
|
1241
|
+
sync: new SwitchWatermarkSync(engine, {
|
|
1242
|
+
listSessions: () => sessionQuery?.listSessions() ?? Promise.resolve([]),
|
|
1243
|
+
readSession: async (sessionId: string) => {
|
|
1244
|
+
if (sessionQuery?.readSession === undefined) throw new Error('sessionQuery.readSession 不可用')
|
|
1245
|
+
return sessionQuery.readSession(sessionId)
|
|
1246
|
+
},
|
|
1247
|
+
readTitleSnapshots: sessionQuery === undefined
|
|
1248
|
+
? undefined
|
|
1249
|
+
: (ids) => sessionQuery.readTitleSnapshots(ids),
|
|
1250
|
+
}, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle),
|
|
1251
|
+
layout: indexLayout,
|
|
1252
|
+
rebuild: { state: 'idle', done: 0, total: 0, startedAt: 0, finishedAt: 0, failures: [] },
|
|
1253
|
+
}
|
|
1254
|
+
const srt: SearchRuntime = {
|
|
1255
|
+
sessionQuery,
|
|
1256
|
+
index: indexState,
|
|
1257
|
+
config: () => current(),
|
|
1258
|
+
registry: () => ({ archivedSessionIds: archiveReader.read().ids }),
|
|
1259
|
+
log,
|
|
1260
|
+
readSessionFromFile,
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const initialConfig = current()
|
|
1264
|
+
if (initialConfig.enabled === false) {
|
|
1265
|
+
log('enabled=false:不注册任何路由')
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
let syncTimer: ReturnType<typeof setInterval> | undefined
|
|
1270
|
+
const scheduleSync = (intervalMs: number): void => {
|
|
1271
|
+
if (syncTimer !== undefined) clearInterval(syncTimer)
|
|
1272
|
+
if (intervalMs <= 0) return
|
|
1273
|
+
syncTimer = setInterval(() => {
|
|
1274
|
+
const latest = current()
|
|
1275
|
+
if (latest.enabled === false || latest.search === false || latest.autoSync === false) return
|
|
1276
|
+
void indexState.sync.poll().catch(() => {})
|
|
1277
|
+
}, Math.max(5_000, intervalMs))
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Realtime titles. A rename appends the log-only `session/title` event, which
|
|
1281
|
+
// reaches the index one poll later (default 30s). Folding the title straight
|
|
1282
|
+
// off the append feed removes that latency without a full pass. The listener
|
|
1283
|
+
// is deliberately trivial — this feed carries EVERY appended event, streaming
|
|
1284
|
+
// chunks included — and never throws, because it runs inside the host's
|
|
1285
|
+
// fire-and-forget append publication.
|
|
1286
|
+
let pendingTitleIds = new Set<string>()
|
|
1287
|
+
let titleTimer: ReturnType<typeof setTimeout> | undefined
|
|
1288
|
+
const flushPendingTitles = (): void => {
|
|
1289
|
+
titleTimer = undefined
|
|
1290
|
+
const ids = [...pendingTitleIds]
|
|
1291
|
+
pendingTitleIds = new Set()
|
|
1292
|
+
if (ids.length === 0) return
|
|
1293
|
+
void indexState.sync.refreshTitles(ids).catch(() => {})
|
|
1294
|
+
}
|
|
1295
|
+
ctx.effect(() => {
|
|
1296
|
+
const bus = ctx as unknown as {
|
|
1297
|
+
on?: (name: string, listener: (session: { id?: unknown }, event: { type?: unknown }) => void) => () => void
|
|
1298
|
+
}
|
|
1299
|
+
if (typeof bus.on !== 'function') return () => {}
|
|
1300
|
+
try {
|
|
1301
|
+
return bus.on('session/event', (session, event) => {
|
|
1302
|
+
if (event?.type !== TITLE_EVENT_TYPE) return
|
|
1303
|
+
const latest = current()
|
|
1304
|
+
if (latest.enabled === false || latest.search === false || latest.autoSync === false) return
|
|
1305
|
+
const id = session?.id
|
|
1306
|
+
if (typeof id !== 'string' || id === '') return
|
|
1307
|
+
pendingTitleIds.add(id)
|
|
1308
|
+
if (titleTimer !== undefined) return
|
|
1309
|
+
titleTimer = setTimeout(flushPendingTitles, TITLE_FLUSH_MS)
|
|
1310
|
+
})
|
|
1311
|
+
} catch {
|
|
1312
|
+
// No event bus on this host: the poll stays the fallback path.
|
|
1313
|
+
return () => {}
|
|
1314
|
+
}
|
|
1315
|
+
}, 'dsh-manage-session: realtime titles')
|
|
1316
|
+
|
|
1317
|
+
// 索引生命周期:恢复巡检 → 打开引擎 → 首轮水位同步 → 定时轮询。
|
|
1318
|
+
// search=false 时整套不启动(引擎不开、无后台任务)。
|
|
1319
|
+
if (initialConfig.search !== false) {
|
|
1320
|
+
void (async () => {
|
|
1321
|
+
try {
|
|
1322
|
+
const recovered = await recoverIndex(indexLayout, log)
|
|
1323
|
+
if (recovered.length > 0) log(`index recovery applied ${recovered.length} fix(es)`)
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
log(`index recovery failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
1326
|
+
}
|
|
1327
|
+
await engine.open().catch(() => {})
|
|
1328
|
+
log(`index open: driver=${engine.driverLabel} dir=${indexLayout.dir}`)
|
|
1329
|
+
if (engine.driverLabel === 'node:sqlite') {
|
|
1330
|
+
log('tip: optional speedup not active — approve the better-sqlite3 build (add "better-sqlite3@*: true" under allowBuilds in the profile pnpm-workspace.yaml, then reinstall) to speed up index rebuilds; everything works without it')
|
|
1331
|
+
}
|
|
1332
|
+
if (initialConfig.autoSync !== false) await indexState.sync.poll().catch(() => {})
|
|
1333
|
+
scheduleSync(initialConfig.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs)
|
|
1334
|
+
})()
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
ctx.effect(() => () => {
|
|
1338
|
+
if (syncTimer !== undefined) clearInterval(syncTimer)
|
|
1339
|
+
if (titleTimer !== undefined) clearTimeout(titleTimer)
|
|
1340
|
+
engine.close()
|
|
1341
|
+
}, 'dsh-manage-session: index lifecycle')
|
|
1342
|
+
|
|
1343
|
+
// ── 管家子域路由。
|
|
1344
|
+
ctx.effect(() => webServer.register({
|
|
1345
|
+
kind: 'prefix',
|
|
1346
|
+
path: STEWARD_API_PREFIX,
|
|
1347
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
1348
|
+
if (!isTrustedApiRequest(req, webRuntime.trustedHosts)) {
|
|
1349
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
1350
|
+
return
|
|
1351
|
+
}
|
|
1352
|
+
if (req.method !== 'POST') {
|
|
1353
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1354
|
+
return
|
|
1355
|
+
}
|
|
1356
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
1357
|
+
const prefix = `${STEWARD_API_PREFIX}/`
|
|
1358
|
+
const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined
|
|
1359
|
+
if (method === undefined || method === '' || method.includes('/')) {
|
|
1360
|
+
writeJson(res, 404, { ok: false, error: `unknown session-steward API method` })
|
|
1361
|
+
return
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
const payload = await readJsonBody(req)
|
|
1365
|
+
writeJson(res, 200, await handleMethod(method, payload, runtime))
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
// 显式失败:任何未识别方法/异常都返回错误,绝不静默。
|
|
1368
|
+
writeJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
}), 'dsh-manage-session: /session-steward/api route')
|
|
1372
|
+
|
|
1373
|
+
// ── 搜索子域路由(历史前缀 /switch-search/api)。
|
|
1374
|
+
ctx.effect(() => webServer.register({
|
|
1375
|
+
kind: 'prefix',
|
|
1376
|
+
path: SWITCH_API_PREFIX,
|
|
1377
|
+
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
1378
|
+
if (!isTrustedApiRequest(req, webRuntime.trustedHosts)) {
|
|
1379
|
+
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
1380
|
+
return
|
|
1381
|
+
}
|
|
1382
|
+
if (req.method !== 'POST') {
|
|
1383
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1384
|
+
return
|
|
1385
|
+
}
|
|
1386
|
+
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
1387
|
+
const prefix = `${SWITCH_API_PREFIX}/`
|
|
1388
|
+
const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined
|
|
1389
|
+
if (method === undefined || method === '' || method.includes('/')) {
|
|
1390
|
+
writeJson(res, 404, { ok: false, error: 'unknown switch-search API method' })
|
|
1391
|
+
return
|
|
1392
|
+
}
|
|
1393
|
+
// 墓碑:这两个方法在分包时代属搜索索引包,合并后已归 history 子域。
|
|
1394
|
+
// 旧 client bundle 必须响亮失败并指路,静默 404 会被读成「归档坏了」。
|
|
1395
|
+
if (method in MOVED_TO_HISTORY) {
|
|
1396
|
+
writeJson(res, 410, movedToHistory(method))
|
|
1397
|
+
return
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
if (methodEnabled(method, current()) === false) {
|
|
1401
|
+
writeJson(res, 200, { ok: false, error: `子域已关闭(search=false):${method} 未注册` })
|
|
1402
|
+
return
|
|
1403
|
+
}
|
|
1404
|
+
if (method === 'index-export') {
|
|
1405
|
+
await indexExport(srt, res)
|
|
1406
|
+
return
|
|
1407
|
+
}
|
|
1408
|
+
if (method === 'index-import') {
|
|
1409
|
+
// The body is raw JSON Lines (or a { snapshot } envelope), not JSON.
|
|
1410
|
+
const text = await readRawBody(req)
|
|
1411
|
+
writeJson(res, 200, await indexImport(srt, text))
|
|
1412
|
+
return
|
|
1413
|
+
}
|
|
1414
|
+
const payload = await readJsonBody(req)
|
|
1415
|
+
writeJson(res, 200, await handleIndexMethod(method, payload, srt))
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
writeJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
1418
|
+
}
|
|
1419
|
+
},
|
|
1420
|
+
}), 'dsh-manage-session: /switch-search/api route')
|
|
1421
|
+
}
|