dsh-manage-session 0.5.4 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +22 -0
- package/CHANGELOG.ko.md +22 -0
- package/CHANGELOG.md +22 -0
- package/README.en.md +13 -11
- package/README.md +11 -10
- package/dsh.plugin.json +2 -2
- package/lib/client.js +900 -386
- package/lib/index.d.ts +241 -16
- package/lib/index.js +526 -71
- package/package.json +15 -9
- package/src/client/card.tsx +14 -10
- package/src/client/index.ts +53 -15
- package/src/client/locales.ts +36 -0
- package/src/client/panel.tsx +26 -36
- package/src/client/search/host-api.ts +5 -0
- package/src/client/search/locales.ts +81 -0
- package/src/client/search/locate.ts +330 -0
- package/src/client/search/manage.tsx +6 -2
- package/src/client/search/panel.tsx +154 -29
- package/src/client/session-menu-legacy.ts +249 -0
- package/src/client/session-menu.tsx +96 -0
- package/src/config.ts +17 -0
- package/src/host/history/tombstones.ts +100 -0
- package/src/host/index/drs-source.ts +211 -0
- package/src/host/index/engine.ts +699 -571
- package/src/host/index/rebuild.ts +432 -368
- package/src/host/index/sync.ts +413 -286
- package/src/host/settings-bridge.ts +27 -0
- package/src/index.ts +1499 -1421
- package/src/client/history-panel.tsx +0 -319
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import zlib from "node:zlib";
|
|
@@ -11,7 +11,8 @@ const DEFAULT_CONFIG = {
|
|
|
11
11
|
enabled: true,
|
|
12
12
|
historyFiles: true,
|
|
13
13
|
healthCheck: true,
|
|
14
|
-
search: true
|
|
14
|
+
search: true,
|
|
15
|
+
directDelete: false
|
|
15
16
|
};
|
|
16
17
|
/** host 半身注册的设置命名空间(与 src/index.ts 保持一致)。 */
|
|
17
18
|
const STEWARD_SETTINGS_NAMESPACE = "session-steward";
|
|
@@ -29,7 +30,9 @@ const SWITCH_DEFAULT_CONFIG = {
|
|
|
29
30
|
autoSync: true,
|
|
30
31
|
syncIntervalMs: 3e4,
|
|
31
32
|
archiveKeep: 2,
|
|
32
|
-
indexDir: ""
|
|
33
|
+
indexDir: "",
|
|
34
|
+
corpusSource: "native",
|
|
35
|
+
drsBaseUrl: ""
|
|
33
36
|
};
|
|
34
37
|
//#endregion
|
|
35
38
|
//#region src/host/history/archive-source.ts
|
|
@@ -1383,6 +1386,59 @@ function setFavoriteState(sessionId, favorite, searchPaths) {
|
|
|
1383
1386
|
}
|
|
1384
1387
|
}
|
|
1385
1388
|
//#endregion
|
|
1389
|
+
//#region src/host/history/tombstones.ts
|
|
1390
|
+
/**
|
|
1391
|
+
* 退场墓碑台账(2026-10-20 用户定标):任何 purge 成功路径都往
|
|
1392
|
+
* `~/.dsh-switch-search/retired-tombstones.jsonl` 追加一行
|
|
1393
|
+
* `{sessionId, retiredAt, source}`,作为离线清残脚本
|
|
1394
|
+
* (scripts/cleanup-retired.mjs)的驱动清单。
|
|
1395
|
+
*
|
|
1396
|
+
* 为什么需要:宿主 `registry.deleteSession` 与 `purgeHistory` 各自覆盖 4 处
|
|
1397
|
+
* 实体(转录目录/projcache/workspace.json 两处 id),但任一路径中途失败、
|
|
1398
|
+
* 索引联动(onPurged)进程崩溃、或 collector 同步轮未跑,都会留下残行/残
|
|
1399
|
+
* 目录。墓碑 = 「这个 id 已被判退场」的持久凭证,DSH 关闭后脚本据此兜底
|
|
1400
|
+
* 清扫,不靠现场推断。
|
|
1401
|
+
*
|
|
1402
|
+
* 纪律:
|
|
1403
|
+
* - **append-only、best-effort**:写台账失败绝不影响删除结果返回(try/catch
|
|
1404
|
+
* 吞错并 log),台账是副账不是主账;
|
|
1405
|
+
* - 每行一个 JSON(JSONL),读侧跳过畸形行——半行(进程崩在 append 中间)
|
|
1406
|
+
* 不致命;
|
|
1407
|
+
* - 幂等消费:脚本按 id 集合去重,重复墓碑无害;台账只增不删(体积可忽略,
|
|
1408
|
+
* 每行 ~120B)。
|
|
1409
|
+
*/
|
|
1410
|
+
/** 台账文件名(固定;脚本以默认路径发现)。 */
|
|
1411
|
+
const TOMBSTONE_FILE = "retired-tombstones.jsonl";
|
|
1412
|
+
/** 默认台账目录 = 索引缺省落点(与 rebuild.ts 的 ~/.dsh-switch-search 同根)。 */
|
|
1413
|
+
function defaultTombstoneDir() {
|
|
1414
|
+
return join(homedir(), ".dsh-switch-search");
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* 追加一批墓碑(一次 purge 成功的 id 集)。best-effort:目录不可建/盘不可写
|
|
1418
|
+
* 只经 `log` 报一行,绝不抛——删除动作的成败判定先于本调用完成。
|
|
1419
|
+
* @returns 实际写出的行数(0 = 静默失败或空入参)。
|
|
1420
|
+
*/
|
|
1421
|
+
function appendRetiredTombstones(sessionIds, opts) {
|
|
1422
|
+
const ids = [...new Set(sessionIds.filter((id) => typeof id === "string" && id !== ""))];
|
|
1423
|
+
if (ids.length === 0) return 0;
|
|
1424
|
+
const dir = opts.dir ?? defaultTombstoneDir();
|
|
1425
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
1426
|
+
try {
|
|
1427
|
+
mkdirSync(dir, { recursive: true });
|
|
1428
|
+
const stamp = now().toISOString();
|
|
1429
|
+
const lines = ids.map((id) => JSON.stringify({
|
|
1430
|
+
sessionId: id,
|
|
1431
|
+
retiredAt: stamp,
|
|
1432
|
+
source: opts.source
|
|
1433
|
+
}) + "\n");
|
|
1434
|
+
appendFileSync(join(dir, TOMBSTONE_FILE), lines.join(""), "utf8");
|
|
1435
|
+
return lines.length;
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
opts.log?.(`tombstone append failed (deletion itself succeeded): ${String(err instanceof Error ? err.message : err)}`);
|
|
1438
|
+
return 0;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
//#endregion
|
|
1386
1442
|
//#region src/host/settings-bridge.ts
|
|
1387
1443
|
/**
|
|
1388
1444
|
* Legacy 设置桥(宿主半身,≤0.1.5 专用):老宿主的服务端 settings 服务保留
|
|
@@ -1407,7 +1463,8 @@ const StewardNsSchema = z.object({
|
|
|
1407
1463
|
enabled: z.boolean().default(true),
|
|
1408
1464
|
historyFiles: z.boolean().default(true),
|
|
1409
1465
|
healthCheck: z.boolean().default(true),
|
|
1410
|
-
search: z.boolean().default(true)
|
|
1466
|
+
search: z.boolean().default(true),
|
|
1467
|
+
directDelete: z.boolean().default(false)
|
|
1411
1468
|
});
|
|
1412
1469
|
/** 搜索命名空间 schema(同上;存储键与 config.ts 承诺一致)。 */
|
|
1413
1470
|
const SwitchNsSchema = z.object({
|
|
@@ -1491,7 +1548,8 @@ const FIELD_VALIDATORS = {
|
|
|
1491
1548
|
enabled: asBoolean,
|
|
1492
1549
|
historyFiles: asBoolean,
|
|
1493
1550
|
healthCheck: asBoolean,
|
|
1494
|
-
search: asBoolean
|
|
1551
|
+
search: asBoolean,
|
|
1552
|
+
directDelete: asBoolean
|
|
1495
1553
|
},
|
|
1496
1554
|
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
1497
1555
|
enabled: asBoolean,
|
|
@@ -1558,6 +1616,17 @@ function describeNamespaces(state) {
|
|
|
1558
1616
|
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {}
|
|
1559
1617
|
};
|
|
1560
1618
|
}
|
|
1619
|
+
function bridgeOverlay(state) {
|
|
1620
|
+
const { bridge } = state;
|
|
1621
|
+
if (bridge === void 0) return {
|
|
1622
|
+
steward: {},
|
|
1623
|
+
search: {}
|
|
1624
|
+
};
|
|
1625
|
+
return {
|
|
1626
|
+
steward: bridge.get("session-steward") ?? {},
|
|
1627
|
+
search: bridge.get("switch-search") ?? {}
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1561
1630
|
/** describe 响应。 */
|
|
1562
1631
|
function settingsDescribe(state) {
|
|
1563
1632
|
return {
|
|
@@ -3176,7 +3245,7 @@ var SwitchIndexEngine = class {
|
|
|
3176
3245
|
upsertSession(input) {
|
|
3177
3246
|
const db = this.requireDb();
|
|
3178
3247
|
const documents = buildIndexDocuments(input.sessionId, input.events);
|
|
3179
|
-
this.withWriteTx(() => {
|
|
3248
|
+
return this.withWriteTx(() => {
|
|
3180
3249
|
this.deleteSessionFts(db, input.sessionId);
|
|
3181
3250
|
db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
|
|
3182
3251
|
const insertDoc = db.prepare(`
|
|
@@ -3199,9 +3268,37 @@ var SwitchIndexEngine = class {
|
|
|
3199
3268
|
updated_at = excluded.updated_at,
|
|
3200
3269
|
indexed_at = excluded.indexed_at
|
|
3201
3270
|
`).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
|
|
3271
|
+
return documents.length;
|
|
3202
3272
|
});
|
|
3203
3273
|
}
|
|
3204
3274
|
/**
|
|
3275
|
+
* 该会话是否已有正文(issue #1:hollow 行判据)。
|
|
3276
|
+
* LIMIT 1 探测命中 docs_session 索引,每轮 poll 对每个未变更会话的开销可忽略。
|
|
3277
|
+
*/
|
|
3278
|
+
hasSessionDocs(sessionId) {
|
|
3279
|
+
return this.requireDb().prepare("SELECT 1 FROM docs WHERE session_id = ? LIMIT 1").get(sessionId) !== void 0;
|
|
3280
|
+
}
|
|
3281
|
+
/**
|
|
3282
|
+
* 「有表头无正文」的会话 id(升序)—— hollow 体检面。补录后仍 hollow 的行
|
|
3283
|
+
* 包含无可索引文本的真空会话(合法终态,见 sync 的 confirmedEmpty 缓存)。
|
|
3284
|
+
*/
|
|
3285
|
+
listHollowSessions() {
|
|
3286
|
+
return this.requireDb().prepare(`
|
|
3287
|
+
SELECT s.session_id FROM sessions s
|
|
3288
|
+
WHERE NOT EXISTS (SELECT 1 FROM docs d WHERE d.session_id = s.session_id)
|
|
3289
|
+
ORDER BY s.session_id
|
|
3290
|
+
`).all().map((row) => row.session_id);
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* `PRAGMA integrity_check` 首行:'ok' 或损坏报告串。swap 后完整性闸门用。
|
|
3294
|
+
*/
|
|
3295
|
+
integrityCheck() {
|
|
3296
|
+
const row = this.requireDb().prepare("PRAGMA integrity_check").get();
|
|
3297
|
+
if (row === void 0) return "no result";
|
|
3298
|
+
const first = Object.values(row)[0];
|
|
3299
|
+
return String(first ?? "no result");
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3205
3302
|
* Write an archived session's header row without any document content.
|
|
3206
3303
|
*
|
|
3207
3304
|
* @deprecated 旧"归档即软删"语义的遗物,合并包 R1 已改为归档保留 docs;仅剩
|
|
@@ -3289,6 +3386,65 @@ var SwitchIndexEngine = class {
|
|
|
3289
3386
|
`).run(input.sessionId, input.version, input.title ?? "", Date.now(), input.archived === true ? 1 : 0);
|
|
3290
3387
|
});
|
|
3291
3388
|
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Insert or replace one session from corpus-source documents (T7.1 DRS
|
|
3391
|
+
* 语料源):docs 已由语料源抽好(surface 字符串),切分仍由本引擎重算。
|
|
3392
|
+
* 与 importSessionDocs 的三点差异,均为检索排序/水印所需:cwd 与
|
|
3393
|
+
* updated_at 直写(日期过滤/时间排序键);标题空串保留旧行(upsertSession
|
|
3394
|
+
* 同款 CASE——DRS 对老会话可能给不出标题,不能抹掉既有精修);归档旗标
|
|
3395
|
+
* 冲突时不动(翻转仍归 setArchived)。
|
|
3396
|
+
* @returns 落库 docs 数(0 = 该会话无可索引文本,合法终态)。
|
|
3397
|
+
*/
|
|
3398
|
+
upsertSessionDocs(input) {
|
|
3399
|
+
const db = this.requireDb();
|
|
3400
|
+
return this.withWriteTx(() => {
|
|
3401
|
+
this.deleteSessionFts(db, input.sessionId);
|
|
3402
|
+
db.prepare("DELETE FROM docs WHERE session_id = ?").run(input.sessionId);
|
|
3403
|
+
const insertDoc = db.prepare(`
|
|
3404
|
+
INSERT INTO docs (session_id, seq, type, surface, time, text, index_text)
|
|
3405
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3406
|
+
`);
|
|
3407
|
+
const insertFts = db.prepare("INSERT INTO docs_fts (rowid, index_text) VALUES (?, ?)");
|
|
3408
|
+
for (const doc of input.docs) {
|
|
3409
|
+
const indexText = segmentForIndex(doc.text);
|
|
3410
|
+
const result = insertDoc.run(input.sessionId, doc.seq, doc.type, doc.surface, doc.time, doc.text, indexText);
|
|
3411
|
+
insertFts.run(Number(result.lastInsertRowid), indexText);
|
|
3412
|
+
}
|
|
3413
|
+
db.prepare(`
|
|
3414
|
+
INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
|
|
3415
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3416
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3417
|
+
version = excluded.version,
|
|
3418
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
3419
|
+
cwd = excluded.cwd,
|
|
3420
|
+
updated_at = excluded.updated_at,
|
|
3421
|
+
indexed_at = excluded.indexed_at
|
|
3422
|
+
`).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
|
|
3423
|
+
return input.docs.length;
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3427
|
+
* 会话头直写(DRS 直连时代的同步轮专用,T7.3):只维护 sessions 元数据
|
|
3428
|
+
* (水位/标题/目录/末时间/归档),**不碰 docs/docs_fts**——内容检索
|
|
3429
|
+
* 已直连 dsh-reference-session-collector(op=search&group=session),本地正文索引
|
|
3430
|
+
* 退役,存量 docs 行自然老化不再新增。空标题保留旧行(同 upsertSessionDocs)。
|
|
3431
|
+
*/
|
|
3432
|
+
upsertSessionHead(input) {
|
|
3433
|
+
const db = this.requireDb();
|
|
3434
|
+
this.withWriteTx(() => {
|
|
3435
|
+
db.prepare(`
|
|
3436
|
+
INSERT INTO sessions (session_id, version, title, cwd, updated_at, indexed_at, archived)
|
|
3437
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3438
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3439
|
+
version = excluded.version,
|
|
3440
|
+
title = CASE WHEN excluded.title != '' THEN excluded.title ELSE sessions.title END,
|
|
3441
|
+
cwd = excluded.cwd,
|
|
3442
|
+
updated_at = excluded.updated_at,
|
|
3443
|
+
indexed_at = excluded.indexed_at,
|
|
3444
|
+
archived = excluded.archived
|
|
3445
|
+
`).run(input.sessionId, input.version, input.title ?? "", input.cwd ?? "", input.updatedAt ?? 0, Date.now(), input.archived === true ? 1 : 0);
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3292
3448
|
/** Remove one session and its documents entirely. */
|
|
3293
3449
|
removeSession(sessionId) {
|
|
3294
3450
|
const db = this.requireDb();
|
|
@@ -3435,34 +3591,46 @@ const TITLE_CHUNK = 50;
|
|
|
3435
3591
|
* One watermark syncer bound to one open engine. `poll()` is re-entrant-safe:
|
|
3436
3592
|
* overlapping calls collapse into the running pass.
|
|
3437
3593
|
*/
|
|
3438
|
-
var SwitchWatermarkSync = class {
|
|
3594
|
+
var SwitchWatermarkSync = class SwitchWatermarkSync {
|
|
3439
3595
|
engine;
|
|
3440
3596
|
sessionQuery;
|
|
3441
3597
|
readArchiveSource;
|
|
3442
3598
|
log;
|
|
3443
3599
|
readSessionFromFile;
|
|
3444
3600
|
readProjectionTitle;
|
|
3601
|
+
drsSource;
|
|
3445
3602
|
running;
|
|
3603
|
+
/**
|
|
3604
|
+
* 本进程已确认「重读过且产出仍为 0 docs」的会话(键 id@version)。
|
|
3605
|
+
* 无可索引文本是合法终态——没有这份缓存,hollow 补录判据会把真空会话
|
|
3606
|
+
* 每轮完整重读一次(热循环)。宿主重启后缓存归零,最多各重读一次。
|
|
3607
|
+
*/
|
|
3608
|
+
confirmedEmpty = /* @__PURE__ */ new Set();
|
|
3609
|
+
static emptyKey(sessionId, version) {
|
|
3610
|
+
return `${sessionId}@${String(version)}`;
|
|
3611
|
+
}
|
|
3446
3612
|
state = {
|
|
3447
3613
|
state: "idle",
|
|
3448
3614
|
lastSyncAt: 0,
|
|
3449
3615
|
indexed: 0,
|
|
3450
3616
|
total: 0,
|
|
3451
3617
|
updated: 0,
|
|
3452
|
-
failures: []
|
|
3618
|
+
failures: [],
|
|
3619
|
+
hollow: 0
|
|
3453
3620
|
};
|
|
3454
3621
|
/**
|
|
3455
3622
|
* @param readSessionFromFile - 可选的**文件级兜底读取器**(管家 health 侧的
|
|
3456
3623
|
* 多帧 zstd 读取器):sessionQuery.readSession 对某些会话(典型:归档会话,
|
|
3457
3624
|
* 或服务面退化)失败时改读转录文件。返回 undefined 表示文件也不可用。
|
|
3458
3625
|
*/
|
|
3459
|
-
constructor(engine, sessionQuery, readArchiveSource, log, readSessionFromFile, readProjectionTitle) {
|
|
3626
|
+
constructor(engine, sessionQuery, readArchiveSource, log, readSessionFromFile, readProjectionTitle, drsSource) {
|
|
3460
3627
|
this.engine = engine;
|
|
3461
3628
|
this.sessionQuery = sessionQuery;
|
|
3462
3629
|
this.readArchiveSource = readArchiveSource;
|
|
3463
3630
|
this.log = log;
|
|
3464
3631
|
this.readSessionFromFile = readSessionFromFile;
|
|
3465
3632
|
this.readProjectionTitle = readProjectionTitle;
|
|
3633
|
+
this.drsSource = drsSource;
|
|
3466
3634
|
}
|
|
3467
3635
|
/**
|
|
3468
3636
|
* 读取一个会话的日志:服务面优先,失败落文件兜底。两条路都失败时抛最后
|
|
@@ -3516,6 +3684,11 @@ var SwitchWatermarkSync = class {
|
|
|
3516
3684
|
this.state.state = "syncing";
|
|
3517
3685
|
const passStart = Date.now();
|
|
3518
3686
|
try {
|
|
3687
|
+
if (this.drsSource !== void 0) try {
|
|
3688
|
+
return await this.runDrsPass(passStart);
|
|
3689
|
+
} catch (drsError) {
|
|
3690
|
+
this.log?.(`DRS corpus pass failed, falling back to native sessionQuery: ${String(drsError instanceof Error ? drsError.message : drsError)}`);
|
|
3691
|
+
}
|
|
3519
3692
|
const records = await this.sessionQuery.listSessions();
|
|
3520
3693
|
this.state.total = records.length;
|
|
3521
3694
|
const archiveSource = this.readArchiveSource?.();
|
|
@@ -3527,11 +3700,12 @@ var SwitchWatermarkSync = class {
|
|
|
3527
3700
|
for (const record of records) {
|
|
3528
3701
|
const header = record.header;
|
|
3529
3702
|
const existing = this.engine.getSession(header.id);
|
|
3530
|
-
|
|
3703
|
+
const emptyKey = SwitchWatermarkSync.emptyKey(header.id, header.version);
|
|
3704
|
+
if (existing !== void 0 && existing.version === header.version && (this.confirmedEmpty.has(emptyKey) || this.engine.hasSessionDocs(header.id))) continue;
|
|
3531
3705
|
changedIds.push(header.id);
|
|
3532
3706
|
try {
|
|
3533
3707
|
const log = await this.readSessionLog(header);
|
|
3534
|
-
this.engine.upsertSession({
|
|
3708
|
+
if (this.engine.upsertSession({
|
|
3535
3709
|
sessionId: header.id,
|
|
3536
3710
|
version: header.version,
|
|
3537
3711
|
cwd: log.session.cwd ?? header.cwd ?? "",
|
|
@@ -3539,7 +3713,8 @@ var SwitchWatermarkSync = class {
|
|
|
3539
3713
|
title: extractTitleFromEvents(log.events),
|
|
3540
3714
|
events: log.events,
|
|
3541
3715
|
archived: archivedSet.has(header.id)
|
|
3542
|
-
});
|
|
3716
|
+
}) === 0) this.confirmedEmpty.add(emptyKey);
|
|
3717
|
+
else this.confirmedEmpty.delete(emptyKey);
|
|
3543
3718
|
updated += 1;
|
|
3544
3719
|
} catch (err) {
|
|
3545
3720
|
failures.push({
|
|
@@ -3549,13 +3724,14 @@ var SwitchWatermarkSync = class {
|
|
|
3549
3724
|
}
|
|
3550
3725
|
}
|
|
3551
3726
|
for (const archivedId of archivedSet) {
|
|
3552
|
-
|
|
3727
|
+
const existingArchived = this.engine.getSession(archivedId);
|
|
3728
|
+
if (existingArchived !== void 0 && (this.confirmedEmpty.has(SwitchWatermarkSync.emptyKey(archivedId, existingArchived.version)) || this.engine.hasSessionDocs(archivedId))) continue;
|
|
3553
3729
|
try {
|
|
3554
3730
|
const log = await this.readSessionLog({
|
|
3555
3731
|
id: archivedId,
|
|
3556
3732
|
version: -1
|
|
3557
3733
|
});
|
|
3558
|
-
this.engine.upsertSession({
|
|
3734
|
+
const produced = this.engine.upsertSession({
|
|
3559
3735
|
sessionId: archivedId,
|
|
3560
3736
|
version: log.session.version,
|
|
3561
3737
|
cwd: log.session.cwd ?? "",
|
|
@@ -3564,6 +3740,9 @@ var SwitchWatermarkSync = class {
|
|
|
3564
3740
|
events: log.events,
|
|
3565
3741
|
archived: true
|
|
3566
3742
|
});
|
|
3743
|
+
const archivedKey = SwitchWatermarkSync.emptyKey(archivedId, log.session.version);
|
|
3744
|
+
if (produced === 0) this.confirmedEmpty.add(archivedKey);
|
|
3745
|
+
else this.confirmedEmpty.delete(archivedKey);
|
|
3567
3746
|
updated += 1;
|
|
3568
3747
|
} catch (err) {
|
|
3569
3748
|
failures.push({
|
|
@@ -3596,10 +3775,11 @@ var SwitchWatermarkSync = class {
|
|
|
3596
3775
|
this.state.updated = updated;
|
|
3597
3776
|
this.state.failures = failures;
|
|
3598
3777
|
this.state.indexed = this.engine.countSessions();
|
|
3778
|
+
this.state.hollow = this.engine.listHollowSessions().length;
|
|
3599
3779
|
this.state.lastSyncAt = Date.now();
|
|
3600
3780
|
this.state.state = "idle";
|
|
3601
3781
|
this.state.error = void 0;
|
|
3602
|
-
this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
|
|
3782
|
+
this.log?.(`sync pass: scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} hollow=${this.state.hollow} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
|
|
3603
3783
|
} catch (err) {
|
|
3604
3784
|
this.state.state = "error";
|
|
3605
3785
|
this.state.error = String(err instanceof Error ? err.message : err);
|
|
@@ -3608,6 +3788,55 @@ var SwitchWatermarkSync = class {
|
|
|
3608
3788
|
return this.snapshot();
|
|
3609
3789
|
}
|
|
3610
3790
|
/**
|
|
3791
|
+
* DRS 语料轮(T7.1):语料头从 DRS `op=sessions` 分页拉取,水位
|
|
3792
|
+
* diff(version=artifact_mtime_ms)后按会话 `op=session&fields=search`
|
|
3793
|
+
* 取检索臂 docs 直插引擎(upsertSessionDocs)。归档集合改用 DRS 行的
|
|
3794
|
+
* archived 旗标(DRS sync 本就只读跟随 workspace.json,同源);标题直接
|
|
3795
|
+
* 采 DRS 行(投影侧同一 extractTitleFromEvents 视界),不再走快照精修。
|
|
3796
|
+
* 每会话失败隔离语义与原生轮一致;无可索引文本仍进 confirmedEmpty 缓存。
|
|
3797
|
+
*/
|
|
3798
|
+
async runDrsPass(passStart) {
|
|
3799
|
+
const source = this.drsSource;
|
|
3800
|
+
if (source === void 0) throw new Error("DRS corpus source unavailable");
|
|
3801
|
+
const heads = await source.listSessions();
|
|
3802
|
+
this.state.total = heads.length;
|
|
3803
|
+
const archivedSet = new Set(heads.filter((head) => head.archived).map((head) => head.sessionId));
|
|
3804
|
+
this.engine.setArchived(archivedSet);
|
|
3805
|
+
const failures = [];
|
|
3806
|
+
let updated = 0;
|
|
3807
|
+
for (const head of heads) {
|
|
3808
|
+
const existing = this.engine.getSession(head.sessionId);
|
|
3809
|
+
if (existing !== void 0 && existing.version === head.version) continue;
|
|
3810
|
+
try {
|
|
3811
|
+
this.engine.upsertSessionHead({
|
|
3812
|
+
sessionId: head.sessionId,
|
|
3813
|
+
version: head.version,
|
|
3814
|
+
title: head.title,
|
|
3815
|
+
cwd: head.cwd,
|
|
3816
|
+
updatedAt: head.updatedAt,
|
|
3817
|
+
archived: head.archived
|
|
3818
|
+
});
|
|
3819
|
+
updated += 1;
|
|
3820
|
+
} catch (err) {
|
|
3821
|
+
failures.push({
|
|
3822
|
+
sessionId: head.sessionId,
|
|
3823
|
+
error: `drs: ${String(err instanceof Error ? err.message : err)}`
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
const corpusIds = new Set(heads.map((head) => head.sessionId));
|
|
3828
|
+
for (const indexed of this.engine.listIndexedSessions()) if (!corpusIds.has(indexed.sessionId) && !archivedSet.has(indexed.sessionId)) this.engine.removeSession(indexed.sessionId);
|
|
3829
|
+
this.state.updated = updated;
|
|
3830
|
+
this.state.failures = failures;
|
|
3831
|
+
this.state.indexed = this.engine.countSessions();
|
|
3832
|
+
this.state.hollow = this.engine.listHollowSessions().length;
|
|
3833
|
+
this.state.lastSyncAt = Date.now();
|
|
3834
|
+
this.state.state = "idle";
|
|
3835
|
+
this.state.error = void 0;
|
|
3836
|
+
this.log?.(`sync pass(DRS): scanned=${this.state.total} updated=${updated} archived=${archivedSet.size} failures=${failures.length} indexed=${this.state.indexed} hollow=${this.state.hollow} duration=${Date.now() - passStart}ms driver=${this.engine.driverLabel}`);
|
|
3837
|
+
return this.snapshot();
|
|
3838
|
+
}
|
|
3839
|
+
/**
|
|
3611
3840
|
* Fold latest titles for changed sessions into the index header rows.
|
|
3612
3841
|
*
|
|
3613
3842
|
* beta.4 教训:整批一次调用,任何一个坏 id(典型:归档 id)让整个 promise
|
|
@@ -3769,6 +3998,145 @@ function detectSteward() {
|
|
|
3769
3998
|
return probePeer(STEWARD_PACKAGE);
|
|
3770
3999
|
}
|
|
3771
4000
|
//#endregion
|
|
4001
|
+
//#region src/host/index/drs-source.ts
|
|
4002
|
+
/**
|
|
4003
|
+
* DRS 语料源:dsh-reference-session-collector 投影库的 HTTP 只读客户端(T7.1)。
|
|
4004
|
+
*
|
|
4005
|
+
* 语料契约 = DRS 单条 fenced 路由 `GET /api/dsh-reference-session-collector`:
|
|
4006
|
+
* - `op=sessions&offset=&limit=` → `{ok, total, rows:[sessions 行]}`(分页);
|
|
4007
|
+
* - `op=session&session=<id>&fields=search` → `{ok, session, rows:[检索臂]}`。
|
|
4008
|
+
*
|
|
4009
|
+
* 字段对照(T7.0,DRS wire snake → steward 引擎列):
|
|
4010
|
+
* sessions.version ← artifact_mtime_ms(水位代理:日志重写必 bump mtime);
|
|
4011
|
+
* sessions.updated_at ← last_time_ms;title/cwd/archived ← 同名列;
|
|
4012
|
+
* docs.type ← kind;docs.surface ← on_surface(1='current'/0='shadowed');
|
|
4013
|
+
* docs.time ← time;docs.text ← search_text;index_text 由本引擎
|
|
4014
|
+
* segmentForIndex 重算(自有切分面不动)。
|
|
4015
|
+
*
|
|
4016
|
+
* 自调用信任面:DRS 的 Host 门是精确匹配(trustedHosts 全小写比对),本客户
|
|
4017
|
+
* 端可带 hostHeader 显式回显首个 trustedHosts 条目,规避「条目省略端口而
|
|
4018
|
+
* URL 带端口」的精确匹配失配;loopback + 无 trustedHosts 的老宿主天然放行。
|
|
4019
|
+
*/
|
|
4020
|
+
/** DRS API 路径(与 dsh-reference-session-collector 的 DRS_API_PATH 字面一致;不跨包 import)。 */
|
|
4021
|
+
const DRS_API_PATH = "/api/dsh-reference-session-collector";
|
|
4022
|
+
/**
|
|
4023
|
+
* 基址解析:显式配置 > 首个 trustedHosts 条目(补 http:// 前缀)>
|
|
4024
|
+
* `http://127.0.0.1:8080`(宿主默认口;trustedHosts 缺席的老线 DRS 门本就放行)。
|
|
4025
|
+
*/
|
|
4026
|
+
function resolveDrsBaseUrl(explicit, trustedHosts) {
|
|
4027
|
+
const trimmed = explicit?.trim() ?? "";
|
|
4028
|
+
const pick = trimmed !== "" ? trimmed : trustedHosts?.find((entry) => entry.trim() !== "")?.trim();
|
|
4029
|
+
if (pick === void 0 || pick === "") return "http://127.0.0.1:8080";
|
|
4030
|
+
return /^https?:\/\//u.test(pick) ? pick : `http://${pick}`;
|
|
4031
|
+
}
|
|
4032
|
+
var DrsCorpusSource = class DrsCorpusSource {
|
|
4033
|
+
options;
|
|
4034
|
+
fetchImpl;
|
|
4035
|
+
constructor(options) {
|
|
4036
|
+
this.options = options;
|
|
4037
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
4038
|
+
}
|
|
4039
|
+
/** 一个 GET 的 JSON 包络(ok!==true 或非 2xx 一律抛,由调用方回退 native)。 */
|
|
4040
|
+
async getJson(query, atHost) {
|
|
4041
|
+
const url = `${(atHost !== void 0 && atHost !== "" ? `http://${atHost}` : this.options.baseUrl).replace(/\/+$/u, "")}${DRS_API_PATH}${query}`;
|
|
4042
|
+
let response;
|
|
4043
|
+
try {
|
|
4044
|
+
response = await this.fetchImpl(url, { headers: {
|
|
4045
|
+
accept: "application/json",
|
|
4046
|
+
...this.options.hostHeader === void 0 ? {} : { host: this.options.hostHeader }
|
|
4047
|
+
} });
|
|
4048
|
+
} catch (err) {
|
|
4049
|
+
throw new Error(`DRS ${query}: ${String(err instanceof Error ? err.message : err)}`);
|
|
4050
|
+
}
|
|
4051
|
+
if (!response.ok) throw new Error(`DRS ${query} → HTTP ${String(response.status)}`);
|
|
4052
|
+
const body = await response.json();
|
|
4053
|
+
if (typeof body !== "object" || body === null) throw new Error(`DRS ${query}: 响应非 JSON 对象`);
|
|
4054
|
+
const record = body;
|
|
4055
|
+
if (record["ok"] !== true) throw new Error(`DRS ${query}: ok=false(${String(record["error"] ?? "unknown")})`);
|
|
4056
|
+
return record;
|
|
4057
|
+
}
|
|
4058
|
+
static headOf(row, fallbackId) {
|
|
4059
|
+
return {
|
|
4060
|
+
sessionId: String(row["session_id"] ?? fallbackId),
|
|
4061
|
+
version: Number(row["artifact_mtime_ms"] ?? 0),
|
|
4062
|
+
title: String(row["title"] ?? ""),
|
|
4063
|
+
cwd: String(row["cwd"] ?? ""),
|
|
4064
|
+
updatedAt: Number(row["last_time_ms"] ?? 0),
|
|
4065
|
+
archived: Number(row["archived"] ?? 0) === 1
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
/** 全量语料头(内部翻页;rows 空或凑满 total 即止)。 */
|
|
4069
|
+
async listSessions() {
|
|
4070
|
+
const heads = [];
|
|
4071
|
+
let offset = 0;
|
|
4072
|
+
for (;;) {
|
|
4073
|
+
const page = await this.getJson(`?op=sessions&offset=${String(offset)}&limit=${String(500)}`);
|
|
4074
|
+
const rows = Array.isArray(page["rows"]) ? page["rows"] : [];
|
|
4075
|
+
for (const row of rows) {
|
|
4076
|
+
const head = DrsCorpusSource.headOf(row, "");
|
|
4077
|
+
if (head.sessionId !== "") heads.push(head);
|
|
4078
|
+
}
|
|
4079
|
+
offset += rows.length;
|
|
4080
|
+
const total = Number(page["total"] ?? heads.length);
|
|
4081
|
+
if (rows.length === 0 || offset >= total) break;
|
|
4082
|
+
}
|
|
4083
|
+
return heads;
|
|
4084
|
+
}
|
|
4085
|
+
/** 一个会话的检索臂 docs;DRS 无此会话(session:null)返回 undefined。 */
|
|
4086
|
+
async readSession(sessionId) {
|
|
4087
|
+
const body = await this.getJson(`?op=session&session=${encodeURIComponent(sessionId)}&fields=search`);
|
|
4088
|
+
if (body["session"] === null || body["session"] === void 0) return void 0;
|
|
4089
|
+
return {
|
|
4090
|
+
head: DrsCorpusSource.headOf(body["session"], sessionId),
|
|
4091
|
+
docs: (Array.isArray(body["rows"]) ? body["rows"] : []).map((row) => ({
|
|
4092
|
+
seq: Number(row["seq"] ?? 0),
|
|
4093
|
+
type: String(row["kind"] ?? ""),
|
|
4094
|
+
surface: Number(row["on_surface"] ?? 1) === 1 ? "current" : "shadowed",
|
|
4095
|
+
time: Number(row["time"] ?? 0),
|
|
4096
|
+
text: String(row["search_text"] ?? "")
|
|
4097
|
+
}))
|
|
4098
|
+
};
|
|
4099
|
+
}
|
|
4100
|
+
/**
|
|
4101
|
+
* 分组内容搜索直连臂(op=search&group=session,DRS surface.searchGrouped 同构):
|
|
4102
|
+
* 会话分组/加权/过滤/锚定节选全部在 DRS 侧完成,本方法只搬 wire →
|
|
4103
|
+
* 引擎命中形状(SwitchSearchHit 字段同名)。失败(网络/非 2xx/包络)直抛,
|
|
4104
|
+
* 调用侧按硬依赖语义显式报错——**不回退本地索引**。
|
|
4105
|
+
*/
|
|
4106
|
+
async searchGrouped(request) {
|
|
4107
|
+
const params = new URLSearchParams();
|
|
4108
|
+
params.set("op", "search");
|
|
4109
|
+
params.set("group", "session");
|
|
4110
|
+
params.set("q", request.query);
|
|
4111
|
+
if (request.kinds !== void 0 && request.kinds.length > 0) params.set("kinds", request.kinds.join(","));
|
|
4112
|
+
if (request.archived !== void 0) params.set("archived", request.archived);
|
|
4113
|
+
if (request.from !== void 0) params.set("from", String(request.from));
|
|
4114
|
+
if (request.to !== void 0) params.set("to", String(request.to));
|
|
4115
|
+
if (request.sortBy === "time") params.set("sort", "time");
|
|
4116
|
+
if (request.limit !== void 0) params.set("limit", String(request.limit));
|
|
4117
|
+
if (request.expand === true) params.set("expand", "1");
|
|
4118
|
+
const body = await this.getJson(`?${params.toString()}`, request.atHost);
|
|
4119
|
+
return (Array.isArray(body["hits"]) ? body["hits"] : []).map((row) => ({
|
|
4120
|
+
sessionId: String(row["session_id"] ?? ""),
|
|
4121
|
+
title: String(row["title"] ?? ""),
|
|
4122
|
+
seq: Number(row["seq"] ?? 0),
|
|
4123
|
+
type: String(row["type"] ?? ""),
|
|
4124
|
+
time: Number(row["time"] ?? 0),
|
|
4125
|
+
updatedAt: Number(row["updated_at"] ?? 0),
|
|
4126
|
+
snippet: String(row["snippet"] ?? "")
|
|
4127
|
+
}));
|
|
4128
|
+
}
|
|
4129
|
+
/** 可性探针(op=status):硬依赖预检/降级提示用;失败返回 false。 */
|
|
4130
|
+
async probe(atHost) {
|
|
4131
|
+
try {
|
|
4132
|
+
await this.getJson("?op=status", atHost);
|
|
4133
|
+
return true;
|
|
4134
|
+
} catch {
|
|
4135
|
+
return false;
|
|
4136
|
+
}
|
|
4137
|
+
}
|
|
4138
|
+
};
|
|
4139
|
+
//#endregion
|
|
3772
4140
|
//#region src/host/index/rebuild.ts
|
|
3773
4141
|
/**
|
|
3774
4142
|
* Non-destructive index rebuild ("整理索引") for the independent index.
|
|
@@ -3831,6 +4199,63 @@ async function listArchives(layout) {
|
|
|
3831
4199
|
}
|
|
3832
4200
|
/** Sessions per batched transaction (one fsync checkpoint per chunk). */
|
|
3833
4201
|
const REBUILD_CHUNK = 50;
|
|
4202
|
+
/**
|
|
4203
|
+
* 主文件旁的 WAL sidecar(`-wal`/`-shm`)随主文件一并迁走;无目的地时
|
|
4204
|
+
* (孤儿残留)直接 force rm。rename 失败降级删除——sidecar 清理永远不能
|
|
4205
|
+
* 阻断 swap 本身。
|
|
4206
|
+
*/
|
|
4207
|
+
async function moveOrDeleteSidecars(basePath, destBasePath) {
|
|
4208
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
4209
|
+
const src = basePath + suffix;
|
|
4210
|
+
if (!existsSync(src)) continue;
|
|
4211
|
+
if (destBasePath !== null) try {
|
|
4212
|
+
await rename(src, destBasePath + suffix);
|
|
4213
|
+
continue;
|
|
4214
|
+
} catch {}
|
|
4215
|
+
await rm(src, { force: true }).catch(() => {});
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
/**
|
|
4219
|
+
* The swap window: synchronous renames only; queries fail only inside it.
|
|
4220
|
+
*
|
|
4221
|
+
* issue #1 评论的损坏根因:只搬主文件时,旧库残留的 -wal/-shm 会被同名顶替
|
|
4222
|
+
* 的新库当自己的 WAL 重放,页号错配 → malformed。因此 sidecar 必须随主文件
|
|
4223
|
+
* 一并迁 archive(干净 close 本会 checkpoint 自删,这里是异常关闭的兜底)。
|
|
4224
|
+
* swap 后跑 integrity_check:非 ok 则把损坏库改名 .corrupt、从刚落的 archive
|
|
4225
|
+
* 回滚重开,并抛错让调用方置 error(把「索引损坏」降级为「回到上次快照」)。
|
|
4226
|
+
* 导出供测试直接驱动 swap 窗口(多连接场景在 Windows 上 rename 必 EBUSY,
|
|
4227
|
+
* 无法经 rebuildIndex 全链路确定性复现)。
|
|
4228
|
+
*/
|
|
4229
|
+
async function swapShadowIntoActive(activeEngine, layout, log) {
|
|
4230
|
+
const activePath = join(layout.dir, layout.active);
|
|
4231
|
+
const buildingPath = join(layout.dir, layout.building);
|
|
4232
|
+
let archivePath = null;
|
|
4233
|
+
if (existsSync(activePath)) {
|
|
4234
|
+
activeEngine.close();
|
|
4235
|
+
archivePath = join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`);
|
|
4236
|
+
await rename(activePath, archivePath);
|
|
4237
|
+
await moveOrDeleteSidecars(activePath, archivePath);
|
|
4238
|
+
} else await moveOrDeleteSidecars(activePath, null);
|
|
4239
|
+
await rename(buildingPath, activePath);
|
|
4240
|
+
await activeEngine.open();
|
|
4241
|
+
const integrity = activeEngine.integrityCheck();
|
|
4242
|
+
if (integrity === "ok") return;
|
|
4243
|
+
log?.(`post-swap integrity FAILED (${integrity}); rolling back to the newest archive`);
|
|
4244
|
+
activeEngine.close();
|
|
4245
|
+
const corruptBase = join(layout.dir, `index.corrupt-${Date.now()}.sqlite`);
|
|
4246
|
+
try {
|
|
4247
|
+
await rename(activePath, corruptBase);
|
|
4248
|
+
await moveOrDeleteSidecars(activePath, corruptBase);
|
|
4249
|
+
if (archivePath !== null) {
|
|
4250
|
+
await rename(archivePath, activePath);
|
|
4251
|
+
for (const suffix of ["-wal", "-shm"]) if (existsSync(archivePath + suffix)) await rename(archivePath + suffix, activePath + suffix).catch(() => {});
|
|
4252
|
+
await activeEngine.open();
|
|
4253
|
+
}
|
|
4254
|
+
} catch (rollbackErr) {
|
|
4255
|
+
log?.(`swap rollback itself failed: ${String(rollbackErr instanceof Error ? rollbackErr.message : rollbackErr)}`);
|
|
4256
|
+
}
|
|
4257
|
+
throw new Error(`switch-search: post-swap integrity check failed: ${integrity}`);
|
|
4258
|
+
}
|
|
3834
4259
|
async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, onProgress, archiveSource, hooks) {
|
|
3835
4260
|
const startedMs = Date.now();
|
|
3836
4261
|
let docsWritten = 0;
|
|
@@ -3934,13 +4359,7 @@ async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, on
|
|
|
3934
4359
|
state.state = "swapping";
|
|
3935
4360
|
emit();
|
|
3936
4361
|
hooks?.log?.(`rebuild shadow complete: ${state.done} sessions, ~${docsWritten} docs, ${state.failures.length} failures; swapping`);
|
|
3937
|
-
|
|
3938
|
-
if (existsSync(activePath)) {
|
|
3939
|
-
activeEngine.close();
|
|
3940
|
-
await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`));
|
|
3941
|
-
}
|
|
3942
|
-
await rename(buildingPath, activePath);
|
|
3943
|
-
await activeEngine.open();
|
|
4362
|
+
await swapShadowIntoActive(activeEngine, layout, hooks?.log);
|
|
3944
4363
|
await pruneArchives(layout, keepArchives);
|
|
3945
4364
|
state.finishedAt = Date.now();
|
|
3946
4365
|
state.state = "idle";
|
|
@@ -3955,8 +4374,9 @@ async function rebuildIndex(activeEngine, layout, sessionQuery, keepArchives, on
|
|
|
3955
4374
|
}
|
|
3956
4375
|
return state;
|
|
3957
4376
|
}
|
|
3958
|
-
/** Import doc-level records into the shadow file and swap it in (same swap path).
|
|
3959
|
-
|
|
4377
|
+
/** Import doc-level records into the shadow file and swap it in (same swap path).
|
|
4378
|
+
* @param log - 可选日志汇(空 docs 记录告警,issue #1 写入侧可观测)。 */
|
|
4379
|
+
async function importIntoIndex(activeEngine, layout, records, keepArchives, log) {
|
|
3960
4380
|
const state = {
|
|
3961
4381
|
state: "building",
|
|
3962
4382
|
done: 0,
|
|
@@ -3974,6 +4394,7 @@ async function importIntoIndex(activeEngine, layout, records, keepArchives) {
|
|
|
3974
4394
|
try {
|
|
3975
4395
|
for (const record of records) {
|
|
3976
4396
|
try {
|
|
4397
|
+
if (record.docs.length === 0) log?.(`snapshot import: session ${record.sessionId} has zero docs (hollow header, sync will backfill)`);
|
|
3977
4398
|
shadow.importSessionDocs({
|
|
3978
4399
|
sessionId: record.sessionId,
|
|
3979
4400
|
version: record.version,
|
|
@@ -3996,13 +4417,7 @@ async function importIntoIndex(activeEngine, layout, records, keepArchives) {
|
|
|
3996
4417
|
throw error;
|
|
3997
4418
|
}
|
|
3998
4419
|
state.state = "swapping";
|
|
3999
|
-
|
|
4000
|
-
if (existsSync(activePath)) {
|
|
4001
|
-
activeEngine.close();
|
|
4002
|
-
await rename(activePath, join(layout.dir, `${layout.archivePrefix}${Date.now()}.sqlite`));
|
|
4003
|
-
}
|
|
4004
|
-
await rename(buildingPath, activePath);
|
|
4005
|
-
await activeEngine.open();
|
|
4420
|
+
await swapShadowIntoActive(activeEngine, layout, log);
|
|
4006
4421
|
await pruneArchives(layout, keepArchives);
|
|
4007
4422
|
state.finishedAt = Date.now();
|
|
4008
4423
|
state.state = "idle";
|
|
@@ -4013,11 +4428,14 @@ async function importIntoIndex(activeEngine, layout, records, keepArchives) {
|
|
|
4013
4428
|
}
|
|
4014
4429
|
return state;
|
|
4015
4430
|
}
|
|
4016
|
-
/** Remove the oldest archives beyond the retention bound. */
|
|
4431
|
+
/** Remove the oldest archives beyond the retention bound (sidecars too). */
|
|
4017
4432
|
async function pruneArchives(layout, keep) {
|
|
4018
4433
|
const archives = await listArchives(layout);
|
|
4019
4434
|
const excess = archives.length - Math.max(0, keep);
|
|
4020
|
-
for (let i = 0; i < excess; i += 1)
|
|
4435
|
+
for (let i = 0; i < excess; i += 1) {
|
|
4436
|
+
await unlink(join(layout.dir, archives[i])).catch(() => {});
|
|
4437
|
+
await moveOrDeleteSidecars(join(layout.dir, archives[i]), null);
|
|
4438
|
+
}
|
|
4021
4439
|
}
|
|
4022
4440
|
/**
|
|
4023
4441
|
* Resolve the index directory that hosts the independent index files:
|
|
@@ -4144,11 +4562,14 @@ const Config = z.object({
|
|
|
4144
4562
|
historyFiles: withVolatile(z.boolean().default(true)),
|
|
4145
4563
|
healthCheck: withVolatile(z.boolean().default(true)),
|
|
4146
4564
|
search: withVolatile(z.boolean().default(true)),
|
|
4565
|
+
directDelete: withVolatile(z.boolean().default(false)),
|
|
4147
4566
|
defaultMode: withVolatile(z.union(["title", "content"]).default("title")),
|
|
4148
4567
|
autoSync: withVolatile(z.boolean().default(true)),
|
|
4149
4568
|
syncIntervalMs: withVolatile(z.number().default(3e4)),
|
|
4150
4569
|
archiveKeep: withVolatile(z.number().default(2)),
|
|
4151
|
-
indexDir: withVolatile(z.string().default(""))
|
|
4570
|
+
indexDir: withVolatile(z.string().default("")),
|
|
4571
|
+
corpusSource: withVolatile(z.union(["native", "drs"]).default("drs")),
|
|
4572
|
+
drsBaseUrl: withVolatile(z.string().default(""))
|
|
4152
4573
|
});
|
|
4153
4574
|
/** 单次请求体的上限(防御无界读取)。 */
|
|
4154
4575
|
const MAX_BODY_BYTES = 16 << 20;
|
|
@@ -4341,7 +4762,7 @@ async function listSessions(srt) {
|
|
|
4341
4762
|
* `sortBy: 'time'` orders by session recency (`updatedAt`), anything else by
|
|
4342
4763
|
* relevance; the host orders before truncating so the page is honest.
|
|
4343
4764
|
*/
|
|
4344
|
-
async function contentSearch(srt, payload) {
|
|
4765
|
+
async function contentSearch(srt, payload, requestHost = "") {
|
|
4345
4766
|
const record = payload;
|
|
4346
4767
|
const query = typeof record?.query === "string" ? record.query.trim() : "";
|
|
4347
4768
|
if (query === "") return {
|
|
@@ -4357,39 +4778,38 @@ async function contentSearch(srt, payload) {
|
|
|
4357
4778
|
let types;
|
|
4358
4779
|
if (Array.isArray(record?.types) && record.types.length > 0) types = record.types.filter((entry) => entry === "all" || entry === "user" || entry === "reply" || entry === "tool");
|
|
4359
4780
|
else types = ["user", "reply"];
|
|
4360
|
-
const
|
|
4361
|
-
if (index.engine.isOpen === false) return {
|
|
4362
|
-
ok: false,
|
|
4363
|
-
error: "独立索引未就绪:请在面板或设置中先建立索引(整理索引)"
|
|
4364
|
-
};
|
|
4781
|
+
const kinds = resolveTypes(types);
|
|
4365
4782
|
try {
|
|
4366
4783
|
return {
|
|
4367
4784
|
ok: true,
|
|
4368
|
-
items:
|
|
4785
|
+
items: await srt.drsSource.searchGrouped({
|
|
4369
4786
|
query,
|
|
4370
|
-
|
|
4371
|
-
limit,
|
|
4372
|
-
sortBy,
|
|
4787
|
+
kinds,
|
|
4373
4788
|
archived,
|
|
4374
|
-
|
|
4375
|
-
|
|
4789
|
+
sortBy,
|
|
4790
|
+
limit,
|
|
4791
|
+
...record?.expand === true ? { expand: true } : {},
|
|
4792
|
+
...requestHost === "" ? {} : { atHost: requestHost },
|
|
4793
|
+
...fromMs === void 0 ? {} : { from: fromMs },
|
|
4794
|
+
...toMs === void 0 ? {} : { to: toMs }
|
|
4376
4795
|
})
|
|
4377
4796
|
};
|
|
4378
4797
|
} catch (err) {
|
|
4379
4798
|
return {
|
|
4380
4799
|
ok: false,
|
|
4381
|
-
error: String(err instanceof Error ? err.message : err)
|
|
4800
|
+
error: `dsh-reference-session-collector 搜索面不可用(硬依赖):${String(err instanceof Error ? err.message : err)}`
|
|
4382
4801
|
};
|
|
4383
4802
|
}
|
|
4384
4803
|
}
|
|
4385
4804
|
/** search-status: probe the independent index readiness and progress. */
|
|
4386
|
-
async function searchStatus(srt) {
|
|
4805
|
+
async function searchStatus(srt, requestHost = "") {
|
|
4387
4806
|
const index = srt.index;
|
|
4388
4807
|
const sync = index.sync.snapshot();
|
|
4808
|
+
const drsUp = await srt.drsSource.probe(requestHost);
|
|
4389
4809
|
return {
|
|
4390
4810
|
ok: true,
|
|
4391
|
-
available:
|
|
4392
|
-
reason:
|
|
4811
|
+
available: drsUp,
|
|
4812
|
+
reason: drsUp ? void 0 : "drs-unavailable",
|
|
4393
4813
|
indexing: sync.state === "syncing",
|
|
4394
4814
|
archivedSessions: index.engine.countArchived(),
|
|
4395
4815
|
archive: index.archiveReader.diagnostics(),
|
|
@@ -4411,6 +4831,7 @@ async function indexStatus(srt) {
|
|
|
4411
4831
|
driver: index.engine.driverLabel,
|
|
4412
4832
|
archive: index.archiveReader.diagnostics(),
|
|
4413
4833
|
dir: index.layout.dir,
|
|
4834
|
+
directDelete: srt.config().directDelete === true,
|
|
4414
4835
|
archives: await listArchives(index.layout).catch(() => []),
|
|
4415
4836
|
sync: {
|
|
4416
4837
|
...sync,
|
|
@@ -4525,7 +4946,7 @@ async function indexImport(srt, text) {
|
|
|
4525
4946
|
};
|
|
4526
4947
|
const config = srt.config();
|
|
4527
4948
|
const keepArchives = Math.max(0, config.archiveKeep ?? SWITCH_DEFAULT_CONFIG.archiveKeep);
|
|
4528
|
-
importIntoIndex(index.engine, index.layout, parsed.records, keepArchives).then((state) => {
|
|
4949
|
+
importIntoIndex(index.engine, index.layout, parsed.records, keepArchives, srt.log).then((state) => {
|
|
4529
4950
|
index.rebuild = state;
|
|
4530
4951
|
}).catch((err) => {
|
|
4531
4952
|
index.rebuild = {
|
|
@@ -4554,10 +4975,10 @@ async function indexImport(srt, text) {
|
|
|
4554
4975
|
};
|
|
4555
4976
|
}
|
|
4556
4977
|
/** JSON 面的搜索子域方法分发(index-export/import 走原始体,在路由层特判)。 */
|
|
4557
|
-
async function handleIndexMethod(method, payload, srt) {
|
|
4978
|
+
async function handleIndexMethod(method, payload, srt, requestHost = "") {
|
|
4558
4979
|
if (method === "list-sessions") return await listSessions(srt);
|
|
4559
|
-
if (method === "content-search") return await contentSearch(srt, payload);
|
|
4560
|
-
if (method === "search-status") return await searchStatus(srt);
|
|
4980
|
+
if (method === "content-search") return await contentSearch(srt, payload, requestHost);
|
|
4981
|
+
if (method === "search-status") return await searchStatus(srt, requestHost);
|
|
4561
4982
|
if (method === "index-status") return await indexStatus(srt);
|
|
4562
4983
|
if (method === "index-rebuild") return await indexRebuild(srt);
|
|
4563
4984
|
return {
|
|
@@ -4616,7 +5037,8 @@ function configSnapshotProjection(config) {
|
|
|
4616
5037
|
enabled: config.enabled,
|
|
4617
5038
|
historyFiles: config.historyFiles,
|
|
4618
5039
|
healthCheck: config.healthCheck,
|
|
4619
|
-
search: config.search
|
|
5040
|
+
search: config.search,
|
|
5041
|
+
directDelete: config.directDelete
|
|
4620
5042
|
},
|
|
4621
5043
|
[SWITCH_SEARCH_SETTINGS_NAMESPACE]: {
|
|
4622
5044
|
enabled: config.enabled,
|
|
@@ -4733,6 +5155,11 @@ async function handleMethod(method, payload, runtime) {
|
|
|
4733
5155
|
});
|
|
4734
5156
|
}
|
|
4735
5157
|
runtime.index?.onPurged(succeeded);
|
|
5158
|
+
appendRetiredTombstones(succeeded, {
|
|
5159
|
+
...runtime.tombstoneDir === void 0 ? {} : { dir: runtime.tombstoneDir() },
|
|
5160
|
+
source: "steward-purge-memory",
|
|
5161
|
+
log: runtime.log
|
|
5162
|
+
});
|
|
4736
5163
|
return {
|
|
4737
5164
|
ok: true,
|
|
4738
5165
|
purged: succeeded.length,
|
|
@@ -4747,7 +5174,13 @@ async function handleMethod(method, payload, runtime) {
|
|
|
4747
5174
|
});
|
|
4748
5175
|
if (result.ok && ids !== void 0) {
|
|
4749
5176
|
const failed = new Set((result.failures ?? []).map((f) => f.sessionId));
|
|
4750
|
-
|
|
5177
|
+
const gone = ids.filter((id) => !failed.has(id));
|
|
5178
|
+
runtime.index?.onPurged(gone);
|
|
5179
|
+
appendRetiredTombstones(gone, {
|
|
5180
|
+
...runtime.tombstoneDir === void 0 ? {} : { dir: runtime.tombstoneDir() },
|
|
5181
|
+
source: "steward-purge-fallback",
|
|
5182
|
+
log: runtime.log
|
|
5183
|
+
});
|
|
4751
5184
|
}
|
|
4752
5185
|
return result;
|
|
4753
5186
|
}
|
|
@@ -4926,17 +5359,27 @@ async function handleMethod(method, payload, runtime) {
|
|
|
4926
5359
|
* @param config - 组合条目(0.1.7:`.volatile()` 字段为 live ref)。
|
|
4927
5360
|
*/
|
|
4928
5361
|
function apply(ctx, config = {}) {
|
|
4929
|
-
const current = () =>
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
5362
|
+
const current = () => {
|
|
5363
|
+
const { steward, search } = bridgeOverlay(settingsBridge);
|
|
5364
|
+
const bool = (doc, key, v) => typeof doc[key] === "boolean" ? doc[key] : v;
|
|
5365
|
+
const num = (doc, key, v) => typeof doc[key] === "number" && Number.isFinite(doc[key]) ? doc[key] : v;
|
|
5366
|
+
const str = (doc, key, v) => typeof doc[key] === "string" ? doc[key] : v;
|
|
5367
|
+
const mode = (v) => search["defaultMode"] === "title" || search["defaultMode"] === "content" ? search["defaultMode"] : v;
|
|
5368
|
+
return {
|
|
5369
|
+
enabled: bool(steward, "enabled", readVolatileValue(config.enabled) ?? DEFAULT_CONFIG.enabled),
|
|
5370
|
+
historyFiles: bool(steward, "historyFiles", readVolatileValue(config.historyFiles) ?? DEFAULT_CONFIG.historyFiles),
|
|
5371
|
+
healthCheck: bool(steward, "healthCheck", readVolatileValue(config.healthCheck) ?? DEFAULT_CONFIG.healthCheck),
|
|
5372
|
+
search: bool(steward, "search", readVolatileValue(config.search) ?? DEFAULT_CONFIG.search),
|
|
5373
|
+
directDelete: bool(steward, "directDelete", readVolatileValue(config.directDelete) ?? DEFAULT_CONFIG.directDelete),
|
|
5374
|
+
defaultMode: mode(readVolatileValue(config.defaultMode) ?? SWITCH_DEFAULT_CONFIG.defaultMode),
|
|
5375
|
+
autoSync: bool(search, "autoSync", readVolatileValue(config.autoSync) ?? SWITCH_DEFAULT_CONFIG.autoSync),
|
|
5376
|
+
syncIntervalMs: num(search, "syncIntervalMs", readVolatileValue(config.syncIntervalMs) ?? SWITCH_DEFAULT_CONFIG.syncIntervalMs),
|
|
5377
|
+
archiveKeep: num(search, "archiveKeep", readVolatileValue(config.archiveKeep) ?? SWITCH_DEFAULT_CONFIG.archiveKeep),
|
|
5378
|
+
indexDir: str(search, "indexDir", readVolatileValue(config.indexDir) ?? SWITCH_DEFAULT_CONFIG.indexDir),
|
|
5379
|
+
corpusSource: readVolatileValue(config.corpusSource) ?? SWITCH_DEFAULT_CONFIG.corpusSource,
|
|
5380
|
+
drsBaseUrl: str(search, "drsBaseUrl", readVolatileValue(config.drsBaseUrl) ?? SWITCH_DEFAULT_CONFIG.drsBaseUrl)
|
|
5381
|
+
};
|
|
5382
|
+
};
|
|
4940
5383
|
const log = (message) => {
|
|
4941
5384
|
try {
|
|
4942
5385
|
ctx.logger?.info?.(`[session-steward] ${message}`);
|
|
@@ -4974,9 +5417,10 @@ function apply(ctx, config = {}) {
|
|
|
4974
5417
|
}
|
|
4975
5418
|
};
|
|
4976
5419
|
const registry = () => ctx.get("workspaceRegistry");
|
|
5420
|
+
const settingsBridge = installLegacySettingsBridge(ctx);
|
|
4977
5421
|
const runtime = {
|
|
4978
5422
|
config: () => current(),
|
|
4979
|
-
settingsBridge
|
|
5423
|
+
settingsBridge,
|
|
4980
5424
|
dshHome: resolvedHome,
|
|
4981
5425
|
registry,
|
|
4982
5426
|
projectionStateFor,
|
|
@@ -5008,6 +5452,10 @@ function apply(ctx, config = {}) {
|
|
|
5008
5452
|
log(`index onPurged failed: ${String(err instanceof Error ? err.message : err)}`);
|
|
5009
5453
|
}
|
|
5010
5454
|
}
|
|
5455
|
+
},
|
|
5456
|
+
tombstoneDir: () => {
|
|
5457
|
+
const dir = current().indexDir;
|
|
5458
|
+
return dir !== "" ? dir : defaultTombstoneDir();
|
|
5011
5459
|
}
|
|
5012
5460
|
};
|
|
5013
5461
|
const initial = current();
|
|
@@ -5051,6 +5499,12 @@ function apply(ctx, config = {}) {
|
|
|
5051
5499
|
return "";
|
|
5052
5500
|
}
|
|
5053
5501
|
};
|
|
5502
|
+
const trustedHosts = webRuntime?.trustedHosts;
|
|
5503
|
+
const drsSource = new DrsCorpusSource({
|
|
5504
|
+
baseUrl: resolveDrsBaseUrl(initial.drsBaseUrl, trustedHosts),
|
|
5505
|
+
hostHeader: trustedHosts !== void 0 && trustedHosts.length > 0 ? trustedHosts[0] : void 0,
|
|
5506
|
+
log
|
|
5507
|
+
});
|
|
5054
5508
|
const indexState = {
|
|
5055
5509
|
engine,
|
|
5056
5510
|
archiveReader,
|
|
@@ -5061,7 +5515,7 @@ function apply(ctx, config = {}) {
|
|
|
5061
5515
|
return sessionQuery.readSession(sessionId);
|
|
5062
5516
|
},
|
|
5063
5517
|
readTitleSnapshots: sessionQuery === void 0 ? void 0 : (ids) => sessionQuery.readTitleSnapshots(ids)
|
|
5064
|
-
}, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle),
|
|
5518
|
+
}, () => ({ archivedSessionIds: archiveReader.read().ids }), log, readSessionFromFile, readProjectionTitle, initial.corpusSource === "native" ? void 0 : drsSource),
|
|
5065
5519
|
layout: indexLayout,
|
|
5066
5520
|
rebuild: {
|
|
5067
5521
|
state: "idle",
|
|
@@ -5075,6 +5529,7 @@ function apply(ctx, config = {}) {
|
|
|
5075
5529
|
const srt = {
|
|
5076
5530
|
sessionQuery,
|
|
5077
5531
|
index: indexState,
|
|
5532
|
+
drsSource,
|
|
5078
5533
|
config: () => current(),
|
|
5079
5534
|
registry: () => ({ archivedSessionIds: archiveReader.read().ids }),
|
|
5080
5535
|
log,
|
|
@@ -5227,7 +5682,7 @@ function apply(ctx, config = {}) {
|
|
|
5227
5682
|
writeJson(res, 200, await indexImport(srt, text));
|
|
5228
5683
|
return;
|
|
5229
5684
|
}
|
|
5230
|
-
writeJson(res, 200, await handleIndexMethod(method, await readJsonBody(req), srt));
|
|
5685
|
+
writeJson(res, 200, await handleIndexMethod(method, await readJsonBody(req), srt, String(req.headers.host ?? "")));
|
|
5231
5686
|
} catch (err) {
|
|
5232
5687
|
writeJson(res, 400, {
|
|
5233
5688
|
ok: false,
|
|
@@ -5238,4 +5693,4 @@ function apply(ctx, config = {}) {
|
|
|
5238
5693
|
}), "dsh-manage-session: /switch-search/api route");
|
|
5239
5694
|
}
|
|
5240
5695
|
//#endregion
|
|
5241
|
-
export { Config, DEFAULT_CONFIG, DEFAULT_INDEX_LAYOUT, HEALTH_METHODS, HISTORY_METHODS, HealthCache, INDEX_METHODS, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, SWITCH_API_PREFIX, SWITCH_DEFAULT_CONFIG, SwitchIndexEngine, SwitchWatermarkSync, V4_HOST_MIN, apply, archiveArchiveFile, archiveHistory, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createArchiveSource, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, detectSteward, dirSize, discoverSessions, editWorkspaceDocument, exportSnapshot, extractTitleFromEvents, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleIndexMethod, handleMethod, importIntoIndex, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, parseSnapshot, prescribe, probePeer, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, rebuildIndex, recoverIndex, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor };
|
|
5696
|
+
export { Config, DEFAULT_CONFIG, DEFAULT_INDEX_LAYOUT, HEALTH_METHODS, HISTORY_METHODS, HealthCache, INDEX_METHODS, SOURCE_MIGRATE_BACKUP_SUFFIX, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, SWITCH_API_PREFIX, SWITCH_DEFAULT_CONFIG, SwitchIndexEngine, SwitchWatermarkSync, V4_HOST_MIN, apply, archiveArchiveFile, archiveHistory, assessRepair, backupFilesIn, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createArchiveSource, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, detectSteward, dirSize, discoverSessions, editWorkspaceDocument, exportSnapshot, extractTitleFromEvents, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, gateSourceKind, generationLogFilename, handleIndexMethod, handleMethod, importIntoIndex, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, isSourceMigrateBackupName, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, migrateLegacySource, migrateSessionSourceKind, parseGenerationLogFilename, parseSnapshot, prescribe, probePeer, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readSourceKindFacts, readTailFacts, rebuildIndex, recoverIndex, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor, swapShadowIntoActive };
|