dsh-sessions-manager 3.6.0 → 3.6.2
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 +4 -4
- package/README.md +4 -4
- package/lib/client.js +100 -28
- package/lib/client.js.map +2 -2
- package/lib/index.js +323 -169
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/client/index.jsx +104 -28
- package/src/client/logic.js +29 -0
- package/src/index.js +409 -190
- package/src/session-meta-cache.js +18 -0
- package/src/title-persist-index.js +13 -3
package/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createPendingMoveStore } from './pending-moves.js'
|
|
|
18
18
|
import { sweepStaleStateTemps } from './state-temp-sweep.js'
|
|
19
19
|
import { aggregateStorage } from './storage-stats.js'
|
|
20
20
|
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
21
|
-
import { createSessionMetaCache, fingerprintOf,
|
|
21
|
+
import { createSessionMetaCache, fingerprintOf, persistFingerprintOf } from './session-meta-cache.js'
|
|
22
22
|
import { createTitleIndexStore } from './title-persist-index.js'
|
|
23
23
|
import { createPersistenceAdapter } from './compat/persistence.js'
|
|
24
24
|
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
@@ -135,20 +135,8 @@ function getActiveSessionId(context) {
|
|
|
135
135
|
return null
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
let firstUser = null
|
|
141
|
-
for (const ev of events) {
|
|
142
|
-
if (ev.type === 'session/title' && ev.data && typeof ev.data.title === 'string' && ev.data.title.length) {
|
|
143
|
-
found = ev.data.title
|
|
144
|
-
}
|
|
145
|
-
if (firstUser === null && ev.type === 'user/message' && ev.data && Array.isArray(ev.data.content)) {
|
|
146
|
-
const txt = ev.data.content.filter((b) => b && b.type === 'text').map((b) => b.text).filter(Boolean).join(' ').trim()
|
|
147
|
-
if (txt) firstUser = txt
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return found || firstUser || null
|
|
151
|
-
}
|
|
138
|
+
// foldTitle(v3.6.1 删除路径的兜底折叠)随 v3.6.2 #7 的「删除路径零解码」一并
|
|
139
|
+
// 移除;标题折叠逻辑的权威实现现在是 runtime 投影(src/index.js projectTitlesStatus)。
|
|
152
140
|
|
|
153
141
|
export function apply(ctx) {
|
|
154
142
|
const w = ctx.workspaceRegistry
|
|
@@ -179,9 +167,15 @@ export function apply(ctx) {
|
|
|
179
167
|
const stat = statsById.get(id)
|
|
180
168
|
const entry = store && store[id]
|
|
181
169
|
if (!stat || !entry) continue
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
170
|
+
// 持久索引只认可跨重启的指纹(legacy 的文件指纹 / handle 世代的 sz 指纹,
|
|
171
|
+
// 见 session-meta-cache.persistFingerprintOf——issue #8 之前 handle 世代
|
|
172
|
+
// 从不落盘,导致每次重启都全量重新解码)。
|
|
173
|
+
const fp = persistFingerprintOf(stat)
|
|
174
|
+
if (!fp) continue
|
|
175
|
+
// v3.6.2 #1:title 为 null 的条目必须是「投影确实成功过」(resolved)才可信。
|
|
176
|
+
// v3.6.1 把投影失败当结果落盘的污染条目(null 标题、无标记)在这里被丢弃,
|
|
177
|
+
// 调用方按未命中处理 → 重新入队预热,索引自愈。
|
|
178
|
+
if (entry.fingerprint === fp && (entry.title || entry.resolved)) {
|
|
185
179
|
hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
|
|
186
180
|
}
|
|
187
181
|
}
|
|
@@ -192,16 +186,20 @@ export function apply(ctx) {
|
|
|
192
186
|
// 队列内部已串行化 + 原子替换)。**必须 await** —— 它是持久写入,浮动写入会在
|
|
193
187
|
// 调用方/测试 teardown 已开始清理状态目录时落地(`ENOTEMPTY: rmdir .../state`),
|
|
194
188
|
// 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
|
|
195
|
-
//
|
|
196
|
-
//
|
|
189
|
+
// 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
|
|
190
|
+
// 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
|
|
191
|
+
// v3.6.2 #1:decoded 的值是 { meta, resolved } —— resolved=false(投影失败)
|
|
192
|
+
// 的条目绝不落盘;null 标题只有 resolved=true 才允许(否则读取端会当污染丢弃)。
|
|
197
193
|
async function persistDecoded(decoded, statsById) {
|
|
198
194
|
if (!decoded || !decoded.size) return
|
|
199
195
|
const batch = {}
|
|
200
196
|
const now = Date.now()
|
|
201
|
-
for (const [id,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
197
|
+
for (const [id, pack] of decoded) {
|
|
198
|
+
if (!pack || !pack.resolved) continue
|
|
199
|
+
const meta = pack.meta
|
|
200
|
+
const fp = persistFingerprintOf(statsById.get(id))
|
|
201
|
+
if (!fp) continue
|
|
202
|
+
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now, resolved: 1 }
|
|
205
203
|
}
|
|
206
204
|
if (!Object.keys(batch).length) return
|
|
207
205
|
try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
|
|
@@ -284,65 +282,193 @@ export function apply(ctx) {
|
|
|
284
282
|
|
|
285
283
|
// Resolve one session's display metadata.
|
|
286
284
|
//
|
|
287
|
-
// 成本模型(issue #1
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
285
|
+
// 成本模型(issue #1 → issue #8):runtime 的标题投影(readTitleSnapshots)
|
|
286
|
+
// 在 0.1.x 上对每条非 live 会话都是**整本日志解码**(SessionCorpus.projectMany
|
|
287
|
+
// → inspectPersisted)。列表构建绝不能在请求路径上同步触发它——issue #8 实测:
|
|
288
|
+
// 87 本日志 / 249MB 的库,一次冷启动全量投影把宿主打满 ~90s CPU。
|
|
291
289
|
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
290
|
+
// 因此请求路径只消费两级缓存(metaCache 内存缓存 → titleIndex 持久索引),
|
|
291
|
+
// 缺失的会话交给后台预热队列分批补齐(见 enqueueWarm/runWarm);侧栏高频
|
|
292
|
+
// 轮询 + 管理面板刷新会让补齐结果自然浮现。
|
|
293
|
+
const EMPTY_META = { title: null, cwd: null, createdAt: null }
|
|
294
|
+
|
|
295
|
+
// —— 冷启动后台预热(issue #8)—————————————————————————————————————
|
|
296
|
+
// 单飞队列:请求路径发现缓存未命中的会话,把 { stat, header } 快照丢进
|
|
297
|
+
// warmQueue,由 runWarm 以小批次消费。每批之间 setImmediate 让步事件循环,
|
|
298
|
+
// 绝不独占宿主主线程;结果写 metaCache + 持久标题索引。stat 是入队时刻的
|
|
299
|
+
// 快照——若日志在预热期间又变了,下次请求的指纹比对会再次判未命中并重新
|
|
300
|
+
// 入队,天然自愈。
|
|
301
|
+
const WARM_CHUNK = 4
|
|
302
|
+
const warmQueue = new Map()
|
|
303
|
+
let warmRunning = false
|
|
304
|
+
let warmKickTimer = null
|
|
305
|
+
|
|
306
|
+
// v3.6.2 #1:投影失败的三态处理(issue #8 审计)。旧实现把「投影失败」和
|
|
307
|
+
// 「会话真没标题」混为同一个 null → 失败被写进缓存/持久索引,跨重启固化成
|
|
308
|
+
// 永久占位且永不重试。现在:
|
|
309
|
+
// - resolved(fulfilled,title 可以是 null = 事实无标题)→ 正常入缓存+落盘;
|
|
310
|
+
// - failed(rejected / 批量异常 / 结果缺条)→ 不缓存、不落盘,按 id 计数退避。
|
|
311
|
+
// 退避:15s×2^n 递增,最多 WARM_MAX_ATTEMPTS 次;此后 parked 一小时后再探一次
|
|
312
|
+
// (坏日志长期停摆时不至于永久静默)。日志指纹任何变化都清零计数重新武装。
|
|
313
|
+
const WARM_MAX_ATTEMPTS = 5
|
|
314
|
+
const warmFail = new Map() // id → { count, fp, nextAt }
|
|
315
|
+
|
|
316
|
+
function warmFpOf(stat) {
|
|
317
|
+
return fingerprintOf(stat) || persistFingerprintOf(stat)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function warmBlocked(id, fp) {
|
|
321
|
+
const rec = warmFail.get(id)
|
|
322
|
+
if (!rec) return 0
|
|
323
|
+
if (fp && rec.fp && rec.fp !== fp) { warmFail.delete(id); return 0 } // 内容变了 → 重新武装
|
|
324
|
+
const now = Date.now()
|
|
325
|
+
return rec.nextAt > now ? rec.nextAt - now : 0
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function warmMarkFailure(id, stat, header) {
|
|
329
|
+
const rec = warmFail.get(id) || { count: 0, fp: null, nextAt: 0 }
|
|
330
|
+
rec.count += 1
|
|
331
|
+
rec.fp = warmFpOf(stat) || rec.fp
|
|
332
|
+
// 退避到点后由 scheduleWarmRetry 原样重新入队(含本轮的 stat/header 快照)。
|
|
333
|
+
if (stat) rec.stat = stat
|
|
334
|
+
if (header) rec.header = header
|
|
335
|
+
const backoff = rec.count >= WARM_MAX_ATTEMPTS
|
|
336
|
+
? 60 * 60 * 1000
|
|
337
|
+
: Math.min(5 * 60 * 1000, 15 * 1000 * Math.pow(2, rec.count))
|
|
338
|
+
rec.nextAt = Date.now() + backoff
|
|
339
|
+
warmFail.set(id, rec)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 队列里还有「未放弃」的活 → 侧栏/面板应缩短轮询节拍(client 消费)。
|
|
343
|
+
function warmPendingNow() {
|
|
344
|
+
if (!warmHasApi) return false
|
|
345
|
+
if (warmRunning || warmQueue.size) return true
|
|
346
|
+
for (const rec of warmFail.values()) if (rec.nextAt > Date.now()) return true
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 退避到点的条目由一个 unref 定时器重新入队(每轮只挂最近的一个)。
|
|
351
|
+
let warmRetryTimer = null
|
|
352
|
+
function scheduleWarmRetry() {
|
|
353
|
+
if (warmRetryTimer) return
|
|
354
|
+
let soonest = 0
|
|
355
|
+
for (const rec of warmFail.values()) {
|
|
356
|
+
const wait = rec.nextAt - Date.now()
|
|
357
|
+
if (wait <= 0) { soonest = 1; break }
|
|
358
|
+
if (!soonest || wait < soonest) soonest = wait
|
|
311
359
|
}
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
360
|
+
if (!soonest) return
|
|
361
|
+
warmRetryTimer = setTimeout(() => {
|
|
362
|
+
warmRetryTimer = null
|
|
363
|
+
// 到点的条目重新入队(enqueue 时会重新校验 blocked/fp)。
|
|
364
|
+
const now = Date.now()
|
|
365
|
+
for (const [id, rec] of warmFail) {
|
|
366
|
+
if (rec.nextAt <= now && !warmQueue.has(id)) warmQueue.set(id, { stat: rec.stat || null, header: rec.header || null })
|
|
367
|
+
}
|
|
368
|
+
if (warmQueue.size) scheduleWarm()
|
|
369
|
+
}, soonest)
|
|
370
|
+
if (typeof warmRetryTimer.unref === 'function') warmRetryTimer.unref()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function scheduleWarm() {
|
|
374
|
+
if (warmKickTimer || warmRunning) return
|
|
375
|
+
warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
|
|
376
|
+
// unref:预热绝不反过来把宿主进程钉住(与 autoArchive「无定时器」同一原则)。
|
|
377
|
+
if (typeof warmKickTimer.unref === 'function') warmKickTimer.unref()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function enqueueWarm(ids, statsById, entryById) {
|
|
381
|
+
if (!ids || !ids.length) return
|
|
382
|
+
// 无投影 API 的 runtime:预热注定无果,不入队(占位行为退回「列表不带标题」
|
|
383
|
+
// 的诚实形态,而不是让退避定时器永远转下去)。
|
|
384
|
+
if (!warmHasApi) return
|
|
385
|
+
for (const id of ids) {
|
|
386
|
+
const key = String(id)
|
|
387
|
+
const stat = statsById ? (statsById.get(key) || null) : null
|
|
388
|
+
// #9:任何指纹都算不出来的会话(live 未落盘等)既进不了内存缓存也进不了
|
|
389
|
+
// 持久索引——投影结果注定无处安放,每轮轮询重复入队只是白烧 CPU(churn)。
|
|
390
|
+
// 跳过;这类行的标题由 DSH 自己的 live 渲染保证。
|
|
391
|
+
const fp = warmFpOf(stat)
|
|
392
|
+
if (!fp) continue
|
|
393
|
+
// #1:仍在失败退避窗口内的条目不重复入队(runWarm 会按 blocked 跳过)。
|
|
394
|
+
const blockedMs = warmBlocked(key, fp)
|
|
395
|
+
const entry = entryById ? entryById.get(key) : null
|
|
396
|
+
const rec = warmFail.get(key)
|
|
397
|
+
if (rec) { rec.stat = stat || rec.stat; if (entry) rec.header = entry.header || rec.header }
|
|
398
|
+
if (blockedMs > 0) continue
|
|
399
|
+
warmQueue.set(key, {
|
|
400
|
+
stat,
|
|
401
|
+
header: (entry && entry.header) || null,
|
|
402
|
+
})
|
|
324
403
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
404
|
+
if (warmQueue.size) scheduleWarm()
|
|
405
|
+
scheduleWarmRetry()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// #7:软删除时标题可能还没预热出来(回收站条目落一条 title:null 的诚实记录),
|
|
409
|
+
// 之后任何一轮 warm 解出该会话标题就补写进回收站索引——只补空标题,
|
|
410
|
+
// 绝不覆盖已有值。回收站渲染端(t.title || t.sessionId)随下次拉取自然更新。
|
|
411
|
+
async function backfillTrashTitles(decoded) {
|
|
412
|
+
if (!decoded || !decoded.size) return
|
|
413
|
+
try {
|
|
414
|
+
const items = await readTrash()
|
|
415
|
+
const worth = items.some((t) => !t.title && decoded.get(String(t.sessionId)) && decoded.get(String(t.sessionId)).meta && decoded.get(String(t.sessionId)).meta.title)
|
|
416
|
+
if (!worth) return
|
|
417
|
+
await mutateTrash((store) => {
|
|
418
|
+
for (const item of store.items) {
|
|
419
|
+
if (item.title) continue
|
|
420
|
+
const pack = decoded.get(String(item.sessionId))
|
|
421
|
+
const t = pack && pack.meta && pack.meta.title
|
|
422
|
+
if (t) item.title = String(t)
|
|
338
423
|
}
|
|
339
|
-
|
|
340
|
-
|
|
424
|
+
})
|
|
425
|
+
} catch (e) { /* 补写失败无害:索引只是加速器 */ }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function runWarm() {
|
|
429
|
+
if (warmRunning) return
|
|
430
|
+
warmRunning = true
|
|
431
|
+
try {
|
|
432
|
+
while (warmQueue.size) {
|
|
433
|
+
const batch = [...warmQueue.entries()].slice(0, WARM_CHUNK)
|
|
434
|
+
for (const [id] of batch) warmQueue.delete(id)
|
|
435
|
+
try {
|
|
436
|
+
const ids = batch.map(([id]) => id)
|
|
437
|
+
const resultById = await projectTitlesStatus(ids)
|
|
438
|
+
const decoded = new Map()
|
|
439
|
+
const statsById = new Map()
|
|
440
|
+
for (const [id, desc] of batch) {
|
|
441
|
+
const r = resultById.get(id)
|
|
442
|
+
if (!r || !r.resolved) {
|
|
443
|
+
// 投影失败:绝不写缓存/持久索引(否则失败被固化为永久占位)。
|
|
444
|
+
warmMarkFailure(id, desc.stat, desc.header)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
warmFail.delete(id)
|
|
448
|
+
const meta = metaFromSnapshot(r.snapshot || null)
|
|
449
|
+
// cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
|
|
450
|
+
// 陈旧;移动会话后尤其如此),投影只负责标题。
|
|
451
|
+
if (desc.header) {
|
|
452
|
+
if (typeof desc.header.cwd === 'string') meta.cwd = desc.header.cwd
|
|
453
|
+
if (desc.header.createdAt != null) meta.createdAt = desc.header.createdAt
|
|
454
|
+
}
|
|
455
|
+
metaCache.set(id, desc.stat, meta)
|
|
456
|
+
statsById.set(id, desc.stat)
|
|
457
|
+
decoded.set(id, { meta, resolved: true })
|
|
458
|
+
}
|
|
459
|
+
await persistDecoded(decoded, statsById)
|
|
460
|
+
// #7:回收站条目删除时可能没有标题(软删除只记录);预热解出来后补写。
|
|
461
|
+
await backfillTrashTitles(decoded)
|
|
462
|
+
} catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
|
|
463
|
+
// 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
|
|
464
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
465
|
+
}
|
|
466
|
+
} finally {
|
|
467
|
+
warmRunning = false
|
|
468
|
+
// 预热期间又有新请求入队 → 再排一轮。
|
|
469
|
+
if (warmQueue.size) scheduleWarm()
|
|
470
|
+
scheduleWarmRetry()
|
|
341
471
|
}
|
|
342
|
-
metaCache.set(key, statInfo, meta)
|
|
343
|
-
// 本条是「真解码」出来的:交给调用方回写持久标题索引(P4 冷启动加速)。
|
|
344
|
-
if (opts.collectDecoded && statInfo) opts.collectDecoded(key, meta)
|
|
345
|
-
return buildItem(key, meta, usage, opts.exposeUsage)
|
|
346
472
|
}
|
|
347
473
|
|
|
348
474
|
// Disk usage + last-write time for every session, in one pass. Also produces
|
|
@@ -373,10 +499,13 @@ export function apply(ctx) {
|
|
|
373
499
|
const id = entry && entry.id != null ? String(entry.id) : (header && header.id != null ? String(header.id) : null)
|
|
374
500
|
if (!id) return
|
|
375
501
|
// SessionHandle 世代:snapshot(header/revision/sizeBytes)是权威轻量
|
|
376
|
-
// 观察,绝不再绕道私有磁盘路径补 stat。
|
|
502
|
+
// 观察,绝不再绕道私有磁盘路径补 stat。revision 进内存缓存指纹;
|
|
503
|
+
// sizeBytes 供 persistFingerprintOf 派生跨重启的 sz 持久指纹(issue #8)。
|
|
377
504
|
if (entry && typeof entry.revision === 'string' && entry.revision) {
|
|
378
505
|
if (Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
379
|
-
statsById.set(id,
|
|
506
|
+
statsById.set(id, Number.isFinite(entry.sizeBytes)
|
|
507
|
+
? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
|
|
508
|
+
: { revision: entry.revision })
|
|
380
509
|
return
|
|
381
510
|
}
|
|
382
511
|
if (entry && Number.isFinite(entry.sizeBytes)) sizeById.set(id, Number(entry.sizeBytes))
|
|
@@ -491,25 +620,21 @@ export function apply(ctx) {
|
|
|
491
620
|
title = header.title || (header.meta && header.meta.title) || null
|
|
492
621
|
}
|
|
493
622
|
if (!title) {
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (snap && snap.title && snap.title.title) title = String(snap.title.title)
|
|
500
|
-
if (snap && snap.session) { if (!cwd) cwd = snap.session.cwd || null }
|
|
501
|
-
} catch (e) { /* fall through */ }
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (!title) {
|
|
623
|
+
// v3.6.2 #7:删除路径**绝不**做标题投影/分块解码——issue #8 的教训:
|
|
624
|
+
// 大日志上单条兜底投影也是整本解压,删除 = 秒级卡宿主。只消费现成缓存:
|
|
625
|
+
// metaCache(用 list 快照算指纹)→ 持久索引 → 侧栏权威缓存。都没有就存
|
|
626
|
+
// null(诚实占位,不再把 cwd/裸 id 冒充标题永久写进回收站),
|
|
627
|
+
// 后台预热解出标题后由 backfillTrashTitles 补写。
|
|
505
628
|
try {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
629
|
+
const delStat = persistenceEntry && typeof persistenceEntry.revision === 'string' && persistenceEntry.revision
|
|
630
|
+
? { revision: persistenceEntry.revision, sizeBytes: Number.isFinite(persistenceEntry.sizeBytes) ? Number(persistenceEntry.sizeBytes) : undefined }
|
|
631
|
+
: (removedPath ? await stat(removedPath).then((st) => ({ mtimeMs: st.mtimeMs, size: st.size })) : null)
|
|
632
|
+
if (delStat) {
|
|
633
|
+
const m = metaCache.get(sid, delStat) || (await hydrateFromPersist([sid], new Map([[sid, delStat]]))).get(sid)
|
|
634
|
+
if (m && m.title) title = String(m.title)
|
|
635
|
+
}
|
|
512
636
|
} catch (e) { /* best-effort */ }
|
|
637
|
+
if (!title) { const at = authorityTitleCache.get(sid); if (at) title = String(at) }
|
|
513
638
|
}
|
|
514
639
|
} catch (e) { /* best-effort */ }
|
|
515
640
|
// Record in the trash index only — the log stays in its workspace dir.
|
|
@@ -521,7 +646,9 @@ export function apply(ctx) {
|
|
|
521
646
|
const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
|
|
522
647
|
await mutateTrash((store) => {
|
|
523
648
|
const entry = {
|
|
524
|
-
|
|
649
|
+
// v3.6.2 #7:不再把 cwd/裸 id 冒充标题永久定格——存 null,预热解出后
|
|
650
|
+
// backfillTrashTitles 补写;渲染端(t.title || t.sessionId)自然降级。
|
|
651
|
+
sessionId: sid, title: title || null, cwd: cwd || null,
|
|
525
652
|
header: header || null, originalPath: removedPath || null,
|
|
526
653
|
sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
|
|
527
654
|
wasArchived: archived, deletedAt: Date.now(),
|
|
@@ -1187,24 +1314,53 @@ export function apply(ctx) {
|
|
|
1187
1314
|
})
|
|
1188
1315
|
}
|
|
1189
1316
|
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
|
|
1317
|
+
// 批量投影(v3.6.2 #1 三态版)。返回 Map<id, { resolved, snapshot|null }>:
|
|
1318
|
+
// resolved=true —— fulfilled 快照(title 可能为 null:会话真的没标题,
|
|
1319
|
+
// 这是事实,可以缓存/落盘);
|
|
1320
|
+
// resolved=false —— rejected / 批量异常 / 结果缺条 / 无投影 API。
|
|
1321
|
+
// 调用方(runWarm)只缓存 resolved 的条目;失败的进退避重试,绝不落盘——
|
|
1322
|
+
// 否则「一次失败」被固化为跨重启的永久占位(v3.6.1 审计 #1)。
|
|
1323
|
+
const warmHasApi = typeof sq.readTitleSnapshots === 'function' || typeof sq.readTitleSnapshot === 'function'
|
|
1324
|
+
|
|
1325
|
+
async function projectTitlesStatus(ids) {
|
|
1194
1326
|
const out = new Map()
|
|
1327
|
+
const markAll = (resolved) => { for (const id of ids) out.set(String(id), { resolved, snapshot: null }) }
|
|
1195
1328
|
if (!ids || !ids.length) return out
|
|
1196
|
-
if (
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1329
|
+
if (!warmHasApi) { markAll(false); return out }
|
|
1330
|
+
if (typeof sq.readTitleSnapshots === 'function') {
|
|
1331
|
+
let results = null
|
|
1332
|
+
try { results = await sq.readTitleSnapshots(ids) } catch (e) { results = null }
|
|
1333
|
+
if (Array.isArray(results)) {
|
|
1334
|
+
results.forEach((result, index) => {
|
|
1335
|
+
const id = String(ids[index])
|
|
1336
|
+
if (result && result.status === 'fulfilled') out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) })
|
|
1337
|
+
else if (result && result.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
|
|
1338
|
+
else if (result) out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) }) // 最老形态:直接是快照
|
|
1339
|
+
else out.set(id, { resolved: false, snapshot: null })
|
|
1340
|
+
})
|
|
1341
|
+
// 结果数组短于请求(runtime 行为异常)→ 缺的条目按失败处理。
|
|
1342
|
+
for (const id of ids) if (!out.has(String(id))) out.set(String(id), { resolved: false, snapshot: null })
|
|
1343
|
+
return out
|
|
1344
|
+
}
|
|
1345
|
+
// 批量形态不认识:退回逐条。
|
|
1346
|
+
}
|
|
1347
|
+
if (typeof sq.readTitleSnapshot === 'function') {
|
|
1348
|
+
for (const raw of ids) {
|
|
1349
|
+
const id = String(raw)
|
|
1350
|
+
try {
|
|
1351
|
+
const r = await sq.readTitleSnapshot(id)
|
|
1352
|
+
if (r && r.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
|
|
1353
|
+
else out.set(id, { resolved: true, snapshot: unwrapSnapshot(r) })
|
|
1354
|
+
} catch (e) { out.set(id, { resolved: false, snapshot: null }) }
|
|
1355
|
+
}
|
|
1356
|
+
return out
|
|
1357
|
+
}
|
|
1358
|
+
markAll(false)
|
|
1205
1359
|
return out
|
|
1206
1360
|
}
|
|
1207
1361
|
|
|
1362
|
+
// 兼容旧调用点(暂无):保留名字不导出,避免误用回「失败即 null」的旧语义。
|
|
1363
|
+
|
|
1208
1364
|
// opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
|
|
1209
1365
|
// the auto-archive sweep need them; the panel list does not).
|
|
1210
1366
|
//
|
|
@@ -1212,7 +1368,8 @@ export function apply(ctx) {
|
|
|
1212
1368
|
// 1. sp.list() 只调一次(原先列了两遍目录)
|
|
1213
1369
|
// 2. 变更令牌优先来自 list 快照:legacy 走 locate+stat,SessionHandle 世代
|
|
1214
1370
|
// 直接用 snapshot.revision(无 locate 可用,也绝不绕私有路径补 stat)
|
|
1215
|
-
// 3.
|
|
1371
|
+
// 3. 缓存未命中的会话**绝不在请求路径上同步投影**(issue #8:runtime 的
|
|
1372
|
+
// readTitleSnapshots 每条都是整本解码)——入队后台预热,先以占位元数据返回
|
|
1216
1373
|
async function allSessionItemsDetailed(opts = {}) {
|
|
1217
1374
|
let entries = []
|
|
1218
1375
|
let headersOk = false
|
|
@@ -1242,37 +1399,59 @@ export function apply(ctx) {
|
|
|
1242
1399
|
wsByPath = {}
|
|
1243
1400
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
1244
1401
|
const currentArchived = new Set((await archivedState().catch(() => ({ archivedSessionIds: [] }))).archivedSessionIds || [])
|
|
1245
|
-
const items = []
|
|
1246
1402
|
const usage = await collectUsage(entries)
|
|
1247
|
-
//
|
|
1403
|
+
// 先按指纹把「缓存命中」与「需要补齐」分开。
|
|
1248
1404
|
const statsById = new Map(visibleIds.map((id) => [
|
|
1249
1405
|
id,
|
|
1250
1406
|
(usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
|
|
1251
1407
|
]))
|
|
1252
|
-
const { missing } = metaCache.partition(visibleIds, statsById)
|
|
1408
|
+
const { cached, missing } = metaCache.partition(visibleIds, statsById)
|
|
1253
1409
|
// P4:missing 里先查持久标题索引(冷启动跳过整本解码),命中的回填内存缓存。
|
|
1410
|
+
// cwd/createdAt 回填时以最新 list header 为权威——持久条目里的 cwd 可能因
|
|
1411
|
+
// 移动会话而陈旧(sz 指纹对 cwd 无感知),标题才是索引的产出物。
|
|
1254
1412
|
const persisted = await hydrateFromPersist(missing, statsById)
|
|
1255
|
-
for (const [id, meta] of persisted)
|
|
1413
|
+
for (const [id, meta] of persisted) {
|
|
1414
|
+
const entry = entryById.get(id)
|
|
1415
|
+
const header = entry && entry.header ? entry.header : null
|
|
1416
|
+
metaCache.set(id, statsById.get(id), {
|
|
1417
|
+
title: meta.title,
|
|
1418
|
+
cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
|
|
1419
|
+
createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
|
|
1420
|
+
})
|
|
1421
|
+
}
|
|
1256
1422
|
const stillMissing = missing.filter((id) => !persisted.has(id))
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1423
|
+
// issue #8:缺失部分交后台预热(分批 + 让步),请求路径零投影、零日志解码。
|
|
1424
|
+
if (stillMissing.length) enqueueWarm(stillMissing, statsById, entryById)
|
|
1425
|
+
const items = []
|
|
1426
|
+
for (const id of visibleIds) {
|
|
1427
|
+
// metaCache 优先:persisted 命中已带 header 权威 cwd/createdAt 回填进
|
|
1428
|
+
// metaCache(persisted.get 里的 cwd 可能因移动会话而陈旧)。
|
|
1429
|
+
// 完全未命中的会话也不能丢 cwd/createdAt——它们来自 list header,零成本
|
|
1430
|
+
// 且是工作区归属/创建时间的权威来源;只有标题允许占位等后台预热(issue #8)。
|
|
1431
|
+
let meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
|
|
1432
|
+
if (!meta) {
|
|
1433
|
+
meta = { title: null, cwd: null, createdAt: null }
|
|
1265
1434
|
const entry = entryById.get(id)
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1435
|
+
const header = entry && entry.header ? entry.header : null
|
|
1436
|
+
if (header) {
|
|
1437
|
+
if (typeof header.cwd === 'string') meta.cwd = header.cwd
|
|
1438
|
+
if (header.createdAt != null) meta.createdAt = header.createdAt
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
const it = buildItem(id, meta, usage, !!(opts && opts.usage))
|
|
1442
|
+
items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
1443
|
+
}
|
|
1444
|
+
if (opts && opts.onlyArchived) {
|
|
1445
|
+
// /archived-sessions/list 的语义:只返回归档集里仍然存在的会话,
|
|
1446
|
+
// 且不带 archived 布尔注记(旧路由的输出形状)。
|
|
1447
|
+
const archivedItems = []
|
|
1448
|
+
for (const it of items) {
|
|
1449
|
+
if (!it.archived) continue
|
|
1450
|
+
const { archived: _drop, ...rest } = it
|
|
1451
|
+
archivedItems.push(rest)
|
|
1452
|
+
}
|
|
1453
|
+
return { items: archivedItems, usage }
|
|
1274
1454
|
}
|
|
1275
|
-
await persistDecoded(decoded, statsById)
|
|
1276
1455
|
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
1277
1456
|
// failing sp.list() can never wipe the whole index.
|
|
1278
1457
|
let starredSet = new Set()
|
|
@@ -1360,6 +1539,7 @@ export function apply(ctx) {
|
|
|
1360
1539
|
const activeTombstones = store.purgedSessionIds.map(String).filter((id) => !present.has(id))
|
|
1361
1540
|
if (ids.length) {
|
|
1362
1541
|
const usage = await collectUsage(entries)
|
|
1542
|
+
const entryById = new Map(entries.map((entry) => [entry.id, entry]))
|
|
1363
1543
|
const statsById = new Map(ids.map((id) => [
|
|
1364
1544
|
id,
|
|
1365
1545
|
(usage.statsById && usage.statsById.get(id)) || { mtimeMs: usage.mtimeById.get(id), size: usage.sizeById.get(id) },
|
|
@@ -1367,30 +1547,23 @@ export function apply(ctx) {
|
|
|
1367
1547
|
const { cached, missing } = metaCache.partition(ids, statsById)
|
|
1368
1548
|
// P4:与列表构建共用持久标题索引,冷启动零解码。
|
|
1369
1549
|
const persisted = await hydrateFromPersist(missing, statsById)
|
|
1370
|
-
for (const [id, meta] of persisted)
|
|
1550
|
+
for (const [id, meta] of persisted) {
|
|
1551
|
+
const entry = entryById.get(id)
|
|
1552
|
+
const header = entry && entry.header ? entry.header : null
|
|
1553
|
+
metaCache.set(id, statsById.get(id), {
|
|
1554
|
+
title: meta.title,
|
|
1555
|
+
cwd: (header && typeof header.cwd === 'string' && header.cwd) ? header.cwd : meta.cwd,
|
|
1556
|
+
createdAt: (header && header.createdAt != null) ? header.createdAt : meta.createdAt,
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1371
1559
|
const rest = missing.filter((id) => !persisted.has(id))
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1560
|
+
// issue #8:缺失标题不在请求路径上同步投影(runtime 投影 = 逐条整本解码),
|
|
1561
|
+
// 交后台预热补齐;侧栏高频轮询会让补齐结果自然浮现。
|
|
1562
|
+
if (rest.length) enqueueWarm(rest, statsById, entryById)
|
|
1375
1563
|
for (const id of ids) {
|
|
1376
|
-
|
|
1377
|
-
if (!meta) {
|
|
1378
|
-
const entry = entries.find((e) => e.id === id)
|
|
1379
|
-
const snapshot = snapshotById.has(id)
|
|
1380
|
-
? snapshotById.get(id)
|
|
1381
|
-
: (typeof sq.readTitleSnapshot === 'function' ? await sq.readTitleSnapshot(id).catch(() => null) : null)
|
|
1382
|
-
const next = metaFromSnapshot(snapshot)
|
|
1383
|
-
if (entry && entry.header) {
|
|
1384
|
-
if (!next.cwd && typeof entry.header.cwd === 'string') next.cwd = entry.header.cwd
|
|
1385
|
-
if (!next.createdAt && entry.header.createdAt != null) next.createdAt = entry.header.createdAt
|
|
1386
|
-
}
|
|
1387
|
-
metaCache.set(id, statsById.get(id), next)
|
|
1388
|
-
if (statsById.get(id)) collectDecoded(id, next)
|
|
1389
|
-
meta = next
|
|
1390
|
-
}
|
|
1564
|
+
const meta = metaCache.get(id, statsById.get(id)) || persisted.get(id) || null
|
|
1391
1565
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
1392
1566
|
}
|
|
1393
|
-
await persistDecoded(decoded, statsById)
|
|
1394
1567
|
}
|
|
1395
1568
|
// 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list snapshot
|
|
1396
1569
|
// 的 header,零解码;空白判定见下方 refineEmptyLineage(0.1.3 起体积法
|
|
@@ -1406,6 +1579,9 @@ export function apply(ctx) {
|
|
|
1406
1579
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1407
1580
|
purgedSessionIds: activeTombstones,
|
|
1408
1581
|
lineage,
|
|
1582
|
+
// v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
|
|
1583
|
+
// 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
|
|
1584
|
+
warmPending: warmPendingNow(),
|
|
1409
1585
|
}
|
|
1410
1586
|
}
|
|
1411
1587
|
|
|
@@ -1418,38 +1594,51 @@ export function apply(ctx) {
|
|
|
1418
1594
|
// 的候选走官方 inspectSession 解码,按事件类型判定。超过上限的日志必有
|
|
1419
1595
|
// 内容,直接跳过(维持体积法/结构分类的结果)。解码失败按「非空」处理:
|
|
1420
1596
|
// 宁可漏判一个空会话,不能把有内容的会话错标成空。
|
|
1597
|
+
// v3.6.2(分支标签延迟审计 #1 的速效半边):旧实现把全部 ≤8KB 候选**串行**
|
|
1598
|
+
// 解码在 sidebar-state 的请求路径里——冷启动(进程内缓存全空)时零解码成本
|
|
1599
|
+
// 的血缘数据被这串慢解码挡在 return 之前,这正是「⑂ 分支」chip 晚出/32s 台阶
|
|
1600
|
+
// 的直接门控。现在:并行限流 4 路 + 每请求预算 REFINE_BUDGET 条(超预算的
|
|
1601
|
+
// 小日志下一拍继续,逐拍收敛;已缓存的条目零成本照常应用,不影响正确性)。
|
|
1602
|
+
// 彻底解绑(精判挪进后台队列)是 3.7.0 的响应契约改造,不在热修范围。
|
|
1603
|
+
const REFINE_BUDGET = 40
|
|
1604
|
+
function applyEmptyScan(lineage, id, empty) {
|
|
1605
|
+
if (empty) {
|
|
1606
|
+
// 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
|
|
1607
|
+
// classifyLineage 对它们返回 null)。
|
|
1608
|
+
const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
|
|
1609
|
+
info.empty = true
|
|
1610
|
+
} else if (lineage[id]) {
|
|
1611
|
+
// 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
|
|
1612
|
+
// 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
|
|
1613
|
+
const info = lineage[id]
|
|
1614
|
+
info.empty = false
|
|
1615
|
+
if (!info.origin && !info.parentSession) delete lineage[id]
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1421
1618
|
async function refineEmptyLineage(lineage, entries) {
|
|
1422
1619
|
if (!persistence || typeof persistence.inspectSession !== 'function') return
|
|
1620
|
+
const limiter = createLimiter(4)
|
|
1621
|
+
const todo = []
|
|
1423
1622
|
for (const entry of entries) {
|
|
1424
1623
|
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1425
1624
|
if (size === null || size > EMPTY_DECODE_LIMIT) continue
|
|
1426
1625
|
const id = String(entry.id)
|
|
1427
|
-
|
|
1428
|
-
if (
|
|
1429
|
-
|
|
1430
|
-
try {
|
|
1431
|
-
const types = []
|
|
1432
|
-
await persistence.inspectSession(id, { onEvents: (batch) => {
|
|
1433
|
-
for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
|
|
1434
|
-
} })
|
|
1435
|
-
isEmpty = isEmptyEventTypes(types)
|
|
1436
|
-
} catch (e) { isEmpty = false }
|
|
1437
|
-
scanned = { sizeBytes: size, empty: isEmpty }
|
|
1438
|
-
emptyScanCache.set(id, scanned)
|
|
1439
|
-
}
|
|
1440
|
-
if (scanned.empty) {
|
|
1441
|
-
// 结构分类没建条目的普通会话也要补上(0.1.3 空会话 header 无血缘字段,
|
|
1442
|
-
// classifyLineage 对它们返回 null)。
|
|
1443
|
-
const info = lineage[id] || (lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: false })
|
|
1444
|
-
info.empty = true
|
|
1445
|
-
} else if (lineage[id]) {
|
|
1446
|
-
// 旧体积法在小日志上的假阳性(如 alpha.2 单事件日志)在这里纠正;
|
|
1447
|
-
// 纠正后若无任何血缘结构,条目整个撤掉,维持「普通会话不产生条目」。
|
|
1448
|
-
const info = lineage[id]
|
|
1449
|
-
info.empty = false
|
|
1450
|
-
if (!info.origin && !info.parentSession) delete lineage[id]
|
|
1451
|
-
}
|
|
1626
|
+
const scanned = emptyScanCache.get(id)
|
|
1627
|
+
if (scanned && scanned.sizeBytes === size) { applyEmptyScan(lineage, id, scanned.empty); continue }
|
|
1628
|
+
if (todo.length < REFINE_BUDGET) todo.push({ id, size })
|
|
1452
1629
|
}
|
|
1630
|
+
await Promise.all(todo.map(({ id, size }) => limiter(async () => {
|
|
1631
|
+
let isEmpty = false
|
|
1632
|
+
try {
|
|
1633
|
+
const types = []
|
|
1634
|
+
await persistence.inspectSession(id, { onEvents: (batch) => {
|
|
1635
|
+
for (const ev of batch || []) if (types.length < 64) types.push(ev && ev.type)
|
|
1636
|
+
} })
|
|
1637
|
+
isEmpty = isEmptyEventTypes(types)
|
|
1638
|
+
} catch (e) { isEmpty = false }
|
|
1639
|
+
emptyScanCache.set(id, { sizeBytes: size, empty: isEmpty })
|
|
1640
|
+
applyEmptyScan(lineage, id, isEmpty)
|
|
1641
|
+
})))
|
|
1453
1642
|
// 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
|
|
1454
1643
|
// 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
|
|
1455
1644
|
const live = new Set()
|
|
@@ -1639,12 +1828,13 @@ export function apply(ctx) {
|
|
|
1639
1828
|
const idStrs = ids.map(String).filter((id) => !hidden.has(id) && (materialized.has(id) || (live && live.get(id))))
|
|
1640
1829
|
wsByPath = {}
|
|
1641
1830
|
try { for (const ent of w.list()) wsByPath[ent.path] = ent } catch (e) { wsByPath = {} }
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1831
|
+
// issue #8:归档列表也复用「缓存直读 + 后台预热」的统一管线。
|
|
1832
|
+
// 旧实现逐条 resolveOne(id)(无 header、无指纹)——冷启动对每条归档
|
|
1833
|
+
// 会话整本解码,且连进程内缓存都永远命中不了。
|
|
1834
|
+
const wanted = new Set(idStrs)
|
|
1835
|
+
const detailed = await allSessionItemsDetailed()
|
|
1836
|
+
const items = detailed.items.filter((it) => wanted.has(String(it.sessionId)) && it.archived)
|
|
1837
|
+
for (const it of items) delete it.archived
|
|
1648
1838
|
json(res, { items })
|
|
1649
1839
|
} catch (e) {
|
|
1650
1840
|
json(res, { error: String((e && e.message) || e) }, 500)
|
|
@@ -1847,7 +2037,7 @@ export function apply(ctx) {
|
|
|
1847
2037
|
path: '/archived-sessions/sessions',
|
|
1848
2038
|
handler: async (req, res) => {
|
|
1849
2039
|
try {
|
|
1850
|
-
json(res, { items: await allSessionItems() })
|
|
2040
|
+
json(res, { items: await allSessionItems(), warmPending: warmPendingNow() })
|
|
1851
2041
|
} catch (e) {
|
|
1852
2042
|
json(res, { error: String((e && e.message) || e) }, 500)
|
|
1853
2043
|
}
|
|
@@ -1934,7 +2124,8 @@ export function apply(ctx) {
|
|
|
1934
2124
|
// 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
|
|
1935
2125
|
// 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
|
|
1936
2126
|
// 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
|
|
1937
|
-
//
|
|
2127
|
+
// 标题走 v3.6.2 的纯缓存读取(metaCache/持久索引/权威缓存),未命中入后台
|
|
2128
|
+
// 预热、本轮返回 null;请求路径零解码。
|
|
1938
2129
|
// live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
|
|
1939
2130
|
disposers.push(ctx.webServer.register({
|
|
1940
2131
|
kind: 'exact',
|
|
@@ -1970,9 +2161,29 @@ export function apply(ctx) {
|
|
|
1970
2161
|
})
|
|
1971
2162
|
if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
|
|
1972
2163
|
}
|
|
2164
|
+
// v3.6.2 #4:标题只读缓存(metaCache → 持久索引 → 权威缓存),未命中
|
|
2165
|
+
// 入后台预热、本轮返回 null。旧实现在请求路径直调 projectTitles——
|
|
2166
|
+
// 展开一个 N 子树的父会话 = N 次整本解码同步在请求路径上(issue #8
|
|
2167
|
+
// 要消灭的形态在本路由的残留)。这里顺手记下指纹与原始 header,供
|
|
2168
|
+
// 缓存读取与 enqueueWarm 使用。
|
|
2169
|
+
const lineageStats = new Map()
|
|
2170
|
+
const lineageHeaders = new Map()
|
|
1973
2171
|
try {
|
|
1974
2172
|
for (const entry of await persistence.listEntries()) {
|
|
1975
2173
|
addHeader(entry.header, entry.sizeBytes)
|
|
2174
|
+
const eid = entry && entry.id != null ? String(entry.id) : null
|
|
2175
|
+
if (eid) {
|
|
2176
|
+
lineageHeaders.set(eid, { header: (entry && entry.header) || null })
|
|
2177
|
+
// 只有 handle 世代(snapshot 带 revision)才建 stats;legacy 的列表快照
|
|
2178
|
+
// 本就没有这些观测,其标题经 sidebarAuthority 的 authorityTitleCache
|
|
2179
|
+
// 兜住。刻意不在这里合成 sz-only stat——会和列表构建的 mtime:size
|
|
2180
|
+
// 指纹在同一索引里乒乓覆盖,引发交替重复解码。
|
|
2181
|
+
if (entry && typeof entry.revision === 'string' && entry.revision) {
|
|
2182
|
+
lineageStats.set(eid, Number.isFinite(entry.sizeBytes)
|
|
2183
|
+
? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
|
|
2184
|
+
: { revision: entry.revision })
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
1976
2187
|
if (entry && entry.id != null) persistedIds.add(String(entry.id))
|
|
1977
2188
|
}
|
|
1978
2189
|
} catch (e) { /* best-effort */ }
|
|
@@ -2020,11 +2231,19 @@ export function apply(ctx) {
|
|
|
2020
2231
|
const allIds = [rootId]
|
|
2021
2232
|
const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
|
|
2022
2233
|
nodes.forEach(walk)
|
|
2023
|
-
|
|
2234
|
+
// v3.6.2 #4:缓存读取 + 预热入队,请求路径零解码。#12:标题按
|
|
2235
|
+
// MAX_TITLE 截断,与列表输出口径一致。
|
|
2236
|
+
const lineageIds = allIds.map(String)
|
|
2237
|
+
const { cached: lineageCached, missing: lineageMissing } = metaCache.partition(lineageIds, lineageStats)
|
|
2238
|
+
const lineagePersisted = await hydrateFromPersist(lineageMissing, lineageStats)
|
|
2239
|
+
for (const [id, meta] of lineagePersisted) metaCache.set(id, lineageStats.get(id), meta)
|
|
2240
|
+
const lineageRest = lineageMissing.filter((id) => !lineagePersisted.has(id))
|
|
2241
|
+
if (lineageRest.length) enqueueWarm(lineageRest, lineageStats, lineageHeaders)
|
|
2242
|
+
const truncateTitle = (t) => (t ? (t.length > MAX_TITLE ? t.slice(0, MAX_TITLE) + '…' : t) : null)
|
|
2024
2243
|
const titleOf = (id) => {
|
|
2025
|
-
const
|
|
2026
|
-
const m =
|
|
2027
|
-
return (m && m.title) || null
|
|
2244
|
+
const key = String(id)
|
|
2245
|
+
const m = lineageCached.get(key) || lineagePersisted.get(key)
|
|
2246
|
+
return truncateTitle((m && m.title) || authorityTitleCache.get(key) || null)
|
|
2028
2247
|
}
|
|
2029
2248
|
const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
|
|
2030
2249
|
nodes.forEach(fill)
|