dsh-sessions-manager 3.5.4 → 3.6.1
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/README.en.md +50 -10
- package/README.md +51 -10
- package/assets/screenshot-session-empty.png +0 -0
- package/assets/screenshot-session-multimove.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-subagent.png +0 -0
- package/lib/client.js +890 -79
- package/lib/client.js.map +3 -3
- package/lib/index.js +887 -221
- package/lib/index.js.map +4 -4
- package/package.json +3 -2
- package/src/client/index.jsx +887 -60
- package/src/client/logic.js +79 -0
- package/src/compat/persistence.js +16 -2
- package/src/handle-era-ops.js +178 -18
- package/src/handle-era-paths.js +22 -9
- package/src/index.js +558 -125
- package/src/lineage.js +78 -0
- package/src/pending-moves.js +126 -0
- package/src/session-meta-cache.js +18 -0
- package/src/state-temp-sweep.js +42 -0
package/src/index.js
CHANGED
|
@@ -7,27 +7,38 @@
|
|
|
7
7
|
// (workspaceRegistry + storageDomain), folds titles/dates/workspace tags from
|
|
8
8
|
// session persistence, physically removes a session's log file on delete, and
|
|
9
9
|
// relocates a conversation (session) between workspaces on move.
|
|
10
|
-
import { mkdir, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
11
11
|
import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { rewriteFrame0CwdInMemory, scanZstdFrames } from './zstd-frame.js'
|
|
15
15
|
import { createSessionMarkdownBuilder } from './markdown.js'
|
|
16
16
|
import { createStarIndex } from './star-index.js'
|
|
17
|
+
import { createPendingMoveStore } from './pending-moves.js'
|
|
18
|
+
import { sweepStaleStateTemps } from './state-temp-sweep.js'
|
|
17
19
|
import { aggregateStorage } from './storage-stats.js'
|
|
18
20
|
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
19
|
-
import { createSessionMetaCache,
|
|
21
|
+
import { createSessionMetaCache, persistFingerprintOf } from './session-meta-cache.js'
|
|
20
22
|
import { createTitleIndexStore } from './title-persist-index.js'
|
|
21
23
|
import { createPersistenceAdapter } from './compat/persistence.js'
|
|
22
24
|
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
23
25
|
import { pathOwnsSession } from './path-guard.js'
|
|
24
26
|
import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
|
|
27
|
+
import { classifyLineage, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
|
|
28
|
+
|
|
29
|
+
// 构建指纹:build.mjs 以 define 在编译期注入;直接以源码运行(测试)时为 'dev'。
|
|
30
|
+
// 用途:capabilities 带出 buildStamp,一眼判断「运行中的 host 是不是这份构建」——
|
|
31
|
+
// 插件 host 端只在宿主启动时加载一次,改完 lib 不重启宿主等于白改。
|
|
32
|
+
const BUILD_STAMP = typeof __BUILD_STAMP__ !== 'undefined' ? __BUILD_STAMP__ : 'dev'
|
|
25
33
|
|
|
26
34
|
|
|
27
35
|
export const name = 'dsh-sessions-manager'
|
|
28
36
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
29
37
|
|
|
30
38
|
const MAX_TITLE = 80
|
|
39
|
+
// 插件自有状态目录(星标 / 自动归档 / 标题索引共用)。与 star-index.js、auto-archive.js
|
|
40
|
+
// 的默认目录保持一致,环境变量同名,便于测试注入。
|
|
41
|
+
const STATE_DIR = process.env.DSH_SESSIONS_MANAGER_STAR_DIR || join(homedir(), '.dsh', 'sessions-manager')
|
|
31
42
|
// Recycle bin (回收站): normal deletes land here instead of being erased.
|
|
32
43
|
const TRASH_DIR = process.env.DSH_SESSIONS_MANAGER_TRASH_DIR || join(homedir(), '.dsh', 'sessions-manager-trash')
|
|
33
44
|
const TRASH_INDEX = join(TRASH_DIR, 'index.json')
|
|
@@ -168,8 +179,11 @@ export function apply(ctx) {
|
|
|
168
179
|
const stat = statsById.get(id)
|
|
169
180
|
const entry = store && store[id]
|
|
170
181
|
if (!stat || !entry) continue
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
// 持久索引只认可跨重启的指纹(legacy 的文件指纹 / handle 世代的 sz 指纹,
|
|
183
|
+
// 见 session-meta-cache.persistFingerprintOf——issue #8 之前 handle 世代
|
|
184
|
+
// 从不落盘,导致每次重启都全量重新解码)。
|
|
185
|
+
const fp = persistFingerprintOf(stat)
|
|
186
|
+
if (!fp) continue
|
|
173
187
|
if (fp && entry.fingerprint === fp) {
|
|
174
188
|
hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
|
|
175
189
|
}
|
|
@@ -177,21 +191,23 @@ export function apply(ctx) {
|
|
|
177
191
|
return hits
|
|
178
192
|
}
|
|
179
193
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
194
|
+
// 把本批真正解码出的元数据回写持久索引(索引只是加速器:写失败不影响响应,
|
|
195
|
+
// 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
|
|
196
|
+
// 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
|
|
197
|
+
// 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
|
|
198
|
+
// 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
|
|
199
|
+
// 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
|
|
200
|
+
async function persistDecoded(decoded, statsById) {
|
|
185
201
|
if (!decoded || !decoded.size) return
|
|
186
202
|
const batch = {}
|
|
187
203
|
const now = Date.now()
|
|
188
204
|
for (const [id, meta] of decoded) {
|
|
189
|
-
const fp =
|
|
190
|
-
if (!
|
|
205
|
+
const fp = persistFingerprintOf(statsById.get(id))
|
|
206
|
+
if (!fp) continue
|
|
191
207
|
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
|
|
192
208
|
}
|
|
193
209
|
if (!Object.keys(batch).length) return
|
|
194
|
-
titleIndex.merge(batch)
|
|
210
|
+
try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
// 从投影快照里抽出元数据;快照缺失/异常时返回零值 meta(调用方决定兜底)。
|
|
@@ -271,65 +287,82 @@ export function apply(ctx) {
|
|
|
271
287
|
|
|
272
288
|
// Resolve one session's display metadata.
|
|
273
289
|
//
|
|
274
|
-
// 成本模型(issue #1
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
290
|
+
// 成本模型(issue #1 → issue #8):runtime 的标题投影(readTitleSnapshots)
|
|
291
|
+
// 在 0.1.x 上对每条非 live 会话都是**整本日志解码**(SessionCorpus.projectMany
|
|
292
|
+
// → inspectPersisted)。列表构建绝不能在请求路径上同步触发它——issue #8 实测:
|
|
293
|
+
// 87 本日志 / 249MB 的库,一次冷启动全量投影把宿主打满 ~90s CPU。
|
|
278
294
|
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
295
|
+
// 因此请求路径只消费两级缓存(metaCache 内存缓存 → titleIndex 持久索引),
|
|
296
|
+
// 缺失的会话交给后台预热队列分批补齐(见 enqueueWarm/runWarm);侧栏高频
|
|
297
|
+
// 轮询 + 管理面板刷新会让补齐结果自然浮现。
|
|
298
|
+
const EMPTY_META = { title: null, cwd: null, createdAt: null }
|
|
299
|
+
|
|
300
|
+
// —— 冷启动后台预热(issue #8)—————————————————————————————————————
|
|
301
|
+
// 单飞队列:请求路径发现缓存未命中的会话,把 { stat, header } 快照丢进
|
|
302
|
+
// warmQueue,由 runWarm 以小批次消费。每批之间 setImmediate 让步事件循环,
|
|
303
|
+
// 绝不独占宿主主线程;结果写 metaCache + 持久标题索引。stat 是入队时刻的
|
|
304
|
+
// 快照——若日志在预热期间又变了,下次请求的指纹比对会再次判未命中并重新
|
|
305
|
+
// 入队,天然自愈。
|
|
306
|
+
const WARM_CHUNK = 4
|
|
307
|
+
const warmQueue = new Map()
|
|
308
|
+
let warmRunning = false
|
|
309
|
+
let warmKickTimer = null
|
|
310
|
+
|
|
311
|
+
function scheduleWarm() {
|
|
312
|
+
if (warmKickTimer || warmRunning) return
|
|
313
|
+
warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
|
|
314
|
+
// unref:预热绝不反过来把宿主进程钉住(与 autoArchive「无定时器」同一原则)。
|
|
315
|
+
if (typeof warmKickTimer.unref === 'function') warmKickTimer.unref()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function enqueueWarm(ids, statsById, entryById) {
|
|
319
|
+
if (!ids || !ids.length) return
|
|
320
|
+
for (const id of ids) {
|
|
321
|
+
const key = String(id)
|
|
322
|
+
const entry = entryById ? entryById.get(key) : null
|
|
323
|
+
warmQueue.set(key, {
|
|
324
|
+
stat: statsById ? (statsById.get(key) || null) : null,
|
|
325
|
+
header: (entry && entry.header) || null,
|
|
326
|
+
})
|
|
311
327
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
+
scheduleWarm()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function runWarm() {
|
|
332
|
+
if (warmRunning) return
|
|
333
|
+
warmRunning = true
|
|
334
|
+
try {
|
|
335
|
+
while (warmQueue.size) {
|
|
336
|
+
const batch = [...warmQueue.entries()].slice(0, WARM_CHUNK)
|
|
337
|
+
for (const [id] of batch) warmQueue.delete(id)
|
|
338
|
+
try {
|
|
339
|
+
const ids = batch.map(([id]) => id)
|
|
340
|
+
const snapshotById = await projectTitles(ids)
|
|
341
|
+
const decoded = new Map()
|
|
342
|
+
const statsById = new Map()
|
|
343
|
+
for (const [id, desc] of batch) {
|
|
344
|
+
const snapshot = snapshotById.has(id) ? snapshotById.get(id) : null
|
|
345
|
+
const meta = metaFromSnapshot(snapshot)
|
|
346
|
+
// cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
|
|
347
|
+
// 陈旧;移动会话后尤其如此),投影只负责标题。
|
|
348
|
+
if (desc.header) {
|
|
349
|
+
if (typeof desc.header.cwd === 'string') meta.cwd = desc.header.cwd
|
|
350
|
+
if (desc.header.createdAt != null) meta.createdAt = desc.header.createdAt
|
|
351
|
+
}
|
|
352
|
+
metaCache.set(id, desc.stat, meta)
|
|
353
|
+
statsById.set(id, desc.stat)
|
|
354
|
+
if (desc.stat) decoded.set(id, meta)
|
|
355
|
+
}
|
|
356
|
+
await persistDecoded(decoded, statsById)
|
|
357
|
+
} catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
|
|
358
|
+
// 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
|
|
359
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
360
|
+
}
|
|
361
|
+
} finally {
|
|
362
|
+
warmRunning = false
|
|
363
|
+
// 预热期间又有新请求入队 → 再排一轮。
|
|
364
|
+
if (warmQueue.size) scheduleWarm()
|
|
328
365
|
}
|
|
329
|
-
metaCache.set(key, statInfo, meta)
|
|
330
|
-
// 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
|
|
331
|
-
if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
|
|
332
|
-
return buildItem(key, meta, usage, opts.exposeUsage)
|
|
333
366
|
}
|
|
334
367
|
|
|
335
368
|
// Disk usage + last-write time for every session, in one pass. Also produces
|
|
@@ -360,10 +393,13 @@ export function apply(ctx) {
|
|
|
360
393
|
const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
|
|
361
394
|
if (!id) return
|
|
362
395
|
// SessionHandle 世代:snapshot(header/revision/sizeBytes)是权威轻量
|
|
363
|
-
// 观察,绝不再绕道私有磁盘路径补 stat。
|
|
396
|
+
// 观察,绝不再绕道私有磁盘路径补 stat。revision 进内存缓存指纹;
|
|
397
|
+
// sizeBytes 供 persistFingerprintOf 派生跨重启的 sz 持久指纹(issue #8)。
|
|
364
398
|
if (entry && typeof entry.revision === 'string' && entry.revision) {
|
|
365
399
|
if (Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
366
|
-
statsById.set(id,
|
|
400
|
+
statsById.set(id, Number.isFinite(entry.sizeBytes)
|
|
401
|
+
? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
|
|
402
|
+
: { revision: entry.revision })
|
|
367
403
|
return
|
|
368
404
|
}
|
|
369
405
|
if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
@@ -431,8 +467,14 @@ export function apply(ctx) {
|
|
|
431
467
|
// when the session is really gone (purge, or externally removed; the latter
|
|
432
468
|
// is caught by gcStars during list builds).
|
|
433
469
|
const stars = createStarIndex()
|
|
470
|
+
// 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
|
|
471
|
+
// 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
|
|
472
|
+
const pendingMoves = createPendingMoveStore()
|
|
434
473
|
// Auto-archive settings live in their own schema-v4 store, off by default.
|
|
435
474
|
const autoArchive = createAutoArchiveStore()
|
|
475
|
+
// 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
|
|
476
|
+
// 孤儿(实测一次累积 153 个)。这里把 1 小时前的旧临时文件收掉,不碰正在写的。
|
|
477
|
+
sweepStaleStateTemps([STATE_DIR, TRASH_DIR], {}).catch(() => {})
|
|
436
478
|
|
|
437
479
|
async function gcStars(validIds) {
|
|
438
480
|
try {
|
|
@@ -716,7 +758,11 @@ export function apply(ctx) {
|
|
|
716
758
|
purged = true
|
|
717
759
|
})
|
|
718
760
|
if (!purged) throw new Error('彻底删除失败')
|
|
719
|
-
stars.removeIds([sid]).catch(
|
|
761
|
+
// 星标清理必须 await。原先这里是浮动写入(`stars.removeIds([sid]).catch(...)`):
|
|
762
|
+
// 删除结果先返回、星标文件随后才落地,正好会撞上调用方(或测试 teardown)已开始的
|
|
763
|
+
// 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
|
|
764
|
+
// 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
|
|
765
|
+
try { await stars.removeIds([sid]) } catch (e) { /* 星标清理失败不阻塞删除结果 */ }
|
|
720
766
|
return { ok: true, purged: true }
|
|
721
767
|
}
|
|
722
768
|
|
|
@@ -778,7 +824,10 @@ export function apply(ctx) {
|
|
|
778
824
|
// crash-safe (backup + rollback) and re-syncs the live object.
|
|
779
825
|
const activeId = getActiveSessionId(ctx)
|
|
780
826
|
if (activeId != null && String(activeId) === String(sid)) {
|
|
781
|
-
|
|
827
|
+
const error = new Error('会话正被 DSH 打开,暂时无法移动;请重启 DSH 后再试。')
|
|
828
|
+
error.status = 409
|
|
829
|
+
error.code = 'DSM_SESSION_BUSY'
|
|
830
|
+
throw error
|
|
782
831
|
}
|
|
783
832
|
const r = await persistence.readSession(sid, 0)
|
|
784
833
|
if (!r || !r.meta) throw new Error('无法读取该会话的日志')
|
|
@@ -798,6 +847,10 @@ export function apply(ctx) {
|
|
|
798
847
|
|
|
799
848
|
const newHeader = Object.assign({}, meta, { cwd: canonical })
|
|
800
849
|
|
|
850
|
+
// 移动过程的非致命提示(如源目录仍有旧代日志未能清理),随成功结果一并
|
|
851
|
+
// 回报给客户端展示。
|
|
852
|
+
const moveNotes = []
|
|
853
|
+
|
|
801
854
|
// 1) Decide relocation strategy. `sessionPersistence.create()` rejects
|
|
802
855
|
// ("already exists in this backend") for ANY session the host has
|
|
803
856
|
// instantiated into its in-memory `states` — and DSH instantiates *every*
|
|
@@ -850,6 +903,8 @@ export function apply(ctx) {
|
|
|
850
903
|
await rename(stagedPath, newPath)
|
|
851
904
|
destinationInstalled = true
|
|
852
905
|
await unlink(backupPath)
|
|
906
|
+
const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
|
|
907
|
+
if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
853
908
|
} catch (e) {
|
|
854
909
|
try { await unlink(stagedPath) } catch (_) {}
|
|
855
910
|
if (destinationInstalled) { try { await unlink(newPath) } catch (_) {} }
|
|
@@ -873,6 +928,17 @@ export function apply(ctx) {
|
|
|
873
928
|
return null
|
|
874
929
|
}
|
|
875
930
|
|
|
931
|
+
// 移动成功后的源目录收尾(legacy 路径):**整体移除**。目标侧已写入校验通过的
|
|
932
|
+
// 权威日志,源目录只要还留着任何一代日志(legacy 旧代 session.jsonl.zstd 或
|
|
933
|
+
// session.vN.jsonl.zstd),就会让同 id 出现在两个项目目录,后端 list/create
|
|
934
|
+
// 立刻报 duplicate。返回未能清理时的残留文件名(供如实回报)。
|
|
935
|
+
const cleanupMovedSourceDir = async (dir) => {
|
|
936
|
+
let entries = null
|
|
937
|
+
try { entries = await readdir(dir) } catch (e) { return [] }
|
|
938
|
+
try { await rm(dir, { recursive: true, force: true }) } catch (e) { return entries }
|
|
939
|
+
return []
|
|
940
|
+
}
|
|
941
|
+
|
|
876
942
|
// Rewriting frame0's cwd now lives in src/zstd-frame.js so it can be
|
|
877
943
|
// regression-tested directly. See that module for why frame boundaries are
|
|
878
944
|
// validated by decompression and why a non-session frame0 is rejected
|
|
@@ -890,7 +956,13 @@ export function apply(ctx) {
|
|
|
890
956
|
}
|
|
891
957
|
}
|
|
892
958
|
try {
|
|
893
|
-
await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
|
|
959
|
+
const movedHandle = await moveSessionToCwd({ sp, sid, header: meta, canonical, events, inheritedEventCount: r.inheritedEventCount })
|
|
960
|
+
if (movedHandle && Array.isArray(movedHandle.reclaimedSiblings) && movedHandle.reclaimedSiblings.length > 0) {
|
|
961
|
+
moveNotes.push(`已顺带回收 ${movedHandle.reclaimedSiblings.length} 处被取代的旧代日志副本(历史移动残留),避免同一会话在多目录重复。`)
|
|
962
|
+
}
|
|
963
|
+
if (movedHandle && movedHandle.sourceCleanup && movedHandle.sourceCleanup.cleaned === false) {
|
|
964
|
+
moveNotes.push(`源目录未能清理干净(残留 ${movedHandle.sourceCleanup.leftover.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
965
|
+
}
|
|
894
966
|
} catch (e) {
|
|
895
967
|
if (e && e.status) throw e
|
|
896
968
|
throw new Error('移动会话日志失败:' + String((e && e.message) || e))
|
|
@@ -937,6 +1009,10 @@ export function apply(ctx) {
|
|
|
937
1009
|
throw new Error('移动后校验失败:会话工作目录未正确更新')
|
|
938
1010
|
}
|
|
939
1011
|
if (backupPath) { try { await unlink(backupPath) } catch (e) {} }
|
|
1012
|
+
if (oldPath) {
|
|
1013
|
+
const keptInSource = await cleanupMovedSourceDir(dirname(oldPath))
|
|
1014
|
+
if (keptInSource.length > 0) moveNotes.push(`源目录未能清理干净(残留 ${keptInSource.join('、')}),若后续移动报“duplicate”请手动清空该目录。`)
|
|
1015
|
+
}
|
|
940
1016
|
} catch (e) {
|
|
941
1017
|
if (ALREADY_EXISTS_RE.test(String((e && e.message) || e))) {
|
|
942
1018
|
// Collision: the session is already materialized in states (archived
|
|
@@ -990,9 +1066,109 @@ export function apply(ctx) {
|
|
|
990
1066
|
workspaceId: target.id,
|
|
991
1067
|
workspaceTitle: target.title,
|
|
992
1068
|
workspacePath: canonical,
|
|
1069
|
+
...(moveNotes.length > 0 ? { notes: moveNotes } : {}),
|
|
993
1070
|
}
|
|
994
1071
|
}
|
|
995
1072
|
|
|
1073
|
+
// ---- 待移动队列(活跃 / 被占用的会话)-----------------------------------
|
|
1074
|
+
// 0.1.5 的单写者锁由持有写句柄的进程掌控,插件既拿不到句柄也没有释放它的公共
|
|
1075
|
+
// API;在占用期硬搬目录会让在写者继续写旧 inode 并在旧路径重建空壳。因此占用时
|
|
1076
|
+
// 只登记排队,等宿主释放(session/disposed)或下次插件
|
|
1077
|
+
// 启动时(会话尚未被打开)自动完成。
|
|
1078
|
+
const QUEUE_NOTE = '已排队:该会话正被 DSH 打开(写所有权只在 DSH 退出时释放)。重启 DSH 时会自动完成,请先别打开它。'
|
|
1079
|
+
|
|
1080
|
+
function isBusyError(e) {
|
|
1081
|
+
if (!e) return false
|
|
1082
|
+
if (e.code === 'DSM_SESSION_BUSY') return true
|
|
1083
|
+
const text = String((e && e.message) || e)
|
|
1084
|
+
return /正被 DSH 打开|already owned/i.test(text)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 移动入口:占用 → 排队;其它错误照旧抛出。返回成功结果或排队结果。
|
|
1088
|
+
async function moveOrQueue(sid, targetPath) {
|
|
1089
|
+
try {
|
|
1090
|
+
return await moveOne(sid, targetPath)
|
|
1091
|
+
} catch (e) {
|
|
1092
|
+
if (!isBusyError(e)) throw e
|
|
1093
|
+
await pendingMoves.queue(sid, targetPath)
|
|
1094
|
+
return { ok: true, moved: false, queued: true, notes: [QUEUE_NOTE] }
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// 启动后的「学步期」:这段时间内 moveOne 的非占用类失败只当环境未就绪,不计入
|
|
1099
|
+
// 「多次失败即放弃」的计数(否则 boot 竞态会把用户的排队项白白丢掉)。
|
|
1100
|
+
const BOOT_WARMUP_MS = 30000
|
|
1101
|
+
const bootedAt = Date.now()
|
|
1102
|
+
|
|
1103
|
+
let queueRunning = false
|
|
1104
|
+
async function runPendingMoves(reason) {
|
|
1105
|
+
if (queueRunning) return { ran: false, moved: 0, kept: 0 }
|
|
1106
|
+
queueRunning = true
|
|
1107
|
+
let moved = 0
|
|
1108
|
+
const notes = []
|
|
1109
|
+
try {
|
|
1110
|
+
const items = await pendingMoves.list()
|
|
1111
|
+
for (const item of items) {
|
|
1112
|
+
try {
|
|
1113
|
+
await moveOne(item.sessionId, item.targetPath)
|
|
1114
|
+
await pendingMoves.remove([item.sessionId])
|
|
1115
|
+
metaCache.invalidate(item.sessionId)
|
|
1116
|
+
moved++
|
|
1117
|
+
notes.push(`排队中的移动已完成:${String(item.sessionId).slice(0, 18)}…`)
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
|
|
1120
|
+
if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
|
|
1121
|
+
const bumped = await pendingMoves.bumpAttempts(item.sessionId)
|
|
1122
|
+
if (bumped && bumped.dropped) notes.push(`排队中的移动多次失败已放弃:${String(item.sessionId).slice(0, 18)}…(${String((e && e.message) || e)})`)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
|
|
1126
|
+
} finally {
|
|
1127
|
+
queueRunning = false
|
|
1128
|
+
}
|
|
1129
|
+
if (notes.length > 0) {
|
|
1130
|
+
try { for (const n of notes) console.warn('[dsh-sessions-manager] ' + n) } catch (e) { /* ignore */ }
|
|
1131
|
+
}
|
|
1132
|
+
void reason
|
|
1133
|
+
return { ran: true, moved, kept: (await pendingMoves.list()).length }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 排队项的补跑时机。**关键前提**:0.1.5 的单写者所有权一经 open 就持有到**进程退出**
|
|
1137
|
+
// 为止——官方没有任何「关闭会话 / 释放所有权」的公开入口(`dsh-commands` 只有
|
|
1138
|
+
// compact/feedback/goal;宿主无 unloadSession/closeSession/evict),而浏览器一连上就会
|
|
1139
|
+
// 自动打开/新建会话,一旦被打开就再也搬不动。所以唯一可靠窗口是**宿主刚起来、浏览器
|
|
1140
|
+
// 还没连上**的那一两秒 ⇒ 启动后立刻试,并在前 30s 内密集重试;之后每 120s 兜底;
|
|
1141
|
+
// 会话真被释放(session/disposed)时 500ms 后再试一次。
|
|
1142
|
+
const queueEffect = typeof ctx.effect === 'function' ? ctx.effect.bind(ctx) : ((fn) => { fn() })
|
|
1143
|
+
if (typeof ctx.on === 'function') {
|
|
1144
|
+
queueEffect(() => ctx.on('session/disposed', (session) => {
|
|
1145
|
+
const id = session && session.id != null ? String(session.id) : null
|
|
1146
|
+
if (!id) return
|
|
1147
|
+
pendingMoves.has(id).then((queued) => {
|
|
1148
|
+
if (!queued) return
|
|
1149
|
+
const t = setTimeout(() => { runPendingMoves('session-disposed').catch(() => {}) }, 500)
|
|
1150
|
+
if (t && typeof t.unref === 'function') t.unref()
|
|
1151
|
+
}).catch(() => {})
|
|
1152
|
+
}))
|
|
1153
|
+
}
|
|
1154
|
+
queueEffect(() => {
|
|
1155
|
+
// 每个启动补跑定时器都要 unref:宿主自己不会闲下来无所谓,但测试里未 unref 的
|
|
1156
|
+
// 30s 定时器会让 node:test 多等 30s 才退出(整套测试被无谓拖长,实测见报告)。
|
|
1157
|
+
const timers = [0, 1000, 3000, 6000, 12000, 30000].map((ms) => {
|
|
1158
|
+
const t = setTimeout(() => {
|
|
1159
|
+
runPendingMoves(ms === 0 ? 'startup' : `startup+${ms}`).catch(() => {})
|
|
1160
|
+
}, ms)
|
|
1161
|
+
if (t && typeof t.unref === 'function') t.unref()
|
|
1162
|
+
return t
|
|
1163
|
+
})
|
|
1164
|
+
// 轻量周期重试:只在队列非空时干活(队列空 = 一次本地 JSON 读取)。
|
|
1165
|
+
const tickTimer = setInterval(() => {
|
|
1166
|
+
pendingMoves.list().then((items) => { if (items.length > 0) return runPendingMoves('tick') }).catch(() => {})
|
|
1167
|
+
}, 120000)
|
|
1168
|
+
if (tickTimer && typeof tickTimer.unref === 'function') tickTimer.unref()
|
|
1169
|
+
return () => { for (const t of timers) clearTimeout(t); clearInterval(tickTimer) }
|
|
1170
|
+
})
|
|
1171
|
+
|
|
996
1172
|
// Force the host's WorkspaceRegistry to rebuild its in-memory sessionPath
|
|
997
1173
|
// index from the durable persistence headers. DSH's WorkspaceEntity.sessionIds
|
|
998
1174
|
// is a *getter* that filters record.sessionIds by `host.sessionPath(id) ===
|
|
@@ -1059,7 +1235,8 @@ export function apply(ctx) {
|
|
|
1059
1235
|
// 1. sp.list() 只调一次(原先列了两遍目录)
|
|
1060
1236
|
// 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
|
|
1061
1237
|
// 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
|
|
1062
|
-
// 3.
|
|
1238
|
+
// 3. 缓存未命中的会话**绝不在请求路径上同步投影**(issue #8:runtime 的
|
|
1239
|
+
// readTitleSnapshots 每条都是整本解码)——入队后台预热,先以占位元数据返回
|
|
1063
1240
|
async function allSessionItemsDetailed(opts = {}) {
|
|
1064
1241
|
let entries = []
|
|
1065
1242
|
let headersOk = false
|
|
@@ -1089,37 +1266,59 @@ export function apply(ctx) {
|
|
|
1089
1266
|
wsByPath = {}
|
|
1090
1267
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
1091
1268
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
1092
|
-
const items = []
|
|
1093
1269
|
const usage = await collectUsage(entries)
|
|
1094
|
-
//
|
|
1270
|
+
// 先按指纹把「缓存命中」与「需要补齐」分开。
|
|
1095
1271
|
const statsById = new Map(visibleIds.map((id) => [
|
|
1096
1272
|
id,
|
|
1097
1273
|
(usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
|
|
1098
1274
|
]))
|
|
1099
|
-
const { missing } = metaCache.partition(visibleIds, statsById)
|
|
1275
|
+
const { cached, missing } = metaCache.partition(visibleIds, statsById)
|
|
1100
1276
|
// P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
|
|
1277
|
+
// cwd/createdAt 回填时以最新 list header 为权威——持久条目里的 cwd 可能因
|
|
1278
|
+
// 移动会话而陈旧(sz 指纹对 cwd 无感知),标题才是索引的产出物。
|
|
1101
1279
|
const persisted = await hydrateFromPersist(missing, statsById)
|
|
1102
|
-
for (const [id, meta] of persisted)
|
|
1280
|
+
for (const [id, meta] of persisted) {
|
|
1281
|
+
const entry = entryById.get(id)
|
|
1282
|
+
const header = entry && entry.header ? entry.header : null
|
|
1283
|
+
metaCache.set(id, statsById.get(id), {
|
|
1284
|
+
title: meta.title,
|
|
1285
|
+
cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
|
|
1286
|
+
createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
|
|
1287
|
+
})
|
|
1288
|
+
}
|
|
1103
1289
|
const stillMissing = missing.filter((id) => !persisted.has(id))
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
|
|
1290
|
+
// issue #8:缺失部分交后台预热(分批 + 让步),请求路径零投影、零日志解码。
|
|
1291
|
+
if (stillMissing.length) enqueueWarm(stillMissing, statsById, entryById)
|
|
1292
|
+
const items = []
|
|
1293
|
+
for (const id of visibleIds) {
|
|
1294
|
+
// metaCache 优先:persisted 命中已带 header 权威 cwd/createdAt 回填进
|
|
1295
|
+
// metaCache(persisted.get 里的 cwd 可能因移动会话而陈旧)。
|
|
1296
|
+
// 完全未命中的会话也不能丢 cwd/createdAt——它们来自 list header,零成本
|
|
1297
|
+
// 且是工作区归属/创建时间的权威来源;只有标题允许占位等后台预热(issue #8)。
|
|
1298
|
+
let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
|
|
1299
|
+
if (!meta) {
|
|
1300
|
+
meta = { title: null, cwd: null, createdAt: null }
|
|
1112
1301
|
const entry = entryById.get(id)
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1302
|
+
const header = entry && entry.header ? entry.header : null
|
|
1303
|
+
if (header) {
|
|
1304
|
+
if (typeof header.cwd === 'string') meta.cwd = header.cwd
|
|
1305
|
+
if (header.createdAt != null) meta.createdAt = header.createdAt
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const it = buildItem(id, meta, usage, !!(opts && opts.usage))
|
|
1309
|
+
items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
1310
|
+
}
|
|
1311
|
+
if (opts && opts.onlyArchived) {
|
|
1312
|
+
// /archived-sessions/list 的语义:只返回归档集里仍然存在的会话,
|
|
1313
|
+
// 且不带 archived 布尔注记(旧路由的输出形状)。
|
|
1314
|
+
const archivedItems = []
|
|
1315
|
+
for (const it of items) {
|
|
1316
|
+
if (!it.archived) continue
|
|
1317
|
+
const { archived: _drop, ...rest } = it
|
|
1318
|
+
archivedItems.push(rest)
|
|
1319
|
+
}
|
|
1320
|
+
return { items: archivedItems, usage }
|
|
1121
1321
|
}
|
|
1122
|
-
persistDecoded(decoded, statsById)
|
|
1123
1322
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
1124
1323
|
// failing sp.list() can never wipe the whole index.
|
|
1125
1324
|
let starredSet = new Set()
|
|
@@ -1207,6 +1406,7 @@ export function apply(ctx) {
|
|
|
1207
1406
|
const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
|
|
1208
1407
|
if (ids.length) {
|
|
1209
1408
|
const usage = await collectUsage(entries)
|
|
1409
|
+
const entryById = new Map(entries.map((entry) => [entry.id, entry]))
|
|
1210
1410
|
const statsById = new Map(ids.map((id) => [
|
|
1211
1411
|
id,
|
|
1212
1412
|
(usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
|
|
@@ -1214,35 +1414,91 @@ export function apply(ctx) {
|
|
|
1214
1414
|
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
1215
1415
|
// P4:与列表构建共用持久标题索引,冷启动零解码。
|
|
1216
1416
|
const persisted = await hydrateFromPersist(missing, statsById)
|
|
1217
|
-
for (const [id, meta] of persisted)
|
|
1417
|
+
for (const [id, meta] of persisted) {
|
|
1418
|
+
const entry = entryById.get(id)
|
|
1419
|
+
const header = entry && entry.header ? entry.header : null
|
|
1420
|
+
metaCache.set(id, statsById.get(id), {
|
|
1421
|
+
title: meta.title,
|
|
1422
|
+
cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
|
|
1423
|
+
createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
|
|
1424
|
+
})
|
|
1425
|
+
}
|
|
1218
1426
|
const rest = missing.filter((id) => !persisted.has(id))
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1427
|
+
// issue #8:缺失标题不在请求路径上同步投影(runtime 投影 = 逐条整本解码),
|
|
1428
|
+
// 交后台预热补齐;侧栏高频轮询会让补齐结果自然浮现。
|
|
1429
|
+
if (rest.length) enqueueWarm(rest, statsById, entryById)
|
|
1222
1430
|
for (const id of ids) {
|
|
1223
|
-
|
|
1224
|
-
if (!meta) {
|
|
1225
|
-
const entry = entries.find((e) => e.id === id)
|
|
1226
|
-
const snapshot = snapshotById.has(id)
|
|
1227
|
-
? snapshotById.get(id)
|
|
1228
|
-
: (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
|
|
1229
|
-
const next = metaFromSnapshot(snapshot)
|
|
1230
|
-
if (entry && entry.header) {
|
|
1231
|
-
if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
|
|
1232
|
-
if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
|
|
1233
|
-
}
|
|
1234
|
-
metaCache.set(id, statsById.get(id), next)
|
|
1235
|
-
if (statsById.get(id)) collectDecoded(id, next)
|
|
1236
|
-
meta = next
|
|
1237
|
-
}
|
|
1431
|
+
const meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
|
|
1238
1432
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
1239
1433
|
}
|
|
1240
|
-
persistDecoded(decoded, statsById)
|
|
1241
1434
|
}
|
|
1435
|
+
// 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
|
|
1436
|
+
// 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
|
|
1437
|
+
// 失效,改为事件类型精判)。普通顶层非空会话不产生条目,payload 最小。
|
|
1438
|
+
const lineage = {}
|
|
1439
|
+
for (const entry of entries) {
|
|
1440
|
+
const info = classifyLineage(entry.header, entry.sizeBytes)
|
|
1441
|
+
if (info) lineage[entry.id] = info
|
|
1442
|
+
}
|
|
1443
|
+
await refineEmptyLineage(lineage, entries)
|
|
1242
1444
|
return {
|
|
1243
1445
|
titles: Object.fromEntries(authorityTitleCache),
|
|
1244
1446
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1245
1447
|
purgedSessionIds: activeTombstones,
|
|
1448
|
+
lineage,
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// 空会话判定缓存:id → { sizeBytes, empty }。日志没变(sizeBytes 相同)
|
|
1453
|
+
// 就不重复解码——authority 会被侧栏高频轮询,缓存让它保持在近零成本。
|
|
1454
|
+
const emptyScanCache = new Map()
|
|
1455
|
+
|
|
1456
|
+
// 0.1.3 空会话精判(事件类型法,见 lineage.js):体积阈值对新格式失效
|
|
1457
|
+
// (头部扩容 + 恒存生命周期帧),改为对「压缩体积 ≤ EMPTY_DECODE_LIMIT」
|
|
1458
|
+
// 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
|
|
1459
|
+
// 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
|
|
1460
|
+
// 宁可漏判一个空会话,不能把有内容的会话错标成空。
|
|
1461
|
+
async function refineEmptyLineage(lineage, entries) {
|
|
1462
|
+
if (!persistence || typeof persistence.inspectSession !== 'function') return
|
|
1463
|
+
for (const entry of entries) {
|
|
1464
|
+
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1465
|
+
if (size === null || size > EMPTY_DECODE_LIMIT) continue
|
|
1466
|
+
const id = String(entry.id)
|
|
1467
|
+
let scanned = emptyScanCache.get(id)
|
|
1468
|
+
if (!scanned || scanned.sizeBytes !== size) {
|
|
1469
|
+
let isEmpty = false
|
|
1470
|
+
try {
|
|
1471
|
+
const types = []
|
|
1472
|
+
await persistence.inspectSession(id, { onEvents: (batch) => {
|
|
1473
|
+
for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
|
|
1474
|
+
} })
|
|
1475
|
+
isEmpty = isEmptyEventTypes(types)
|
|
1476
|
+
} catch (e) { isEmpty = false }
|
|
1477
|
+
scanned = { sizeBytes: size, empty: isEmpty }
|
|
1478
|
+
emptyScanCache.set(id, scanned)
|
|
1479
|
+
}
|
|
1480
|
+
if (scanned.empty) {
|
|
1481
|
+
// 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
|
|
1482
|
+
// classifyLineage 对它们返回 null)。
|
|
1483
|
+
const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
|
|
1484
|
+
info.empty = true
|
|
1485
|
+
} else if (lineage[id]) {
|
|
1486
|
+
// 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
|
|
1487
|
+
// 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
|
|
1488
|
+
const info = lineage[id]
|
|
1489
|
+
info.empty = false
|
|
1490
|
+
if (!info.origin && !info.parentSession) delete lineage[id]
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
// 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
|
|
1494
|
+
// 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
|
|
1495
|
+
const live = new Set()
|
|
1496
|
+
for (const entry of entries) {
|
|
1497
|
+
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1498
|
+
if (size !== null && size <= EMPTY_DECODE_LIMIT) live.add(String(entry.id))
|
|
1499
|
+
}
|
|
1500
|
+
for (const key of emptyScanCache.keys()) {
|
|
1501
|
+
if (!live.has(key)) emptyScanCache.delete(key)
|
|
1246
1502
|
}
|
|
1247
1503
|
}
|
|
1248
1504
|
|
|
@@ -1393,7 +1649,7 @@ export function apply(ctx) {
|
|
|
1393
1649
|
disposers.push(ctx.webServer.register({
|
|
1394
1650
|
kind: 'exact',
|
|
1395
1651
|
path: '/archived-sessions/capabilities',
|
|
1396
|
-
handler: async (req, res) => json(res, capabilities),
|
|
1652
|
+
handler: async (req, res) => json(res, { ...capabilities, buildStamp: BUILD_STAMP }),
|
|
1397
1653
|
}))
|
|
1398
1654
|
|
|
1399
1655
|
disposers.push(ctx.webServer.register({
|
|
@@ -1423,12 +1679,13 @@ export function apply(ctx) {
|
|
|
1423
1679
|
const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
|
|
1424
1680
|
wsByPath = {}
|
|
1425
1681
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1682
|
+
// issue #8:归档列表也复用「缓存直读 + 后台预热」的统一管线。
|
|
1683
|
+
// 旧实现逐条 resolveOne(id)(无 header、无指纹)——冷启动对每条归档
|
|
1684
|
+
// 会话整本解码,且连进程内缓存都永远命中不了。
|
|
1685
|
+
const wanted = new Set(idStrs)
|
|
1686
|
+
const detailed = await allSessionItemsDetailed()
|
|
1687
|
+
const items = detailed.items.filter((it) => wanted.has(String(it.sessionId)) && it.archived)
|
|
1688
|
+
for (const it of items) delete it.archived
|
|
1432
1689
|
json(res, { items })
|
|
1433
1690
|
} catch (e) {
|
|
1434
1691
|
json(res, { error: String((e && e.message) || e) }, 500)
|
|
@@ -1597,7 +1854,7 @@ export function apply(ctx) {
|
|
|
1597
1854
|
const out = await purgeFromTrash(sid)
|
|
1598
1855
|
metaCache.invalidate(sid)
|
|
1599
1856
|
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
1600
|
-
titleIndex.remove([sid])
|
|
1857
|
+
try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
|
|
1601
1858
|
json(res, out)
|
|
1602
1859
|
} catch (e) {
|
|
1603
1860
|
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
@@ -1615,7 +1872,7 @@ export function apply(ctx) {
|
|
|
1615
1872
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1616
1873
|
const results = []
|
|
1617
1874
|
for (const sid of ids) {
|
|
1618
|
-
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); titleIndex.remove([sid]).catch(() => {}) }
|
|
1875
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}) }
|
|
1619
1876
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1620
1877
|
}
|
|
1621
1878
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -1715,6 +1972,110 @@ export function apply(ctx) {
|
|
|
1715
1972
|
},
|
|
1716
1973
|
}))
|
|
1717
1974
|
|
|
1975
|
+
// 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
|
|
1976
|
+
// 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
|
|
1977
|
+
// 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
|
|
1978
|
+
// 标题走既有批量投影(readTitleSnapshots),零整本日志解码。
|
|
1979
|
+
// live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
|
|
1980
|
+
disposers.push(ctx.webServer.register({
|
|
1981
|
+
kind: 'exact',
|
|
1982
|
+
path: '/archived-sessions/lineage-tree',
|
|
1983
|
+
handler: async (req, res) => {
|
|
1984
|
+
try {
|
|
1985
|
+
const body = await readJsonBody(req)
|
|
1986
|
+
const rootId = body && typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
1987
|
+
if (!rootId || !isSafeSessionId(rootId)) return json(res, { error: 'missing sessionId' }, 400)
|
|
1988
|
+
const headerById = new Map()
|
|
1989
|
+
const sizeById = new Map()
|
|
1990
|
+
// 与会话列表同源的可见性判定:回收站项目一律不参与血缘树。
|
|
1991
|
+
const trashedIds = new Set()
|
|
1992
|
+
let purgedIds = []
|
|
1993
|
+
const persistedIds = new Set()
|
|
1994
|
+
try {
|
|
1995
|
+
const store = await readTrashStore()
|
|
1996
|
+
for (const item of store.items) trashedIds.add(String(item.sessionId))
|
|
1997
|
+
purgedIds = store.purgedSessionIds.map(String)
|
|
1998
|
+
} catch (e) { /* best-effort */ }
|
|
1999
|
+
const addHeader = (h, sizeBytes) => {
|
|
2000
|
+
if (!h || h.id == null) return
|
|
2001
|
+
const id = String(h.id)
|
|
2002
|
+
// 回收站里的会话(软删除后日志已搬走)不能出现在血缘树里,否则
|
|
2003
|
+
// 会出现「删掉了侧栏还在」。彻底删除的墓碑在下面按「当前已不存在」
|
|
2004
|
+
// 统一剔除,避免压住后来同 id 重建的会话。
|
|
2005
|
+
if (headerById.has(id) || trashedIds.has(id)) return
|
|
2006
|
+
headerById.set(id, {
|
|
2007
|
+
parentSession: h.parentSession != null ? String(h.parentSession) : null,
|
|
2008
|
+
subagent: h.origin === 'subagent',
|
|
2009
|
+
delegationDepth: Number.isFinite(h.delegationDepth) ? h.delegationDepth : null,
|
|
2010
|
+
createdAt: typeof h.createdAt === 'number' ? h.createdAt : null,
|
|
2011
|
+
})
|
|
2012
|
+
if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
|
|
2013
|
+
}
|
|
2014
|
+
try {
|
|
2015
|
+
for (const entry of await persistence.listEntries()) {
|
|
2016
|
+
addHeader(entry.header, entry.sizeBytes)
|
|
2017
|
+
if (entry && entry.id != null) persistedIds.add(String(entry.id))
|
|
2018
|
+
}
|
|
2019
|
+
} catch (e) { /* best-effort */ }
|
|
2020
|
+
const liveIds = new Set()
|
|
2021
|
+
const live = ctx.get('sessions')
|
|
2022
|
+
try {
|
|
2023
|
+
if (live && typeof live.list === 'function') {
|
|
2024
|
+
live.list().forEach((s) => { liveIds.add(String(s.id)); addHeader(s.header, undefined) })
|
|
2025
|
+
}
|
|
2026
|
+
} catch (e) { /* best-effort */ }
|
|
2027
|
+
// 墓碑只对「持久层里确实没了」的 id 生效:同 id 会话后来重新出现时
|
|
2028
|
+
// 必须让位,不能永久压住新会话(与会话列表同一套规则)。
|
|
2029
|
+
for (const id of purgedIds) {
|
|
2030
|
+
if (!persistedIds.has(id)) headerById.delete(id)
|
|
2031
|
+
}
|
|
2032
|
+
// 子代理索引:parentSession → 直接子代理 id 列表(只收 origin==='subagent')。
|
|
2033
|
+
const kidsOf = new Map()
|
|
2034
|
+
for (const [id, h] of headerById) {
|
|
2035
|
+
if (!h.subagent || !h.parentSession) continue
|
|
2036
|
+
if (!kidsOf.has(h.parentSession)) kidsOf.set(h.parentSession, [])
|
|
2037
|
+
kidsOf.get(h.parentSession).push(id)
|
|
2038
|
+
}
|
|
2039
|
+
// 递归展开:visited 防环,节点总数封顶防病态数据。
|
|
2040
|
+
const MAX_NODES = 500
|
|
2041
|
+
let nodeCount = 0
|
|
2042
|
+
const build = (id, depth, visited) => {
|
|
2043
|
+
nodeCount++
|
|
2044
|
+
const h = headerById.get(id) || {}
|
|
2045
|
+
const childVisited = new Set(visited)
|
|
2046
|
+
childVisited.add(id)
|
|
2047
|
+
const kidIds = nodeCount >= MAX_NODES ? [] : (kidsOf.get(id) || []).filter((k) => !childVisited.has(k))
|
|
2048
|
+
return {
|
|
2049
|
+
sessionId: id,
|
|
2050
|
+
parentSession: h.parentSession || null,
|
|
2051
|
+
delegationDepth: h.delegationDepth,
|
|
2052
|
+
depth,
|
|
2053
|
+
createdAt: h.createdAt || null,
|
|
2054
|
+
sizeBytes: sizeById.has(id) ? sizeById.get(id) : null,
|
|
2055
|
+
live: liveIds.has(id),
|
|
2056
|
+
title: null,
|
|
2057
|
+
children: kidIds.map((k) => build(k, depth + 1, childVisited)),
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
const nodes = (kidsOf.get(rootId) || []).map((k) => build(k, 1, new Set([rootId])))
|
|
2061
|
+
const allIds = [rootId]
|
|
2062
|
+
const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
|
|
2063
|
+
nodes.forEach(walk)
|
|
2064
|
+
const titles = await projectTitles(allIds)
|
|
2065
|
+
const titleOf = (id) => {
|
|
2066
|
+
const snap = titles.get(id)
|
|
2067
|
+
const m = snap ? metaFromSnapshot(snap) : null
|
|
2068
|
+
return (m && m.title) || null
|
|
2069
|
+
}
|
|
2070
|
+
const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
|
|
2071
|
+
nodes.forEach(fill)
|
|
2072
|
+
json(res, { parent: { sessionId: rootId, title: titleOf(rootId) }, nodes })
|
|
2073
|
+
} catch (e) {
|
|
2074
|
+
json(res, { error: String((e && e.message) || e) }, errorStatus(e))
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
}))
|
|
2078
|
+
|
|
1718
2079
|
// Available target workspaces (for the move picker).
|
|
1719
2080
|
disposers.push(ctx.webServer.register({
|
|
1720
2081
|
kind: 'exact',
|
|
@@ -1739,7 +2100,8 @@ export function apply(ctx) {
|
|
|
1739
2100
|
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
1740
2101
|
if (!sid) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1741
2102
|
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
1742
|
-
const moved = await
|
|
2103
|
+
const moved = await moveOrQueue(sid, target)
|
|
2104
|
+
if (moved.queued) return json(res, { sessionId: sid, ...moved })
|
|
1743
2105
|
// 移动会改写日志 frame0 的 cwd:元数据(cwd)已变,主动丢弃缓存条目,
|
|
1744
2106
|
// 不等 mtime 指纹自然失效(Windows 上 mtime 精度较粗,指纹可能不变)。
|
|
1745
2107
|
metaCache.invalidate(sid)
|
|
@@ -1756,6 +2118,77 @@ export function apply(ctx) {
|
|
|
1756
2118
|
},
|
|
1757
2119
|
}))
|
|
1758
2120
|
|
|
2121
|
+
// Move many conversations to one target workspace (issue #7). Sequential
|
|
2122
|
+
// on purpose: each move rewrites frame0 (file IO), and one bad session
|
|
2123
|
+
// must not block the rest. Single reindex at the end covers all moves.
|
|
2124
|
+
disposers.push(ctx.webServer.register({
|
|
2125
|
+
kind: 'exact',
|
|
2126
|
+
path: '/archived-sessions/move-many',
|
|
2127
|
+
handler: async (req, res) => {
|
|
2128
|
+
try {
|
|
2129
|
+
const body = await readJsonBody(req)
|
|
2130
|
+
const ids = Array.isArray(body && body.sessionIds)
|
|
2131
|
+
? [...new Set(body.sessionIds.filter((x) => typeof x === 'string' && x))]
|
|
2132
|
+
: []
|
|
2133
|
+
const target = body && typeof body.targetPath === 'string' ? body.targetPath : null
|
|
2134
|
+
if (!ids.length) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
2135
|
+
if (!target) return json(res, { ok: false, error: 'missing targetPath' }, 400)
|
|
2136
|
+
let moved = 0
|
|
2137
|
+
const failed = []
|
|
2138
|
+
const queued = []
|
|
2139
|
+
for (const sid of ids) {
|
|
2140
|
+
try {
|
|
2141
|
+
const r = await moveOrQueue(sid, target)
|
|
2142
|
+
if (r && r.queued) { queued.push(sid); continue }
|
|
2143
|
+
// frame0 的 cwd 被改写:与单移动同理由,主动丢缓存不等指纹失效。
|
|
2144
|
+
metaCache.invalidate(sid)
|
|
2145
|
+
moved++
|
|
2146
|
+
} catch (e) {
|
|
2147
|
+
const err = e && e.code ? { sessionId: sid, error: String((e && e.message) || e), code: e.code } : { sessionId: sid, error: String((e && e.message) || e) }
|
|
2148
|
+
failed.push(err)
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
try { await reindexRegistry() } catch (e) { /* best-effort */ }
|
|
2152
|
+
json(res, {
|
|
2153
|
+
moved,
|
|
2154
|
+
failed,
|
|
2155
|
+
...(queued.length > 0 ? { queued, notes: [`${queued.length} 个会话已排队(正被 DSH 打开),重启 DSH 后会自动完成;重启后请先别打开它们。`] } : {}),
|
|
2156
|
+
})
|
|
2157
|
+
} catch (e) {
|
|
2158
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2159
|
+
}
|
|
2160
|
+
},
|
|
2161
|
+
}))
|
|
2162
|
+
|
|
2163
|
+
// 待移动队列:查看 / 取消(会话被占用时排队,释放后自动完成)。
|
|
2164
|
+
disposers.push(ctx.webServer.register({
|
|
2165
|
+
kind: 'exact',
|
|
2166
|
+
path: '/archived-sessions/pending-moves',
|
|
2167
|
+
handler: async (req, res) => {
|
|
2168
|
+
try {
|
|
2169
|
+
const items = await pendingMoves.list()
|
|
2170
|
+
json(res, { items: items.map((i) => ({ sessionId: i.sessionId, targetPath: i.targetPath, queuedAt: i.queuedAt, attempts: i.attempts })) })
|
|
2171
|
+
} catch (e) {
|
|
2172
|
+
json(res, { error: String((e && e.message) || e) }, 500)
|
|
2173
|
+
}
|
|
2174
|
+
},
|
|
2175
|
+
}))
|
|
2176
|
+
disposers.push(ctx.webServer.register({
|
|
2177
|
+
kind: 'exact',
|
|
2178
|
+
path: '/archived-sessions/pending-moves/cancel',
|
|
2179
|
+
handler: async (req, res) => {
|
|
2180
|
+
try {
|
|
2181
|
+
const body = await readJsonBody(req)
|
|
2182
|
+
const ids = parseIds(body)
|
|
2183
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
2184
|
+
const removed = await pendingMoves.remove(ids)
|
|
2185
|
+
json(res, { ok: true, removed })
|
|
2186
|
+
} catch (e) {
|
|
2187
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2188
|
+
}
|
|
2189
|
+
},
|
|
2190
|
+
}))
|
|
2191
|
+
|
|
1759
2192
|
// Archive (hide) one session.
|
|
1760
2193
|
disposers.push(ctx.webServer.register({
|
|
1761
2194
|
kind: 'exact',
|