dsh-manage-session 0.5.7 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +33 -229
- package/CHANGELOG.ko.md +33 -229
- package/CHANGELOG.md +33 -363
- package/INSTALL.ja.md +164 -164
- package/INSTALL.ko.md +164 -164
- package/INSTALL.md +164 -164
- package/INSTALL.zh.md +164 -164
- package/LICENSE +21 -21
- package/README.en.md +41 -46
- package/README.ja.md +33 -57
- package/README.ko.md +32 -55
- package/README.md +64 -64
- package/cordis.patch.yml +19 -16
- package/dsh.plugin.json +4 -4
- package/lib/client.js +661 -3082
- package/lib/index.d.ts +79 -970
- package/lib/index.js +249 -2669
- package/locale/de.json +2 -2
- package/locale/en.json +2 -2
- package/locale/es.json +2 -2
- package/locale/fr.json +2 -2
- package/locale/it.json +2 -2
- package/locale/ja.json +2 -2
- package/locale/ko.json +2 -2
- package/locale/ru.json +2 -2
- package/locale/zh.json +2 -2
- package/package.json +15 -25
- package/screenshots.json +3 -3
- package/src/client/bridge-scope.ts +202 -213
- package/src/client/card.tsx +154 -154
- package/src/client/health-panel.tsx +49 -49
- package/src/client/host-api.ts +72 -6
- package/src/client/index.ts +594 -503
- package/src/client/locales.ts +893 -893
- package/src/client/manage-locales.ts +178 -0
- package/src/client/{search/manage.tsx → manage.tsx} +430 -404
- package/src/client/panel.tsx +131 -129
- package/src/client/session-menu-legacy.ts +2 -3
- package/src/client/session-menu.tsx +5 -5
- package/src/config.ts +43 -99
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/repair.ts +2 -2
- package/src/host/health/source-kind.ts +350 -350
- package/src/host/history/archive-source.ts +250 -250
- package/src/host/history/archive.ts +249 -249
- package/src/host/history/favorites.ts +71 -71
- package/src/host/history/purge.ts +370 -370
- package/src/host/history/tombstones.ts +3 -3
- package/src/host/registry/archive-registry.ts +317 -317
- package/src/host/settings-bridge.ts +222 -264
- package/src/index.ts +66 -694
- package/src/client/search/card.tsx +0 -563
- package/src/client/search/host-api.ts +0 -213
- package/src/client/search/locales.ts +0 -861
- package/src/client/search/locate.ts +0 -330
- package/src/client/search/panel.tsx +0 -940
- package/src/client/search/platform.ts +0 -136
- package/src/host/index/archive-source.ts +0 -90
- package/src/host/index/drs-source.ts +0 -211
- package/src/host/index/engine.ts +0 -699
- package/src/host/index/extract.ts +0 -272
- package/src/host/index/peers.ts +0 -62
- package/src/host/index/rebuild.ts +0 -432
- package/src/host/index/schema.ts +0 -136
- package/src/host/index/snapshot.ts +0 -135
- package/src/host/index/sync.ts +0 -413
package/src/index.ts
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-manage-session
|
|
2
|
+
* dsh-manage-session 主机半身(会话管理器):一条 fenced HTTP 路由 ——
|
|
3
3
|
*
|
|
4
|
-
* - `/session
|
|
5
|
-
* - `/switch-search/api`:搜索索引子域(历史前缀,浏览器旧 bundle 依赖),方法名 `index-*`/`list-sessions` 等。
|
|
4
|
+
* - `/manage-session/api`:管理 + 体检,方法名一律 `session-*`。
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - `
|
|
10
|
-
* - `
|
|
6
|
+
* 两个子域开关(feature gate,关掉不注册对应方法,调用返回显式 disabled 错误,
|
|
7
|
+
* 不留空壳):
|
|
8
|
+
* - `historyFiles`(会话历史文件 / 归档):`session-history-*`;
|
|
9
|
+
* - `healthCheck`(健康检查 / 体检):`session-health-*`。
|
|
11
10
|
* 另有插件总开关 `enabled`:关闭后不注册任何路由。
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
* session-
|
|
15
|
-
*
|
|
12
|
+
* **本包不再带搜索/索引**(已拆回 dsh-search-index,DRS 依赖也随之回去):
|
|
13
|
+
* 管理台的语料改由 `session-catalog` 方法从宿主 `sessionQuery` + 官方归档集合
|
|
14
|
+
* 现场拼装(活跃/归档同表),不维护任何自有索引、不依赖 DRS。
|
|
16
15
|
*
|
|
17
16
|
* 只读诊断 + 可逆处置;不改会话日志、不改历史数据、不静默丢弃字段。
|
|
18
17
|
*/
|
|
@@ -26,17 +25,13 @@ import {
|
|
|
26
25
|
DEFAULT_CONFIG,
|
|
27
26
|
STEWARD_API_PREFIX,
|
|
28
27
|
STEWARD_SETTINGS_NAMESPACE,
|
|
29
|
-
SWITCH_API_PREFIX,
|
|
30
|
-
SWITCH_DEFAULT_CONFIG,
|
|
31
|
-
SWITCH_SEARCH_SETTINGS_NAMESPACE,
|
|
32
28
|
type StewardConfig,
|
|
33
|
-
type SwitchSearchConfig,
|
|
34
29
|
} from './config.ts'
|
|
35
30
|
import { archiveHistory, listHistory, pruneHistory, storagePathsFor } from './host/history/archive.ts'
|
|
36
31
|
import { listFavorites, setFavoriteState } from './host/history/favorites.ts'
|
|
37
32
|
import type { StewardRegistryFace } from './host/history/archive-source.ts'
|
|
33
|
+
import { readArchiveSet } from './host/history/archive-source.ts'
|
|
38
34
|
import { locateSessionUsage, purgeHistory } from './host/history/purge.ts'
|
|
39
|
-
import { appendRetiredTombstones, defaultTombstoneDir } from './host/history/tombstones.ts'
|
|
40
35
|
import {
|
|
41
36
|
bridgeOverlay,
|
|
42
37
|
installLegacySettingsBridge,
|
|
@@ -51,29 +46,9 @@ import { decodeSessionLogFile } from './host/health/decode.ts'
|
|
|
51
46
|
import { assessRepair, prescribe, quarantineProjectionCache } from './host/health/repair.ts'
|
|
52
47
|
import { countCorpus, findSession, findSessionLog, scanSessions } from './host/health/scan.ts'
|
|
53
48
|
import { gateSourceKind, migrateSessionSourceKind, readSourceKindFacts } from './host/health/source-kind.ts'
|
|
54
|
-
import { SwitchIndexEngine, resolveTypes, type SwitchIndexContentType, type SwitchSearchSort } from './host/index/engine.ts'
|
|
55
|
-
import { SwitchWatermarkSync, type SwitchSyncState } from './host/index/sync.ts'
|
|
56
|
-
import { createArchiveSource, type SwitchArchiveDiagnostics } from './host/index/archive-source.ts'
|
|
57
|
-
import { detectSteward } from './host/index/peers.ts'
|
|
58
|
-
import { DrsCorpusSource, resolveDrsBaseUrl } from './host/index/drs-source.ts'
|
|
59
|
-
// 归档集合的写方(prune)在管家子域:搜索子域只读归档集合把归档会话折进索引
|
|
60
|
-
// 状态(单一写方,一个 owner —— 合并后 owner 就在本包 history 子域)。
|
|
61
|
-
export { createArchiveSource, type SwitchArchiveDiagnostics } from './host/index/archive-source.ts'
|
|
62
|
-
import {
|
|
63
|
-
DEFAULT_INDEX_LAYOUT,
|
|
64
|
-
importIntoIndex,
|
|
65
|
-
listArchives,
|
|
66
|
-
recoverIndex,
|
|
67
|
-
rebuildIndex,
|
|
68
|
-
resolveIndexDir,
|
|
69
|
-
type SwitchIndexLayout,
|
|
70
|
-
type SwitchRebuildState,
|
|
71
|
-
} from './host/index/rebuild.ts'
|
|
72
|
-
import { exportSnapshot, parseSnapshot } from './host/index/snapshot.ts'
|
|
73
|
-
import { extractTitleFromEvents, type SwitchRawEvent } from './host/index/extract.ts'
|
|
74
49
|
|
|
75
|
-
export { DEFAULT_CONFIG, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE
|
|
76
|
-
export type { StewardConfig
|
|
50
|
+
export { DEFAULT_CONFIG, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE } from './config.ts'
|
|
51
|
+
export type { StewardConfig } from './config.ts'
|
|
77
52
|
export { readArchiveSet, pruneArchiveFile, archiveArchiveFile, editWorkspaceDocument } from './host/history/archive-source.ts'
|
|
78
53
|
export { listHistory, pruneHistory, archiveHistory } from './host/history/archive.ts'
|
|
79
54
|
export { purgeHistory, locateSessionUsage, indexSessionDirs, isSafeChild, dirSize, sessionsRootFor, projCacheRootFor, isSourceMigrateBackupName, backupFilesIn } from './host/history/purge.ts'
|
|
@@ -105,12 +80,6 @@ export { HealthCache } from './host/health/cache.ts'
|
|
|
105
80
|
export type { HealthCacheEntry } from './host/health/cache.ts'
|
|
106
81
|
export { assessRepair } from './host/health/repair.ts'
|
|
107
82
|
export type { RepairAssessment, RepairVerdict } from './host/health/repair.ts'
|
|
108
|
-
export { SwitchIndexEngine } from './host/index/engine.ts'
|
|
109
|
-
export { extractTitleFromEvents } from './host/index/extract.ts'
|
|
110
|
-
export { SwitchWatermarkSync } from './host/index/sync.ts'
|
|
111
|
-
export { rebuildIndex, importIntoIndex, recoverIndex, swapShadowIntoActive, DEFAULT_INDEX_LAYOUT } from './host/index/rebuild.ts'
|
|
112
|
-
export { exportSnapshot, parseSnapshot } from './host/index/snapshot.ts'
|
|
113
|
-
export { detectSteward, probePeer } from './host/index/peers.ts'
|
|
114
83
|
|
|
115
84
|
/** 本插件声明的宿主服务(与 toggle 相同的注入面)。0.2.1-a.2 起 webRuntime 更名 webStartup,读取面走双名回退链。 */
|
|
116
85
|
export const inject = ['webServer', 'webStartup']
|
|
@@ -160,32 +129,12 @@ export const Config = z.object({
|
|
|
160
129
|
enabled: withVolatile(z.boolean().default(true)),
|
|
161
130
|
historyFiles: withVolatile(z.boolean().default(true)),
|
|
162
131
|
healthCheck: withVolatile(z.boolean().default(true)),
|
|
163
|
-
search: withVolatile(z.boolean().default(true)),
|
|
164
132
|
directDelete: withVolatile(z.boolean().default(false)),
|
|
165
|
-
defaultMode: withVolatile(z.union(['title', 'content']).default('title')),
|
|
166
|
-
autoSync: withVolatile(z.boolean().default(true)),
|
|
167
|
-
syncIntervalMs: withVolatile(z.number().default(30_000)),
|
|
168
|
-
archiveKeep: withVolatile(z.number().default(2)),
|
|
169
|
-
indexDir: withVolatile(z.string().default('')),
|
|
170
|
-
corpusSource: withVolatile(z.union(['native', 'drs']).default('drs')),
|
|
171
|
-
drsBaseUrl: withVolatile(z.string().default('')),
|
|
172
133
|
})
|
|
173
134
|
|
|
174
135
|
/** 单次请求体的上限(防御无界读取)。 */
|
|
175
136
|
const MAX_BODY_BYTES = 16 << 20
|
|
176
137
|
|
|
177
|
-
/** content-search 单次返回的默认会话数上限。 */
|
|
178
|
-
const DEFAULT_LIMIT = 20
|
|
179
|
-
|
|
180
|
-
/** 独立索引目录的环境变量覆盖。 */
|
|
181
|
-
const INDEX_DIR_ENV = 'DSH_SWITCH_SEARCH_DIR'
|
|
182
|
-
|
|
183
|
-
/** 改名/自动标题落盘的 log-only 事件(追加进日志,会推 version,水位轮询本可迟到一轮兜住)。 */
|
|
184
|
-
const TITLE_EVENT_TYPE = 'session/title'
|
|
185
|
-
|
|
186
|
-
/** 改名风暴合并为一次标题折叠(自动标题一轮会连发多条)。 */
|
|
187
|
-
const TITLE_FLUSH_MS = 250
|
|
188
|
-
|
|
189
138
|
/** 归一化 Host authority,无法解析时为 undefined。 */
|
|
190
139
|
function parseAuthority(authority: string): URL | undefined {
|
|
191
140
|
try {
|
|
@@ -289,6 +238,10 @@ export interface StewardRuntime {
|
|
|
289
238
|
projectionStateFor: (sessionId: string) => Record<string, unknown> | undefined
|
|
290
239
|
attribute: (projection: string) => { projection?: string; package?: string; field?: string } | undefined
|
|
291
240
|
log: (message: string) => void
|
|
241
|
+
/**
|
|
242
|
+
* 管理台语料来源(宿主 `sessionQuery`;可选:缺席时 `session-catalog` 显式报错)。
|
|
243
|
+
*/
|
|
244
|
+
sessionQuery?: SwitchSessionQuery
|
|
292
245
|
/**
|
|
293
246
|
* 体检结果缓存(进程内,随 fiber 存活)。
|
|
294
247
|
*
|
|
@@ -301,23 +254,6 @@ export interface StewardRuntime {
|
|
|
301
254
|
* 租约。缺省(单测/modern 宿主)= describe 回空投影、mutate 显式拒绝。
|
|
302
255
|
*/
|
|
303
256
|
settingsBridge?: BridgeState
|
|
304
|
-
/**
|
|
305
|
-
* 搜索索引联动钩子(可选):管家写侧操作成功后同步翻索引的归档标记 /
|
|
306
|
-
* 删除索引行,消灭"搜索还挂着 30s 前的幽灵"的窗口。缺省(单测/降级)为无联动。
|
|
307
|
-
*/
|
|
308
|
-
index?: {
|
|
309
|
-
/** 批量归档成功 → 翻 archived=true。 */
|
|
310
|
-
onArchive: (sessionIds: readonly string[]) => void
|
|
311
|
-
/** 取消归档成功 → 翻 archived=false。 */
|
|
312
|
-
onUnarchive: (sessionIds: readonly string[]) => void
|
|
313
|
-
/** 清理成功(物理删除)→ 索引行整个移除。 */
|
|
314
|
-
onPurged: (sessionIds: readonly string[]) => void
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* 退场墓碑台账目录(可选):purge 成功后追加 retired-tombstones.jsonl。
|
|
318
|
-
* 缺省 = ~/.dsh-switch-search(单测注入临时目录隔离真实家目录)。
|
|
319
|
-
*/
|
|
320
|
-
tombstoneDir?: () => string
|
|
321
257
|
}
|
|
322
258
|
|
|
323
259
|
/**
|
|
@@ -341,26 +277,16 @@ export const HEALTH_METHODS = [
|
|
|
341
277
|
'session-health-repair',
|
|
342
278
|
'session-health-source-migrate',
|
|
343
279
|
] as const
|
|
344
|
-
/** 搜索索引子域方法(`/switch-search/api`;index-export/import 走原始体,其余 JSON)。 */
|
|
345
|
-
export const INDEX_METHODS = [
|
|
346
|
-
'list-sessions',
|
|
347
|
-
'content-search',
|
|
348
|
-
'search-status',
|
|
349
|
-
'index-status',
|
|
350
|
-
'index-rebuild',
|
|
351
|
-
'index-export',
|
|
352
|
-
'index-import',
|
|
353
|
-
] as const
|
|
354
280
|
|
|
355
281
|
/** 依据开关判定某方法是否启用。 */
|
|
356
282
|
export function methodEnabled(method: string, config: Required<StewardConfig>): boolean {
|
|
283
|
+
if (method === 'session-catalog') return config.enabled !== false
|
|
357
284
|
if ((HISTORY_METHODS as readonly string[]).includes(method)) return config.historyFiles !== false
|
|
358
285
|
if ((HEALTH_METHODS as readonly string[]).includes(method)) return config.healthCheck !== false
|
|
359
|
-
if ((INDEX_METHODS as readonly string[]).includes(method)) return config.search !== false
|
|
360
286
|
return false
|
|
361
287
|
}
|
|
362
288
|
|
|
363
|
-
/** ------------------------------------------------------------------
|
|
289
|
+
/** ------------------------------------------------------------------ 会话语料结构镜像 */
|
|
364
290
|
|
|
365
291
|
/**
|
|
366
292
|
* One session header shape the query service returns (structural subset).
|
|
@@ -390,21 +316,17 @@ interface SwitchTitleObservationResult {
|
|
|
390
316
|
reason?: unknown
|
|
391
317
|
}
|
|
392
318
|
|
|
393
|
-
/** The session-query service face
|
|
319
|
+
/** The session-query service face used by the manage console catalog. */
|
|
394
320
|
interface SwitchSessionQuery {
|
|
395
321
|
listSessions(signal?: AbortSignal): Promise<readonly SwitchSessionRecord[]>
|
|
396
322
|
readSession?(sessionId: string): Promise<{
|
|
397
323
|
session: SwitchSessionHeader
|
|
398
|
-
events: readonly
|
|
324
|
+
events: readonly unknown[]
|
|
399
325
|
}>
|
|
400
326
|
readTitleSnapshots(
|
|
401
327
|
sessionIds: readonly string[],
|
|
402
328
|
signal?: AbortSignal,
|
|
403
329
|
): Promise<readonly SwitchTitleObservationResult[]>
|
|
404
|
-
searchSessions?(
|
|
405
|
-
request: { query: string; eventFilters?: readonly unknown[]; limit?: number },
|
|
406
|
-
exec?: { signal?: AbortSignal },
|
|
407
|
-
): Promise<{ items: readonly unknown[]; nextCursor?: string }>
|
|
408
330
|
}
|
|
409
331
|
|
|
410
332
|
/**
|
|
@@ -431,316 +353,44 @@ async function titleMap(
|
|
|
431
353
|
return map
|
|
432
354
|
}
|
|
433
355
|
|
|
434
|
-
/**
|
|
435
|
-
async function listSessions(
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
// corpus projection (readTitleSnapshots per session) is what used to blow
|
|
441
|
-
// the client's 10s timeout on large corpora. A refresh sync runs in the
|
|
442
|
-
// background so newly created sessions appear on the next open.
|
|
443
|
-
if (index.engine.isOpen && index.engine.countSessions() > 0) {
|
|
444
|
-
if (sessionQuery !== undefined && index.sync.snapshot().state !== 'syncing') {
|
|
445
|
-
void index.sync.poll().catch(() => {})
|
|
446
|
-
}
|
|
447
|
-
return {
|
|
448
|
-
ok: true,
|
|
449
|
-
items: index.engine.listIndexedSessions().map(session => ({
|
|
450
|
-
sessionId: session.sessionId,
|
|
451
|
-
title: session.title,
|
|
452
|
-
cwd: session.cwd,
|
|
453
|
-
updatedAt: session.updatedAt,
|
|
454
|
-
archived: session.archived,
|
|
455
|
-
})),
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
if (sessionQuery === undefined) {
|
|
459
|
-
return { ok: false, error: 'sessionQuery 服务不可用,且独立索引尚未建立' }
|
|
460
|
-
}
|
|
356
|
+
/** session-catalog: 管理台语料(活跃 + 归档同表,无自有索引、无 DRS)。 */
|
|
357
|
+
async function listSessions(runtime: StewardRuntime): Promise<{ ok: boolean; items?: unknown[]; error?: string }> {
|
|
358
|
+
const sessionQuery = runtime.sessionQuery
|
|
359
|
+
if (sessionQuery === undefined) return { ok: false, error: 'sessionQuery 服务不可用,无法列出会话' }
|
|
360
|
+
// 归档集合与管理台写路同源(存储文件优先、registry 兜底)——否则清理后列表不跟。
|
|
361
|
+
const archivedIds = new Set(readArchiveSet(runtime.registry(), storagePathsFor(runtime.dshHome)).ids)
|
|
461
362
|
try {
|
|
462
363
|
const records = await sessionQuery.listSessions()
|
|
463
364
|
const titles = await titleMap(sessionQuery, records.map(record => record.header.id))
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
365
|
+
const seen = new Set<string>()
|
|
366
|
+
const items: unknown[] = records.map((record) => {
|
|
367
|
+
seen.add(record.header.id)
|
|
368
|
+
return {
|
|
467
369
|
sessionId: record.header.id,
|
|
468
370
|
title: titles.get(record.header.id) ?? '',
|
|
469
371
|
cwd: record.header.cwd ?? '',
|
|
470
372
|
updatedAt: record.header.createdAt,
|
|
471
|
-
archived:
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
} catch (err) {
|
|
475
|
-
return { ok: false, error: String(err instanceof Error ? err.message : err) }
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* content-search: session-grouped hits from the independent index.
|
|
481
|
-
* `sortBy: 'time'` orders by session recency (`updatedAt`), anything else by
|
|
482
|
-
* relevance; the host orders before truncating so the page is honest.
|
|
483
|
-
*/
|
|
484
|
-
async function contentSearch(
|
|
485
|
-
srt: SearchRuntime,
|
|
486
|
-
payload: unknown,
|
|
487
|
-
requestHost = '',
|
|
488
|
-
): Promise<{ ok: boolean; items?: unknown[]; error?: string }> {
|
|
489
|
-
const record = payload as { query?: unknown; limit?: unknown; types?: unknown; sortBy?: unknown; archived?: unknown; expand?: unknown; from?: unknown; to?: unknown } | null
|
|
490
|
-
const query = typeof record?.query === 'string' ? record.query.trim() : ''
|
|
491
|
-
if (query === '') return { ok: false, error: '缺少 query' }
|
|
492
|
-
// 检索域 chip(全部/活跃/归档);未知值回退 'all',新旧客户端互相兼容。
|
|
493
|
-
const archived: 'active' | 'archived' | 'all'
|
|
494
|
-
= record?.archived === 'active' ? 'active'
|
|
495
|
-
: record?.archived === 'archived' ? 'archived'
|
|
496
|
-
: 'all'
|
|
497
|
-
// 日期范围(R6):本地日界,结束日含当天;无效值静默忽略(新旧客户端兼容)。
|
|
498
|
-
const fromMs = dayBoundaryMs(record?.from)
|
|
499
|
-
const toMs = record?.to === undefined ? undefined : dayBoundaryMs(record?.to, true)
|
|
500
|
-
const requestedLimit = typeof record?.limit === 'number' && Number.isSafeInteger(record.limit)
|
|
501
|
-
? record.limit
|
|
502
|
-
: DEFAULT_LIMIT
|
|
503
|
-
const limit = Math.min(Math.max(1, requestedLimit), 100)
|
|
504
|
-
// Unknown or absent ordering degrades to relevance — never to an error, so an
|
|
505
|
-
// old client half talking to a new host half keeps working.
|
|
506
|
-
const sortBy: SwitchSearchSort = record?.sortBy === 'time' ? 'time' : 'relevance'
|
|
507
|
-
let types: readonly SwitchIndexContentType[]
|
|
508
|
-
if (Array.isArray(record?.types) && record.types.length > 0) {
|
|
509
|
-
types = record.types.filter((entry): entry is SwitchIndexContentType =>
|
|
510
|
-
entry === 'all' || entry === 'user' || entry === 'reply' || entry === 'tool')
|
|
511
|
-
} else {
|
|
512
|
-
types = ['user', 'reply']
|
|
513
|
-
}
|
|
514
|
-
// T7.3 硬依赖直连臂:检索语义(会话分组/类型加权/表面与归档日期过滤/
|
|
515
|
-
// 锚定节选)全部在 dsh-reference-session-collector 投影库侧完成(op=search&group=session,
|
|
516
|
-
// 语料已经过注入段剥离)。DRS 不可达 → 显式报错,**不回退本地索引**。
|
|
517
|
-
const kinds = resolveTypes(types)
|
|
518
|
-
try {
|
|
519
|
-
const items = await srt.drsSource.searchGrouped({
|
|
520
|
-
query,
|
|
521
|
-
kinds,
|
|
522
|
-
archived,
|
|
523
|
-
sortBy,
|
|
524
|
-
limit,
|
|
525
|
-
// 命中展开(面板「分开显示」开关):同会话多命中各自成行;
|
|
526
|
-
// 非 true 一律缺省聚合(旧客户端零感知)。
|
|
527
|
-
...(record?.expand === true ? { expand: true } : {}),
|
|
528
|
-
...(requestHost === '' ? {} : { atHost: requestHost }),
|
|
529
|
-
...(fromMs === undefined ? {} : { from: fromMs }),
|
|
530
|
-
...(toMs === undefined ? {} : { to: toMs }),
|
|
373
|
+
archived: archivedIds.has(record.header.id),
|
|
374
|
+
}
|
|
531
375
|
})
|
|
376
|
+
// 归档集合里未被 sessionQuery 语料覆盖的 id(老线/离线条目)也列出来,
|
|
377
|
+
// 否则「归档域筛选」会漏掉真实存在于归档数组里的会话。
|
|
378
|
+
for (const sessionId of archivedIds) {
|
|
379
|
+
if (seen.has(sessionId)) continue
|
|
380
|
+
items.push({ sessionId, title: '', cwd: '', updatedAt: 0, archived: true })
|
|
381
|
+
}
|
|
532
382
|
return { ok: true, items }
|
|
533
383
|
} catch (err) {
|
|
534
|
-
return { ok: false, error:
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** search-status: probe the independent index readiness and progress. */
|
|
539
|
-
async function searchStatus(srt: SearchRuntime, requestHost = ''): Promise<unknown> {
|
|
540
|
-
const index = srt.index
|
|
541
|
-
const sync = index.sync.snapshot()
|
|
542
|
-
// T7.3:搜索可用性由 DRS 探针裁决(硬依赖);本地引擎仅剩会话头元数据面。
|
|
543
|
-
const drsUp = await srt.drsSource.probe(requestHost)
|
|
544
|
-
return {
|
|
545
|
-
ok: true,
|
|
546
|
-
available: drsUp,
|
|
547
|
-
reason: drsUp ? undefined : 'drs-unavailable',
|
|
548
|
-
indexing: sync.state === 'syncing',
|
|
549
|
-
archivedSessions: index.engine.countArchived(),
|
|
550
|
-
archive: index.archiveReader.diagnostics(),
|
|
551
|
-
sync,
|
|
552
|
-
rebuild: index.rebuild,
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** index-status: full lifecycle surface for the settings row. */
|
|
557
|
-
async function indexStatus(srt: SearchRuntime): Promise<unknown> {
|
|
558
|
-
const index = srt.index
|
|
559
|
-
const sync = index.sync.snapshot()
|
|
560
|
-
let indexed = sync.indexed
|
|
561
|
-
if (index.engine.isOpen) indexed = index.engine.countSessions()
|
|
562
|
-
return {
|
|
563
|
-
ok: true,
|
|
564
|
-
available: index.engine.isOpen && indexed > 0,
|
|
565
|
-
archivedSessions: index.engine.isOpen ? index.engine.countArchived() : 0,
|
|
566
|
-
// 归档子域的 owner 就是本包(history 子域);保留探针字段形状,旧客户端
|
|
567
|
-
// 的「会话管家负责」提示语在新包语境下依然为真。
|
|
568
|
-
steward: detectSteward(),
|
|
569
|
-
driver: index.engine.driverLabel,
|
|
570
|
-
archive: index.archiveReader.diagnostics() as SwitchArchiveDiagnostics,
|
|
571
|
-
dir: index.layout.dir,
|
|
572
|
-
// 直接删除入口开关(功能①):搜索面板据此渲染行内删除按钮;缺省关。
|
|
573
|
-
directDelete: srt.config().directDelete === true,
|
|
574
|
-
archives: await listArchives(index.layout).catch(() => []),
|
|
575
|
-
sync: { ...sync, indexed } satisfies SwitchSyncState,
|
|
576
|
-
rebuild: index.rebuild,
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/**
|
|
581
|
-
* index-rebuild: start the non-destructive 整理 (shadow build → atomic swap →
|
|
582
|
-
* archives). Responds immediately; progress rides index-status.
|
|
583
|
-
*/
|
|
584
|
-
async function indexRebuild(srt: SearchRuntime): Promise<{ ok: boolean; started?: boolean; error?: string }> {
|
|
585
|
-
const index = srt.index
|
|
586
|
-
if (index.rebuild.state === 'building' || index.rebuild.state === 'swapping') {
|
|
587
|
-
return { ok: false, error: '整理已在进行中' }
|
|
588
|
-
}
|
|
589
|
-
const sessionQuery = srt.sessionQuery
|
|
590
|
-
if (sessionQuery === undefined || sessionQuery.readSession === undefined) {
|
|
591
|
-
return { ok: false, error: 'sessionQuery 服务不可用,无法读取会话日志' }
|
|
592
|
-
}
|
|
593
|
-
const config = srt.config()
|
|
594
|
-
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep)
|
|
595
|
-
void rebuildIndex(
|
|
596
|
-
index.engine,
|
|
597
|
-
index.layout,
|
|
598
|
-
{
|
|
599
|
-
listSessions: () => sessionQuery.listSessions(),
|
|
600
|
-
readSession: async (sessionId: string) => {
|
|
601
|
-
const snapshot = await sessionQuery.readSession!(sessionId)
|
|
602
|
-
return { session: snapshot.session, events: snapshot.events }
|
|
603
|
-
},
|
|
604
|
-
},
|
|
605
|
-
keepArchives,
|
|
606
|
-
undefined,
|
|
607
|
-
srt.registry,
|
|
608
|
-
{
|
|
609
|
-
log: srt.log,
|
|
610
|
-
onState: (live) => { index.rebuild = live },
|
|
611
|
-
readSessionFromFile: srt.readSessionFromFile,
|
|
612
|
-
},
|
|
613
|
-
).then((state) => {
|
|
614
|
-
index.rebuild = state
|
|
615
|
-
}).catch((err) => {
|
|
616
|
-
index.rebuild = {
|
|
617
|
-
state: 'error',
|
|
618
|
-
done: 0,
|
|
619
|
-
total: 0,
|
|
620
|
-
startedAt: Date.now(),
|
|
621
|
-
finishedAt: 0,
|
|
622
|
-
failures: [],
|
|
623
|
-
error: String(err instanceof Error ? err.message : err),
|
|
624
|
-
}
|
|
625
|
-
})
|
|
626
|
-
index.rebuild = { state: 'building', done: 0, total: 0, startedAt: Date.now(), finishedAt: 0, failures: [] }
|
|
627
|
-
return { ok: true, started: true }
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Tombs for the two methods the search package used to own before the
|
|
632
|
-
* steward merge. A stale client bundle (browser refresh does not reload the
|
|
633
|
-
* host half) must fail LOUDLY and be told where the feature went — a silent
|
|
634
|
-
* 404 would read as "archiving is broken".
|
|
635
|
-
*/
|
|
636
|
-
const MOVED_TO_HISTORY: Record<string, string> = {
|
|
637
|
-
'list-archived': 'session-history-list',
|
|
638
|
-
'archive-prune': 'session-history-prune',
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** Build the explicit "moved" error body for a tombstoned method. */
|
|
642
|
-
function movedToHistory(method: string): { ok: false; error: string } {
|
|
643
|
-
const replacement = MOVED_TO_HISTORY[method]
|
|
644
|
-
return {
|
|
645
|
-
ok: false,
|
|
646
|
-
error: `"${method}" 已并入会话管家 history 子域:请改用 POST ${STEWARD_API_PREFIX}/${replacement}`,
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/** index-export: dump the active index as JSON Lines. */
|
|
651
|
-
async function indexExport(srt: SearchRuntime, res: ServerResponse): Promise<void> {
|
|
652
|
-
const index = srt.index
|
|
653
|
-
if (index.engine.isOpen === false) {
|
|
654
|
-
writeJson(res, 200, { ok: false, error: '独立索引未就绪' })
|
|
655
|
-
return
|
|
656
|
-
}
|
|
657
|
-
writeRaw(res, 200, 'application/x-ndjson; charset=utf-8', exportSnapshot(index.engine))
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/** index-import: parse a JSON Lines snapshot and swap it in as the active index. */
|
|
661
|
-
async function indexImport(srt: SearchRuntime, text: string) {
|
|
662
|
-
const index = srt.index
|
|
663
|
-
if (index.rebuild.state === 'building' || index.rebuild.state === 'swapping') {
|
|
664
|
-
return { ok: false, error: '整理/导入已在进行中' }
|
|
665
|
-
}
|
|
666
|
-
// Body is either raw JSON Lines or a JSON envelope { snapshot: "..." }.
|
|
667
|
-
if (text.includes('"snapshot"')) {
|
|
668
|
-
try {
|
|
669
|
-
const envelope = JSON.parse(text) as { snapshot?: unknown }
|
|
670
|
-
if (typeof envelope.snapshot === 'string') text = envelope.snapshot
|
|
671
|
-
} catch { /* treat as plain JSONL */ }
|
|
384
|
+
return { ok: false, error: String(err instanceof Error ? err.message : err) }
|
|
672
385
|
}
|
|
673
|
-
const parsed = parseSnapshot(text)
|
|
674
|
-
if (parsed.records.length === 0) return { ok: false, error: `快照无可导入会话(跳过 ${parsed.skipped} 行)` }
|
|
675
|
-
const config = srt.config()
|
|
676
|
-
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep)
|
|
677
|
-
void importIntoIndex(index.engine, index.layout, parsed.records, keepArchives, srt.log)
|
|
678
|
-
.then((state) => { index.rebuild = state })
|
|
679
|
-
.catch((err) => {
|
|
680
|
-
index.rebuild = {
|
|
681
|
-
state: 'error',
|
|
682
|
-
done: 0,
|
|
683
|
-
total: parsed.records.length,
|
|
684
|
-
startedAt: Date.now(),
|
|
685
|
-
finishedAt: 0,
|
|
686
|
-
failures: [],
|
|
687
|
-
error: String(err instanceof Error ? err.message : err),
|
|
688
|
-
}
|
|
689
|
-
})
|
|
690
|
-
index.rebuild = { state: 'building', done: 0, total: parsed.records.length, startedAt: Date.now(), finishedAt: 0, failures: [] }
|
|
691
|
-
return { ok: true, started: true, sessions: parsed.records.length, skipped: parsed.skipped }
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
/** ------------------------------------------------------------------ index service */
|
|
695
|
-
|
|
696
|
-
/** The per-activation index service state, carried in the apply closure. */
|
|
697
|
-
export interface SwitchIndexServiceState {
|
|
698
|
-
engine: SwitchIndexEngine
|
|
699
|
-
sync: SwitchWatermarkSync
|
|
700
|
-
layout: SwitchIndexLayout
|
|
701
|
-
rebuild: SwitchRebuildState
|
|
702
|
-
/** Official archive-set reader (registry first, storage-hub file fallback). */
|
|
703
|
-
archiveReader: ReturnType<typeof createArchiveSource>
|
|
704
386
|
}
|
|
705
387
|
|
|
706
|
-
/**
|
|
707
|
-
|
|
708
|
-
* optional live sessionQuery, the index service state, and the latest config.
|
|
709
|
-
* Handlers never touch the cordis context — arbitrary property writes on a
|
|
710
|
-
* Context are rejected ("cannot set property ... without provide").
|
|
711
|
-
*/
|
|
712
|
-
export interface SearchRuntime {
|
|
388
|
+
/** 管理台语料运行时(无索引、无 DRS;只认宿主 sessionQuery + 官方归档集合)。 */
|
|
389
|
+
interface CatalogRuntime {
|
|
713
390
|
sessionQuery: SwitchSessionQuery | undefined
|
|
714
|
-
index: SwitchIndexServiceState
|
|
715
|
-
/** DRS 直连客户端(T7.3 硬依赖:内容分组检索臂唯一来源 + 可性探针)。 */
|
|
716
|
-
drsSource: DrsCorpusSource
|
|
717
|
-
config: () => Required<StewardConfig> & SwitchSearchConfig
|
|
718
|
-
/** Lazy official archive-set source (registry first, file fallback). */
|
|
719
391
|
registry: () => { archivedSessionIds: readonly string[] }
|
|
720
|
-
/** Cordis logger bridge ([session-steward] prefixed). */
|
|
721
|
-
log: (msg: string) => void
|
|
722
|
-
/** 文件级兜底读取器(重建钩子透传;见 SwitchWatermarkSync 注释)。 */
|
|
723
|
-
readSessionFromFile: (sessionId: string) => Promise<{
|
|
724
|
-
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
725
|
-
events: readonly SwitchRawEvent[]
|
|
726
|
-
} | undefined>
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/** JSON 面的搜索子域方法分发(index-export/import 走原始体,在路由层特判)。 */
|
|
730
|
-
export async function handleIndexMethod(method: string, payload: unknown, srt: SearchRuntime, requestHost = ''): Promise<unknown> {
|
|
731
|
-
if (method === 'list-sessions') return await listSessions(srt)
|
|
732
|
-
if (method === 'content-search') return await contentSearch(srt, payload, requestHost)
|
|
733
|
-
if (method === 'search-status') return await searchStatus(srt, requestHost)
|
|
734
|
-
if (method === 'index-status') return await indexStatus(srt)
|
|
735
|
-
if (method === 'index-rebuild') return await indexRebuild(srt)
|
|
736
|
-
return { ok: false, error: `未知的 switch-search API 方法 "${method}"` }
|
|
737
392
|
}
|
|
738
393
|
|
|
739
|
-
/**
|
|
740
|
-
* 官方 workspaceRegistry 的内存归档面(结构化镜像)。0.1.7+ 双方法齐全,
|
|
741
|
-
* 0.1.5 只有 archiveSession;方法自带 setState 持久化 —— 调用即生效即落盘,
|
|
742
|
-
* **免重启**。探测到才走内存路径,否则降级文件编辑(requiresRestart)。
|
|
743
|
-
*/
|
|
744
394
|
interface MemoryRegistryFace {
|
|
745
395
|
archiveSession?(sessionId: string, options?: { stopActivity?: boolean }): Promise<void>
|
|
746
396
|
unarchiveSession?(sessionId: string): Promise<void>
|
|
@@ -769,7 +419,7 @@ async function runMemoryArchiveOps(
|
|
|
769
419
|
|
|
770
420
|
/** 收藏文件候选(与管家其余域同一 dshHome 纪律)。 */
|
|
771
421
|
function favoritesPathsFor(dshHome: string): string[] {
|
|
772
|
-
return [join(dshHome, 'storages', 'session-
|
|
422
|
+
return [join(dshHome, 'storages', 'manage-session-favorites.json')]
|
|
773
423
|
}
|
|
774
424
|
|
|
775
425
|
/** 本地日界(YYYY-MM-DD → epoch ms;nextDay 为排他上界)。非法返回 undefined。 */
|
|
@@ -795,27 +445,18 @@ function validSessionIds(value: unknown): string[] | undefined {
|
|
|
795
445
|
* @param payload - 已解析的请求体。
|
|
796
446
|
* @param runtime - 运行时依赖。
|
|
797
447
|
*/
|
|
798
|
-
/**
|
|
448
|
+
/** 现代宿主的只读投影:把运行时配置按命名空间形状输出(describe 用)。 */
|
|
799
449
|
function configSnapshotProjection(
|
|
800
|
-
config: Required<StewardConfig
|
|
450
|
+
config: Required<StewardConfig>,
|
|
801
451
|
): Record<string, Record<string, unknown>> {
|
|
802
452
|
return {
|
|
803
453
|
[STEWARD_SETTINGS_NAMESPACE]: {
|
|
804
454
|
enabled: config.enabled,
|
|
805
455
|
historyFiles: config.historyFiles,
|
|
806
456
|
healthCheck: config.healthCheck,
|
|
807
|
-
search: config.search,
|
|
808
457
|
// 老线卡回读面:漏字段 = 拨了显示不跟(写路 bridge schema 已在位)。
|
|
809
458
|
directDelete: config.directDelete,
|
|
810
459
|
},
|
|
811
|
-
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
812
|
-
enabled: config.enabled,
|
|
813
|
-
defaultMode: config.defaultMode ?? SWITCH_DEFAULT_CONFIG.defaultMode,
|
|
814
|
-
autoSync: config.autoSync ?? SWITCH_DEFAULT_CONFIG.autoSync,
|
|
815
|
-
syncIntervalMs: config.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs,
|
|
816
|
-
archiveKeep: config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep,
|
|
817
|
-
indexDir: config.indexDir ?? SWITCH_DEFAULT_CONFIG.indexDir,
|
|
818
|
-
},
|
|
819
460
|
}
|
|
820
461
|
}
|
|
821
462
|
|
|
@@ -828,9 +469,9 @@ export async function handleMethod(
|
|
|
828
469
|
// 设置桥方法不受子域闸管——关掉 enabled/historyFiles/healthCheck 后仍必须
|
|
829
470
|
// 能读配置(否则卡片永远读不到「把它打开」的开关)。
|
|
830
471
|
const settingsMethods = method === 'settings-describe' || method === 'settings-mutate'
|
|
831
|
-
const known = [...HISTORY_METHODS, ...HEALTH_METHODS, 'settings-describe', 'settings-mutate'] as readonly string[]
|
|
472
|
+
const known = [...HISTORY_METHODS, ...HEALTH_METHODS, 'session-catalog', 'settings-describe', 'settings-mutate'] as readonly string[]
|
|
832
473
|
if (!known.includes(method)) {
|
|
833
|
-
return { ok: false, error: `未知的 session
|
|
474
|
+
return { ok: false, error: `未知的 manage-session API 方法 "${method}"` }
|
|
834
475
|
}
|
|
835
476
|
if (!settingsMethods && !methodEnabled(method, config)) {
|
|
836
477
|
const which = (HISTORY_METHODS as readonly string[]).includes(method) ? 'historyFiles' : 'healthCheck'
|
|
@@ -851,6 +492,9 @@ export async function handleMethod(
|
|
|
851
492
|
if (method === 'session-history-list') {
|
|
852
493
|
return await listHistory(runtime.registry, undefined, storagePathsFor(runtime.dshHome), runtime.dshHome)
|
|
853
494
|
}
|
|
495
|
+
if (method === 'session-catalog') {
|
|
496
|
+
return await listSessions(runtime)
|
|
497
|
+
}
|
|
854
498
|
if (method === 'session-history-archive') {
|
|
855
499
|
const request = (payload ?? {}) as { sessionIds?: unknown }
|
|
856
500
|
const ids = validSessionIds(request.sessionIds)
|
|
@@ -861,7 +505,6 @@ export async function handleMethod(
|
|
|
861
505
|
if (mem?.archiveSession !== undefined) {
|
|
862
506
|
const before = new Set(mem.archivedSessionIds ?? [])
|
|
863
507
|
const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, 'archive')
|
|
864
|
-
runtime.index?.onArchive(succeeded)
|
|
865
508
|
const total = (mem.archivedSessionIds ?? []).length
|
|
866
509
|
return {
|
|
867
510
|
ok: true,
|
|
@@ -871,9 +514,7 @@ export async function handleMethod(
|
|
|
871
514
|
...(failures.length === 0 ? {} : { failures }),
|
|
872
515
|
}
|
|
873
516
|
}
|
|
874
|
-
|
|
875
|
-
if (result.ok) runtime.index?.onArchive(ids)
|
|
876
|
-
return result
|
|
517
|
+
return archiveHistory(payload, runtime.log, storagePathsFor(runtime.dshHome))
|
|
877
518
|
}
|
|
878
519
|
if (method === 'session-history-prune') {
|
|
879
520
|
const request = (payload ?? {}) as { sessionIds?: unknown }
|
|
@@ -882,7 +523,6 @@ export async function handleMethod(
|
|
|
882
523
|
const mem = runtime.registry() as MemoryRegistryFace | undefined
|
|
883
524
|
if (mem?.unarchiveSession !== undefined && ids !== undefined) {
|
|
884
525
|
const { succeeded, failures } = await runMemoryArchiveOps(mem, ids, 'unarchive')
|
|
885
|
-
runtime.index?.onUnarchive(succeeded)
|
|
886
526
|
const remaining = (mem.archivedSessionIds ?? []).length
|
|
887
527
|
return {
|
|
888
528
|
ok: true,
|
|
@@ -892,9 +532,7 @@ export async function handleMethod(
|
|
|
892
532
|
...(failures.length === 0 ? {} : { failures }),
|
|
893
533
|
}
|
|
894
534
|
}
|
|
895
|
-
|
|
896
|
-
if (result.ok && ids !== undefined) runtime.index?.onUnarchive(ids)
|
|
897
|
-
return result
|
|
535
|
+
return pruneHistory(payload, runtime.log, storagePathsFor(runtime.dshHome))
|
|
898
536
|
}
|
|
899
537
|
if (method === 'session-history-purge') {
|
|
900
538
|
const request = (payload ?? {}) as { sessionIds?: unknown }
|
|
@@ -925,13 +563,6 @@ export async function handleMethod(
|
|
|
925
563
|
failures.push({ sessionId: id, reason: String(err instanceof Error ? err.message : err) })
|
|
926
564
|
}
|
|
927
565
|
}
|
|
928
|
-
runtime.index?.onPurged(succeeded)
|
|
929
|
-
// 墓碑台账(功能②前置):只对成功退场的 id 落账;写失败不影响删除结果。
|
|
930
|
-
appendRetiredTombstones(succeeded, {
|
|
931
|
-
...(runtime.tombstoneDir === undefined ? {} : { dir: runtime.tombstoneDir() }),
|
|
932
|
-
source: 'steward-purge-memory',
|
|
933
|
-
log: runtime.log,
|
|
934
|
-
})
|
|
935
566
|
return {
|
|
936
567
|
ok: true,
|
|
937
568
|
purged: succeeded.length,
|
|
@@ -940,22 +571,10 @@ export async function handleMethod(
|
|
|
940
571
|
...(failures.length === 0 ? {} : { failures }),
|
|
941
572
|
}
|
|
942
573
|
}
|
|
943
|
-
|
|
574
|
+
return purgeHistory(payload, runtime.log, {
|
|
944
575
|
dshHome: runtime.dshHome,
|
|
945
576
|
searchPaths: storagePathsFor(runtime.dshHome),
|
|
946
577
|
})
|
|
947
|
-
// 联动只在真删成功后:失败条目留在索引里(还能搜到,如实)。
|
|
948
|
-
if (result.ok && ids !== undefined) {
|
|
949
|
-
const failed = new Set((result.failures ?? []).map(f => f.sessionId))
|
|
950
|
-
const gone = ids.filter((id) => !failed.has(id))
|
|
951
|
-
runtime.index?.onPurged(gone)
|
|
952
|
-
appendRetiredTombstones(gone, {
|
|
953
|
-
...(runtime.tombstoneDir === undefined ? {} : { dir: runtime.tombstoneDir() }),
|
|
954
|
-
source: 'steward-purge-fallback',
|
|
955
|
-
log: runtime.log,
|
|
956
|
-
})
|
|
957
|
-
}
|
|
958
|
-
return result
|
|
959
578
|
}
|
|
960
579
|
|
|
961
580
|
if (method === 'session-history-favorites-list') {
|
|
@@ -974,7 +593,12 @@ export async function handleMethod(
|
|
|
974
593
|
ok: true,
|
|
975
594
|
namespace: STEWARD_SETTINGS_NAMESPACE,
|
|
976
595
|
prefix: STEWARD_API_PREFIX,
|
|
977
|
-
switches: {
|
|
596
|
+
switches: {
|
|
597
|
+
enabled: config.enabled,
|
|
598
|
+
historyFiles: config.historyFiles,
|
|
599
|
+
healthCheck: config.healthCheck,
|
|
600
|
+
directDelete: config.directDelete,
|
|
601
|
+
},
|
|
978
602
|
historyMethods: [...HISTORY_METHODS],
|
|
979
603
|
healthMethods: [...HEALTH_METHODS],
|
|
980
604
|
home: runtime.dshHome,
|
|
@@ -1129,43 +753,28 @@ export async function handleMethod(
|
|
|
1129
753
|
* @param ctx - host 插件上下文(webServer / webStartup(旧名 webRuntime)/ 可选 sessionQuery、workspaceRegistry)。
|
|
1130
754
|
* @param config - 组合条目(0.1.7:`.volatile()` 字段为 live ref)。
|
|
1131
755
|
*/
|
|
1132
|
-
export function apply(ctx: Context, config: Partial<StewardConfig
|
|
756
|
+
export function apply(ctx: Context, config: Partial<StewardConfig> = {}): void {
|
|
1133
757
|
// 0.1.7:volatile 字段每次读取解引出最新快照(开关即时生效),不再有任何
|
|
1134
758
|
// settings 注册调用。
|
|
1135
759
|
// ≤0.1.5 补口(3085 实锤):老线组合 config 是静态缺省,卡的写落在宿主
|
|
1136
760
|
// settings 服务(下方 settingsBridge)——不叠加则「拨得动、不生效」;
|
|
1137
761
|
// 已写键才采信(型别不符/缺席回退组合值),桥未咬合(0.1.7+)零叠加。
|
|
1138
|
-
const current = (): Required<StewardConfig>
|
|
1139
|
-
const { steward
|
|
762
|
+
const current = (): Required<StewardConfig> => {
|
|
763
|
+
const { steward } = bridgeOverlay(settingsBridge)
|
|
1140
764
|
const bool = (doc: Record<string, unknown>, key: string, v: boolean): boolean =>
|
|
1141
765
|
typeof doc[key] === 'boolean' ? (doc[key] as boolean) : v
|
|
1142
|
-
const num = (doc: Record<string, unknown>, key: string, v: number): number =>
|
|
1143
|
-
typeof doc[key] === 'number' && Number.isFinite(doc[key] as number) ? (doc[key] as number) : v
|
|
1144
|
-
const str = (doc: Record<string, unknown>, key: string, v: string): string =>
|
|
1145
|
-
typeof doc[key] === 'string' ? (doc[key] as string) : v
|
|
1146
|
-
const mode = (v: 'title' | 'content'): 'title' | 'content' =>
|
|
1147
|
-
search['defaultMode'] === 'title' || search['defaultMode'] === 'content' ? (search['defaultMode'] as 'title' | 'content') : v
|
|
1148
766
|
return {
|
|
1149
767
|
enabled: bool(steward, 'enabled', readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled),
|
|
1150
768
|
historyFiles: bool(steward, 'historyFiles', readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles),
|
|
1151
769
|
healthCheck: bool(steward, 'healthCheck', readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck),
|
|
1152
|
-
search: bool(steward, 'search', readVolatileValue(config.search) ?? DEFAULT_CONFIG.search),
|
|
1153
770
|
directDelete: bool(steward, 'directDelete', readVolatileValue(config.directDelete) ?? DEFAULT_CONFIG.directDelete),
|
|
1154
|
-
defaultMode: mode(readVolatileValue(config.defaultMode) ?? SWITCH_DEFAULT_CONFIG.defaultMode),
|
|
1155
|
-
autoSync: bool(search, 'autoSync', readVolatileValue(config.autoSync) ?? SWITCH_DEFAULT_CONFIG.autoSync),
|
|
1156
|
-
syncIntervalMs: num(search, 'syncIntervalMs', readVolatileValue(config.syncIntervalMs) ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs),
|
|
1157
|
-
archiveKeep: num(search, 'archiveKeep', readVolatileValue(config.archiveKeep) ?? SWITCH_DEFAULT_CONFIG.archiveKeep),
|
|
1158
|
-
indexDir: str(search, 'indexDir', readVolatileValue(config.indexDir) ?? SWITCH_DEFAULT_CONFIG.indexDir),
|
|
1159
|
-
// corpusSource/drsBaseUrl 不在 legacy 桥 schema(老线无 DRS 接线面):只认组合值。
|
|
1160
|
-
corpusSource: readVolatileValue(config.corpusSource) ?? SWITCH_DEFAULT_CONFIG.corpusSource,
|
|
1161
|
-
drsBaseUrl: str(search, 'drsBaseUrl', readVolatileValue(config.drsBaseUrl) ?? SWITCH_DEFAULT_CONFIG.drsBaseUrl),
|
|
1162
771
|
}
|
|
1163
772
|
}
|
|
1164
773
|
|
|
1165
774
|
const log = (message: string): void => {
|
|
1166
775
|
try {
|
|
1167
776
|
const logger = (ctx as unknown as { logger?: { info?: (m: string) => void; warn?: (m: string) => void } }).logger
|
|
1168
|
-
logger?.info?.(`[session
|
|
777
|
+
logger?.info?.(`[manage-session] ${message}`)
|
|
1169
778
|
} catch { /* 日志绝不允许破坏宿主 */ }
|
|
1170
779
|
}
|
|
1171
780
|
|
|
@@ -1226,198 +835,10 @@ export function apply(ctx: Context, config: Partial<StewardConfig & SwitchSearch
|
|
|
1226
835
|
attribute: attributor,
|
|
1227
836
|
log,
|
|
1228
837
|
cache: new HealthCache(),
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
onArchive: (ids) => {
|
|
1232
|
-
if (!engine.isOpen) return
|
|
1233
|
-
try { for (const id of ids) engine.setArchivedOne(id, true) } catch (err) { log(`index onArchive failed: ${String(err instanceof Error ? err.message : err)}`) }
|
|
1234
|
-
},
|
|
1235
|
-
onUnarchive: (ids) => {
|
|
1236
|
-
if (!engine.isOpen) return
|
|
1237
|
-
try { for (const id of ids) engine.setArchivedOne(id, false) } catch (err) { log(`index onUnarchive failed: ${String(err instanceof Error ? err.message : err)}`) }
|
|
1238
|
-
},
|
|
1239
|
-
onPurged: (ids) => {
|
|
1240
|
-
if (!engine.isOpen) return
|
|
1241
|
-
try { for (const id of ids) engine.removeSession(id) } catch (err) { log(`index onPurged failed: ${String(err instanceof Error ? err.message : err)}`) }
|
|
1242
|
-
},
|
|
1243
|
-
},
|
|
1244
|
-
// 墓碑台账随索引目录(indexDir 自定义时同源,否则 ~/.dsh-switch-search)。
|
|
1245
|
-
tombstoneDir: () => {
|
|
1246
|
-
const dir = current().indexDir
|
|
1247
|
-
return dir !== '' ? dir : defaultTombstoneDir()
|
|
1248
|
-
},
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// ── 搜索子域运行时:独立索引生命周期(引擎打开、水位同步、后台轮询)。
|
|
1252
|
-
// 全部是后台工作;HTTP 保持即时。
|
|
1253
|
-
const initial = current()
|
|
1254
|
-
const sessionQuery = ctx.get('sessionQuery') as SwitchSessionQuery | undefined
|
|
1255
|
-
const archiveReader = createArchiveSource(() => ctx.get('workspaceRegistry') as SwitchWorkspaceRegistry | undefined)
|
|
1256
|
-
const indexLayout: SwitchIndexLayout = {
|
|
1257
|
-
...DEFAULT_INDEX_LAYOUT,
|
|
1258
|
-
dir: resolveIndexDir(initial.indexDir || process.env[INDEX_DIR_ENV]),
|
|
1259
|
-
}
|
|
1260
|
-
const engine = new SwitchIndexEngine({ path: `${indexLayout.dir}/${indexLayout.active}` })
|
|
1261
|
-
// 文件级兜底读取器:health 侧的多帧 zstd 读取器直接读转录文件,映射成
|
|
1262
|
-
// SwitchRawEvent 流(version 置 0 —— 语料头版本由调用方钉,不依赖文件)。
|
|
1263
|
-
const readSessionFromFile = async (sessionId: string): Promise<{
|
|
1264
|
-
session: { id: string; version: number; createdAt?: number; cwd?: string }
|
|
1265
|
-
events: readonly SwitchRawEvent[]
|
|
1266
|
-
} | undefined> => {
|
|
1267
|
-
try {
|
|
1268
|
-
const logPath = findSessionLog(sessionId, resolvedHome)
|
|
1269
|
-
if (logPath === undefined) return undefined
|
|
1270
|
-
const decoded = decodeSessionLogFile(logPath)
|
|
1271
|
-
return {
|
|
1272
|
-
session: { id: sessionId, version: 0 },
|
|
1273
|
-
events: decoded.events.map(event => ({
|
|
1274
|
-
seq: event.seq,
|
|
1275
|
-
type: event.type,
|
|
1276
|
-
time: event.time,
|
|
1277
|
-
surfaceOp: event.surfaceOp,
|
|
1278
|
-
data: event.data,
|
|
1279
|
-
})),
|
|
1280
|
-
}
|
|
1281
|
-
} catch (err) {
|
|
1282
|
-
log(`file fallback read failed for ${sessionId}: ${String(err instanceof Error ? err.message : err)}`)
|
|
1283
|
-
return undefined
|
|
1284
|
-
}
|
|
838
|
+
// 管理台语料:宿主 sessionQuery(惰性取,装载序不保证)。
|
|
839
|
+
sessionQuery: ctx.get('sessionQuery') as SwitchSessionQuery | undefined,
|
|
1285
840
|
}
|
|
1286
841
|
|
|
1287
|
-
// 投影缓存标题第三源:官方逐会话缓存文件 record.rows.title.val(与管家
|
|
1288
|
-
// history 子域同一形状;读不到/畸形一律空串,绝不抛进同步)。
|
|
1289
|
-
const readProjectionTitle = (sessionId: string): string => {
|
|
1290
|
-
try {
|
|
1291
|
-
const path = join(resolvedHome, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`)
|
|
1292
|
-
if (!existsSync(path)) return ''
|
|
1293
|
-
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
1294
|
-
record?: { rows?: { title?: { val?: unknown } } }
|
|
1295
|
-
}
|
|
1296
|
-
const value = parsed.record?.rows?.title?.val
|
|
1297
|
-
return typeof value === 'string' ? value : ''
|
|
1298
|
-
} catch {
|
|
1299
|
-
return ''
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// DRS 语料源(T7.1):corpusSource='drs' 时索引语料改从 dsh-reference-session-collector
|
|
1304
|
-
// 投影库取(docs 直插引擎);DRS 不可达时同步轮内部整轮回退原生
|
|
1305
|
-
// sessionQuery + 文件兜底。装配在 apply 期一次性成形——corpusSource 切换
|
|
1306
|
-
// 重启后生效(水位语义切换本就伴随一轮全量重灌)。Host 头回显首个
|
|
1307
|
-
// trustedHosts 条目:DRS 信任门是精确匹配,条目省略端口时 URL 带端口会失配。
|
|
1308
|
-
const trustedHosts = webRuntime?.trustedHosts
|
|
1309
|
-
const drsSource = new DrsCorpusSource({
|
|
1310
|
-
baseUrl: resolveDrsBaseUrl(initial.drsBaseUrl, trustedHosts),
|
|
1311
|
-
hostHeader: trustedHosts !== undefined && trustedHosts.length > 0 ? trustedHosts[0] : undefined,
|
|
1312
|
-
log,
|
|
1313
|
-
})
|
|
1314
|
-
|
|
1315
|
-
const indexState: SwitchIndexServiceState = {
|
|
1316
|
-
engine,
|
|
1317
|
-
archiveReader,
|
|
1318
|
-
sync: new SwitchWatermarkSync(engine, {
|
|
1319
|
-
listSessions: () => sessionQuery?.listSessions() ?? Promise.resolve([]),
|
|
1320
|
-
readSession: async (sessionId: string) => {
|
|
1321
|
-
if (sessionQuery?.readSession === undefined) throw new Error('sessionQuery.readSession 不可用')
|
|
1322
|
-
return sessionQuery.readSession(sessionId)
|
|
1323
|
-
},
|
|
1324
|
-
readTitleSnapshots: sessionQuery === undefined
|
|
1325
|
-
? undefined
|
|
1326
|
-
: (ids) => sessionQuery.readTitleSnapshots(ids),
|
|
1327
|
-
}, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle, initial.corpusSource === 'native' ? undefined : drsSource),
|
|
1328
|
-
layout: indexLayout,
|
|
1329
|
-
rebuild: { state: 'idle', done: 0, total: 0, startedAt: 0, finishedAt: 0, failures: [] },
|
|
1330
|
-
}
|
|
1331
|
-
const srt: SearchRuntime = {
|
|
1332
|
-
sessionQuery,
|
|
1333
|
-
index: indexState,
|
|
1334
|
-
drsSource,
|
|
1335
|
-
config: () => current(),
|
|
1336
|
-
registry: () => ({ archivedSessionIds: archiveReader.read().ids }),
|
|
1337
|
-
log,
|
|
1338
|
-
readSessionFromFile,
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
const initialConfig = current()
|
|
1342
|
-
if (initialConfig.enabled === false) {
|
|
1343
|
-
log('enabled=false:不注册任何路由')
|
|
1344
|
-
return
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
let syncTimer: ReturnType<typeof setInterval> | undefined
|
|
1348
|
-
const scheduleSync = (intervalMs: number): void => {
|
|
1349
|
-
if (syncTimer !== undefined) clearInterval(syncTimer)
|
|
1350
|
-
if (intervalMs <= 0) return
|
|
1351
|
-
syncTimer = setInterval(() => {
|
|
1352
|
-
const latest = current()
|
|
1353
|
-
if (latest.enabled === false || latest.search === false || latest.autoSync === false) return
|
|
1354
|
-
void indexState.sync.poll().catch(() => {})
|
|
1355
|
-
}, Math.max(5_000, intervalMs))
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// Realtime titles. A rename appends the log-only `session/title` event, which
|
|
1359
|
-
// reaches the index one poll later (default 30s). Folding the title straight
|
|
1360
|
-
// off the append feed removes that latency without a full pass. The listener
|
|
1361
|
-
// is deliberately trivial — this feed carries EVERY appended event, streaming
|
|
1362
|
-
// chunks included — and never throws, because it runs inside the host's
|
|
1363
|
-
// fire-and-forget append publication.
|
|
1364
|
-
let pendingTitleIds = new Set<string>()
|
|
1365
|
-
let titleTimer: ReturnType<typeof setTimeout> | undefined
|
|
1366
|
-
const flushPendingTitles = (): void => {
|
|
1367
|
-
titleTimer = undefined
|
|
1368
|
-
const ids = [...pendingTitleIds]
|
|
1369
|
-
pendingTitleIds = new Set()
|
|
1370
|
-
if (ids.length === 0) return
|
|
1371
|
-
void indexState.sync.refreshTitles(ids).catch(() => {})
|
|
1372
|
-
}
|
|
1373
|
-
ctx.effect(() => {
|
|
1374
|
-
const bus = ctx as unknown as {
|
|
1375
|
-
on?: (name: string, listener: (session: { id?: unknown }, event: { type?: unknown }) => void) => () => void
|
|
1376
|
-
}
|
|
1377
|
-
if (typeof bus.on !== 'function') return () => {}
|
|
1378
|
-
try {
|
|
1379
|
-
return bus.on('session/event', (session, event) => {
|
|
1380
|
-
if (event?.type !== TITLE_EVENT_TYPE) return
|
|
1381
|
-
const latest = current()
|
|
1382
|
-
if (latest.enabled === false || latest.search === false || latest.autoSync === false) return
|
|
1383
|
-
const id = session?.id
|
|
1384
|
-
if (typeof id !== 'string' || id === '') return
|
|
1385
|
-
pendingTitleIds.add(id)
|
|
1386
|
-
if (titleTimer !== undefined) return
|
|
1387
|
-
titleTimer = setTimeout(flushPendingTitles, TITLE_FLUSH_MS)
|
|
1388
|
-
})
|
|
1389
|
-
} catch {
|
|
1390
|
-
// No event bus on this host: the poll stays the fallback path.
|
|
1391
|
-
return () => {}
|
|
1392
|
-
}
|
|
1393
|
-
}, 'dsh-manage-session: realtime titles')
|
|
1394
|
-
|
|
1395
|
-
// 索引生命周期:恢复巡检 → 打开引擎 → 首轮水位同步 → 定时轮询。
|
|
1396
|
-
// search=false 时整套不启动(引擎不开、无后台任务)。
|
|
1397
|
-
if (initialConfig.search !== false) {
|
|
1398
|
-
void (async () => {
|
|
1399
|
-
try {
|
|
1400
|
-
const recovered = await recoverIndex(indexLayout, log)
|
|
1401
|
-
if (recovered.length > 0) log(`index recovery applied ${recovered.length} fix(es)`)
|
|
1402
|
-
} catch (err) {
|
|
1403
|
-
log(`index recovery failed: ${String(err instanceof Error ? err.message : err)}`)
|
|
1404
|
-
}
|
|
1405
|
-
await engine.open().catch(() => {})
|
|
1406
|
-
log(`index open: driver=${engine.driverLabel} dir=${indexLayout.dir}`)
|
|
1407
|
-
if (engine.driverLabel === 'node:sqlite') {
|
|
1408
|
-
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')
|
|
1409
|
-
}
|
|
1410
|
-
if (initialConfig.autoSync !== false) await indexState.sync.poll().catch(() => {})
|
|
1411
|
-
scheduleSync(initialConfig.syncIntervalMs ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs)
|
|
1412
|
-
})()
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
|
-
ctx.effect(() => () => {
|
|
1416
|
-
if (syncTimer !== undefined) clearInterval(syncTimer)
|
|
1417
|
-
if (titleTimer !== undefined) clearTimeout(titleTimer)
|
|
1418
|
-
engine.close()
|
|
1419
|
-
}, 'dsh-manage-session: index lifecycle')
|
|
1420
|
-
|
|
1421
842
|
// ── 管家子域路由。
|
|
1422
843
|
ctx.effect(() => webServer.register({
|
|
1423
844
|
kind: 'prefix',
|
|
@@ -1435,7 +856,7 @@ export function apply(ctx: Context, config: Partial<StewardConfig & SwitchSearch
|
|
|
1435
856
|
const prefix = `${STEWARD_API_PREFIX}/`
|
|
1436
857
|
const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined
|
|
1437
858
|
if (method === undefined || method === '' || method.includes('/')) {
|
|
1438
|
-
writeJson(res, 404, { ok: false, error: `unknown session
|
|
859
|
+
writeJson(res, 404, { ok: false, error: `unknown manage-session API method` })
|
|
1439
860
|
return
|
|
1440
861
|
}
|
|
1441
862
|
try {
|
|
@@ -1446,54 +867,5 @@ export function apply(ctx: Context, config: Partial<StewardConfig & SwitchSearch
|
|
|
1446
867
|
writeJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
1447
868
|
}
|
|
1448
869
|
},
|
|
1449
|
-
}), 'dsh-manage-session: /session
|
|
1450
|
-
|
|
1451
|
-
// ── 搜索子域路由(历史前缀 /switch-search/api)。
|
|
1452
|
-
ctx.effect(() => webServer.register({
|
|
1453
|
-
kind: 'prefix',
|
|
1454
|
-
path: SWITCH_API_PREFIX,
|
|
1455
|
-
handler: async (req: IncomingMessage, res: ServerResponse) => {
|
|
1456
|
-
if (!isTrustedApiRequest(req, webRuntime.trustedHosts)) {
|
|
1457
|
-
writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
1458
|
-
return
|
|
1459
|
-
}
|
|
1460
|
-
if (req.method !== 'POST') {
|
|
1461
|
-
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
1462
|
-
return
|
|
1463
|
-
}
|
|
1464
|
-
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
1465
|
-
const prefix = `${SWITCH_API_PREFIX}/`
|
|
1466
|
-
const method = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined
|
|
1467
|
-
if (method === undefined || method === '' || method.includes('/')) {
|
|
1468
|
-
writeJson(res, 404, { ok: false, error: 'unknown switch-search API method' })
|
|
1469
|
-
return
|
|
1470
|
-
}
|
|
1471
|
-
// 墓碑:这两个方法在分包时代属搜索索引包,合并后已归 history 子域。
|
|
1472
|
-
// 旧 client bundle 必须响亮失败并指路,静默 404 会被读成「归档坏了」。
|
|
1473
|
-
if (method in MOVED_TO_HISTORY) {
|
|
1474
|
-
writeJson(res, 410, movedToHistory(method))
|
|
1475
|
-
return
|
|
1476
|
-
}
|
|
1477
|
-
try {
|
|
1478
|
-
if (methodEnabled(method, current()) === false) {
|
|
1479
|
-
writeJson(res, 200, { ok: false, error: `子域已关闭(search=false):${method} 未注册` })
|
|
1480
|
-
return
|
|
1481
|
-
}
|
|
1482
|
-
if (method === 'index-export') {
|
|
1483
|
-
await indexExport(srt, res)
|
|
1484
|
-
return
|
|
1485
|
-
}
|
|
1486
|
-
if (method === 'index-import') {
|
|
1487
|
-
// The body is raw JSON Lines (or a { snapshot } envelope), not JSON.
|
|
1488
|
-
const text = await readRawBody(req)
|
|
1489
|
-
writeJson(res, 200, await indexImport(srt, text))
|
|
1490
|
-
return
|
|
1491
|
-
}
|
|
1492
|
-
const payload = await readJsonBody(req)
|
|
1493
|
-
writeJson(res, 200, await handleIndexMethod(method, payload, srt, String(req.headers.host ?? '')))
|
|
1494
|
-
} catch (err) {
|
|
1495
|
-
writeJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
1496
|
-
}
|
|
1497
|
-
},
|
|
1498
|
-
}), 'dsh-manage-session: /switch-search/api route')
|
|
870
|
+
}), 'dsh-manage-session: /manage-session/api route')
|
|
1499
871
|
}
|