dsh-sessions-manager 3.6.1 → 3.7.0
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 +21 -10
- package/README.md +23 -12
- package/lib/client.js +1087 -57
- package/lib/client.js.map +2 -2
- package/lib/index.js +1103 -201
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +915 -56
- package/src/client/logic.js +314 -0
- package/src/empty-scan-index.js +118 -0
- package/src/index.js +566 -127
- package/src/lineage.js +18 -14
- package/src/move-notices.js +129 -0
- package/src/saved-filters.js +208 -0
- package/src/tag-index.js +308 -0
- package/src/title-persist-index.js +13 -3
package/src/index.js
CHANGED
|
@@ -14,17 +14,21 @@ 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 { createTagIndex } from './tag-index.js'
|
|
18
|
+
import { createSavedFilters } from './saved-filters.js'
|
|
17
19
|
import { createPendingMoveStore } from './pending-moves.js'
|
|
20
|
+
import { createMoveNoticeStore } from './move-notices.js'
|
|
18
21
|
import { sweepStaleStateTemps } from './state-temp-sweep.js'
|
|
19
22
|
import { aggregateStorage } from './storage-stats.js'
|
|
20
23
|
import { createAutoArchiveStore, pickInactiveCandidates } from './auto-archive.js'
|
|
21
|
-
import { createSessionMetaCache, persistFingerprintOf } from './session-meta-cache.js'
|
|
24
|
+
import { createSessionMetaCache, fingerprintOf, persistFingerprintOf } from './session-meta-cache.js'
|
|
22
25
|
import { createTitleIndexStore } from './title-persist-index.js'
|
|
26
|
+
import { createEmptyScanStore } from './empty-scan-index.js'
|
|
23
27
|
import { createPersistenceAdapter } from './compat/persistence.js'
|
|
24
28
|
import { detectCapabilities, requireCapability } from './compat/capabilities.js'
|
|
25
29
|
import { pathOwnsSession } from './path-guard.js'
|
|
26
30
|
import { purgeSessionArtifacts, moveSessionToCwd } from './handle-era-ops.js'
|
|
27
|
-
import { classifyLineage, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
|
|
31
|
+
import { classifyLineage, emptyScanCandidate, isEmptyEventTypes, EMPTY_DECODE_LIMIT } from './lineage.js'
|
|
28
32
|
|
|
29
33
|
// 构建指纹:build.mjs 以 define 在编译期注入;直接以源码运行(测试)时为 'dev'。
|
|
30
34
|
// 用途:capabilities 带出 buildStamp,一眼判断「运行中的 host 是不是这份构建」——
|
|
@@ -36,7 +40,8 @@ export const name = 'dsh-sessions-manager'
|
|
|
36
40
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
37
41
|
|
|
38
42
|
const MAX_TITLE = 80
|
|
39
|
-
// 插件自有状态目录(星标 / 自动归档 / 标题索引共用)。与 star-index.js、
|
|
43
|
+
// 插件自有状态目录(星标 / 标签 / 保存筛选 / 自动归档 / 标题索引共用)。与 star-index.js、
|
|
44
|
+
// tag-index.js、auto-archive.js
|
|
40
45
|
// 的默认目录保持一致,环境变量同名,便于测试注入。
|
|
41
46
|
const STATE_DIR = process.env.DSH_SESSIONS_MANAGER_STAR_DIR || join(homedir(), '.dsh', 'sessions-manager')
|
|
42
47
|
// Recycle bin (回收站): normal deletes land here instead of being erased.
|
|
@@ -135,20 +140,8 @@ function getActiveSessionId(context) {
|
|
|
135
140
|
return null
|
|
136
141
|
}
|
|
137
142
|
|
|
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
|
-
}
|
|
143
|
+
// foldTitle(v3.6.1 删除路径的兜底折叠)随 v3.6.2 #7 的「删除路径零解码」一并
|
|
144
|
+
// 移除;标题折叠逻辑的权威实现现在是 runtime 投影(src/index.js projectTitlesStatus)。
|
|
152
145
|
|
|
153
146
|
export function apply(ctx) {
|
|
154
147
|
const w = ctx.workspaceRegistry
|
|
@@ -166,6 +159,10 @@ export function apply(ctx) {
|
|
|
166
159
|
// 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
|
|
167
160
|
// 重启后也直接复用。见 src/title-persist-index.js。
|
|
168
161
|
const titleIndex = createTitleIndexStore({ dir: TRASH_DIR, file: join(TRASH_DIR, 'title-index.json') })
|
|
162
|
+
// T1(3.7.0):空白精判结论的持久层。只有**真解码成功**的判定落盘(unknown
|
|
163
|
+
// 与失败兜底都不是结论),sz 指纹 + 14 天 TTL 双闸门防「同尺寸原地改写」。
|
|
164
|
+
const emptyIndex = createEmptyScanStore({ dir: TRASH_DIR })
|
|
165
|
+
const EMPTY_VERDICT_TTL_MS = 14 * 24 * 3600 * 1000
|
|
169
166
|
|
|
170
167
|
// P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
|
|
171
168
|
// 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
|
|
@@ -184,7 +181,10 @@ export function apply(ctx) {
|
|
|
184
181
|
// 从不落盘,导致每次重启都全量重新解码)。
|
|
185
182
|
const fp = persistFingerprintOf(stat)
|
|
186
183
|
if (!fp) continue
|
|
187
|
-
|
|
184
|
+
// v3.6.2 #1:title 为 null 的条目必须是「投影确实成功过」(resolved)才可信。
|
|
185
|
+
// v3.6.1 把投影失败当结果落盘的污染条目(null 标题、无标记)在这里被丢弃,
|
|
186
|
+
// 调用方按未命中处理 → 重新入队预热,索引自愈。
|
|
187
|
+
if (entry.fingerprint === fp && (entry.title || entry.resolved)) {
|
|
188
188
|
hits.set(id, { title: entry.title, cwd: entry.cwd, createdAt: entry.createdAt })
|
|
189
189
|
}
|
|
190
190
|
}
|
|
@@ -197,14 +197,18 @@ export function apply(ctx) {
|
|
|
197
197
|
// 且"响应已返回但索引没落盘"时进程若恰好退出就会丢条目。
|
|
198
198
|
// 指纹用 persistFingerprintOf:legacy 文件指纹 + handle 世代 sz(sizeBytes) 指纹
|
|
199
199
|
// 都可跨重启;rev: 指纹仍被排除(实例内 opaque token,issue #8)。
|
|
200
|
+
// v3.6.2 #1:decoded 的值是 { meta, resolved } —— resolved=false(投影失败)
|
|
201
|
+
// 的条目绝不落盘;null 标题只有 resolved=true 才允许(否则读取端会当污染丢弃)。
|
|
200
202
|
async function persistDecoded(decoded, statsById) {
|
|
201
203
|
if (!decoded || !decoded.size) return
|
|
202
204
|
const batch = {}
|
|
203
205
|
const now = Date.now()
|
|
204
|
-
for (const [id,
|
|
206
|
+
for (const [id, pack] of decoded) {
|
|
207
|
+
if (!pack || !pack.resolved) continue
|
|
208
|
+
const meta = pack.meta
|
|
205
209
|
const fp = persistFingerprintOf(statsById.get(id))
|
|
206
210
|
if (!fp) continue
|
|
207
|
-
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now }
|
|
211
|
+
batch[id] = { title: meta.title, cwd: meta.cwd, createdAt: meta.createdAt, fingerprint: fp, updatedAt: now, resolved: 1 }
|
|
208
212
|
}
|
|
209
213
|
if (!Object.keys(batch).length) return
|
|
210
214
|
try { await titleIndex.merge(batch) } catch (e) { /* 索引写失败不影响本次响应 */ }
|
|
@@ -276,8 +280,9 @@ export function apply(ctx) {
|
|
|
276
280
|
workspaceGone: !!(cwd && !ws),
|
|
277
281
|
hasWorkspace: !!cwd,
|
|
278
282
|
}
|
|
279
|
-
// sizeBytes / updatedAt
|
|
280
|
-
//
|
|
283
|
+
// sizeBytes / updatedAt 只在需要的路由里带出(存储分析 / 自动归档 / 会话
|
|
284
|
+
// 列表——3.7.0 起列表也要:分支组时间回退键读 updatedAt)。它们本就来自
|
|
285
|
+
// usage,只是 payload 体积,不引入任何解码。
|
|
281
286
|
if (exposeUsage && usage) {
|
|
282
287
|
if (usage.sizeById && usage.sizeById.has(key)) base.sizeBytes = usage.sizeById.get(key)
|
|
283
288
|
if (usage.mtimeById && usage.mtimeById.has(key)) base.updatedAt = usage.mtimeById.get(key)
|
|
@@ -308,6 +313,73 @@ export function apply(ctx) {
|
|
|
308
313
|
let warmRunning = false
|
|
309
314
|
let warmKickTimer = null
|
|
310
315
|
|
|
316
|
+
// v3.6.2 #1:投影失败的三态处理(issue #8 审计)。旧实现把「投影失败」和
|
|
317
|
+
// 「会话真没标题」混为同一个 null → 失败被写进缓存/持久索引,跨重启固化成
|
|
318
|
+
// 永久占位且永不重试。现在:
|
|
319
|
+
// - resolved(fulfilled,title 可以是 null = 事实无标题)→ 正常入缓存+落盘;
|
|
320
|
+
// - failed(rejected / 批量异常 / 结果缺条)→ 不缓存、不落盘,按 id 计数退避。
|
|
321
|
+
// 退避:15s×2^n 递增,最多 WARM_MAX_ATTEMPTS 次;此后 parked 一小时后再探一次
|
|
322
|
+
// (坏日志长期停摆时不至于永久静默)。日志指纹任何变化都清零计数重新武装。
|
|
323
|
+
const WARM_MAX_ATTEMPTS = 5
|
|
324
|
+
const warmFail = new Map() // id → { count, fp, nextAt }
|
|
325
|
+
|
|
326
|
+
function warmFpOf(stat) {
|
|
327
|
+
return fingerprintOf(stat) || persistFingerprintOf(stat)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function warmBlocked(id, fp) {
|
|
331
|
+
const rec = warmFail.get(id)
|
|
332
|
+
if (!rec) return 0
|
|
333
|
+
if (fp && rec.fp && rec.fp !== fp) { warmFail.delete(id); return 0 } // 内容变了 → 重新武装
|
|
334
|
+
const now = Date.now()
|
|
335
|
+
return rec.nextAt > now ? rec.nextAt - now : 0
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function warmMarkFailure(id, stat, header) {
|
|
339
|
+
const rec = warmFail.get(id) || { count: 0, fp: null, nextAt: 0 }
|
|
340
|
+
rec.count += 1
|
|
341
|
+
rec.fp = warmFpOf(stat) || rec.fp
|
|
342
|
+
// 退避到点后由 scheduleWarmRetry 原样重新入队(含本轮的 stat/header 快照)。
|
|
343
|
+
if (stat) rec.stat = stat
|
|
344
|
+
if (header) rec.header = header
|
|
345
|
+
const backoff = rec.count >= WARM_MAX_ATTEMPTS
|
|
346
|
+
? 60 * 60 * 1000
|
|
347
|
+
: Math.min(5 * 60 * 1000, 15 * 1000 * Math.pow(2, rec.count))
|
|
348
|
+
rec.nextAt = Date.now() + backoff
|
|
349
|
+
warmFail.set(id, rec)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 队列里还有「未放弃」的活 → 侧栏/面板应缩短轮询节拍(client 消费)。
|
|
353
|
+
function warmPendingNow() {
|
|
354
|
+
if (!warmHasApi) return false
|
|
355
|
+
if (warmRunning || warmQueue.size) return true
|
|
356
|
+
for (const rec of warmFail.values()) if (rec.nextAt > Date.now()) return true
|
|
357
|
+
return false
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 退避到点的条目由一个 unref 定时器重新入队(每轮只挂最近的一个)。
|
|
361
|
+
let warmRetryTimer = null
|
|
362
|
+
function scheduleWarmRetry() {
|
|
363
|
+
if (warmRetryTimer) return
|
|
364
|
+
let soonest = 0
|
|
365
|
+
for (const rec of warmFail.values()) {
|
|
366
|
+
const wait = rec.nextAt - Date.now()
|
|
367
|
+
if (wait <= 0) { soonest = 1; break }
|
|
368
|
+
if (!soonest || wait < soonest) soonest = wait
|
|
369
|
+
}
|
|
370
|
+
if (!soonest) return
|
|
371
|
+
warmRetryTimer = setTimeout(() => {
|
|
372
|
+
warmRetryTimer = null
|
|
373
|
+
// 到点的条目重新入队(enqueue 时会重新校验 blocked/fp)。
|
|
374
|
+
const now = Date.now()
|
|
375
|
+
for (const [id, rec] of warmFail) {
|
|
376
|
+
if (rec.nextAt <= now && !warmQueue.has(id)) warmQueue.set(id, { stat: rec.stat || null, header: rec.header || null })
|
|
377
|
+
}
|
|
378
|
+
if (warmQueue.size) scheduleWarm()
|
|
379
|
+
}, soonest)
|
|
380
|
+
if (typeof warmRetryTimer.unref === 'function') warmRetryTimer.unref()
|
|
381
|
+
}
|
|
382
|
+
|
|
311
383
|
function scheduleWarm() {
|
|
312
384
|
if (warmKickTimer || warmRunning) return
|
|
313
385
|
warmKickTimer = setTimeout(() => { warmKickTimer = null; runWarm() }, 25)
|
|
@@ -317,15 +389,50 @@ export function apply(ctx) {
|
|
|
317
389
|
|
|
318
390
|
function enqueueWarm(ids, statsById, entryById) {
|
|
319
391
|
if (!ids || !ids.length) return
|
|
392
|
+
// 无投影 API 的 runtime:预热注定无果,不入队(占位行为退回「列表不带标题」
|
|
393
|
+
// 的诚实形态,而不是让退避定时器永远转下去)。
|
|
394
|
+
if (!warmHasApi) return
|
|
320
395
|
for (const id of ids) {
|
|
321
396
|
const key = String(id)
|
|
397
|
+
const stat = statsById ? (statsById.get(key) || null) : null
|
|
398
|
+
// #9:任何指纹都算不出来的会话(live 未落盘等)既进不了内存缓存也进不了
|
|
399
|
+
// 持久索引——投影结果注定无处安放,每轮轮询重复入队只是白烧 CPU(churn)。
|
|
400
|
+
// 跳过;这类行的标题由 DSH 自己的 live 渲染保证。
|
|
401
|
+
const fp = warmFpOf(stat)
|
|
402
|
+
if (!fp) continue
|
|
403
|
+
// #1:仍在失败退避窗口内的条目不重复入队(runWarm 会按 blocked 跳过)。
|
|
404
|
+
const blockedMs = warmBlocked(key, fp)
|
|
322
405
|
const entry = entryById ? entryById.get(key) : null
|
|
406
|
+
const rec = warmFail.get(key)
|
|
407
|
+
if (rec) { rec.stat = stat || rec.stat; if (entry) rec.header = entry.header || rec.header }
|
|
408
|
+
if (blockedMs > 0) continue
|
|
323
409
|
warmQueue.set(key, {
|
|
324
|
-
stat
|
|
410
|
+
stat,
|
|
325
411
|
header: (entry && entry.header) || null,
|
|
326
412
|
})
|
|
327
413
|
}
|
|
328
|
-
scheduleWarm()
|
|
414
|
+
if (warmQueue.size) scheduleWarm()
|
|
415
|
+
scheduleWarmRetry()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// #7:软删除时标题可能还没预热出来(回收站条目落一条 title:null 的诚实记录),
|
|
419
|
+
// 之后任何一轮 warm 解出该会话标题就补写进回收站索引——只补空标题,
|
|
420
|
+
// 绝不覆盖已有值。回收站渲染端(t.title || t.sessionId)随下次拉取自然更新。
|
|
421
|
+
async function backfillTrashTitles(decoded) {
|
|
422
|
+
if (!decoded || !decoded.size) return
|
|
423
|
+
try {
|
|
424
|
+
const items = await readTrash()
|
|
425
|
+
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)
|
|
426
|
+
if (!worth) return
|
|
427
|
+
await mutateTrash((store) => {
|
|
428
|
+
for (const item of store.items) {
|
|
429
|
+
if (item.title) continue
|
|
430
|
+
const pack = decoded.get(String(item.sessionId))
|
|
431
|
+
const t = pack && pack.meta && pack.meta.title
|
|
432
|
+
if (t) item.title = String(t)
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
} catch (e) { /* 补写失败无害:索引只是加速器 */ }
|
|
329
436
|
}
|
|
330
437
|
|
|
331
438
|
async function runWarm() {
|
|
@@ -337,12 +444,18 @@ export function apply(ctx) {
|
|
|
337
444
|
for (const [id] of batch) warmQueue.delete(id)
|
|
338
445
|
try {
|
|
339
446
|
const ids = batch.map(([id]) => id)
|
|
340
|
-
const
|
|
447
|
+
const resultById = await projectTitlesStatus(ids)
|
|
341
448
|
const decoded = new Map()
|
|
342
449
|
const statsById = new Map()
|
|
343
450
|
for (const [id, desc] of batch) {
|
|
344
|
-
const
|
|
345
|
-
|
|
451
|
+
const r = resultById.get(id)
|
|
452
|
+
if (!r || !r.resolved) {
|
|
453
|
+
// 投影失败:绝不写缓存/持久索引(否则失败被固化为永久占位)。
|
|
454
|
+
warmMarkFailure(id, desc.stat, desc.header)
|
|
455
|
+
continue
|
|
456
|
+
}
|
|
457
|
+
warmFail.delete(id)
|
|
458
|
+
const meta = metaFromSnapshot(r.snapshot || null)
|
|
346
459
|
// cwd/createdAt 永远以最新 list header 为权威(投影的 session 头可能
|
|
347
460
|
// 陈旧;移动会话后尤其如此),投影只负责标题。
|
|
348
461
|
if (desc.header) {
|
|
@@ -351,9 +464,11 @@ export function apply(ctx) {
|
|
|
351
464
|
}
|
|
352
465
|
metaCache.set(id, desc.stat, meta)
|
|
353
466
|
statsById.set(id, desc.stat)
|
|
354
|
-
|
|
467
|
+
decoded.set(id, { meta, resolved: true })
|
|
355
468
|
}
|
|
356
469
|
await persistDecoded(decoded, statsById)
|
|
470
|
+
// #7:回收站条目删除时可能没有标题(软删除只记录);预热解出来后补写。
|
|
471
|
+
await backfillTrashTitles(decoded)
|
|
357
472
|
} catch (e) { /* 单批失败不影响后续批次:索引/缓存只是加速器 */ }
|
|
358
473
|
// 批间让步:保证宿主事件循环(RPC/心跳/其他插件)始终可调度。
|
|
359
474
|
await new Promise((resolve) => setImmediate(resolve))
|
|
@@ -362,6 +477,7 @@ export function apply(ctx) {
|
|
|
362
477
|
warmRunning = false
|
|
363
478
|
// 预热期间又有新请求入队 → 再排一轮。
|
|
364
479
|
if (warmQueue.size) scheduleWarm()
|
|
480
|
+
scheduleWarmRetry()
|
|
365
481
|
}
|
|
366
482
|
}
|
|
367
483
|
|
|
@@ -467,9 +583,18 @@ export function apply(ctx) {
|
|
|
467
583
|
// when the session is really gone (purge, or externally removed; the latter
|
|
468
584
|
// is caught by gcStars during list builds).
|
|
469
585
|
const stars = createStarIndex()
|
|
586
|
+
// 标签(3.7.0,schema v4):与星标同型、同状态目录(tags.json)。纯用户元数据,
|
|
587
|
+
// rename/merge/delete 只重写标签文档,永不触碰会话本体(方案 §1.3 红线)。
|
|
588
|
+
const tags = createTagIndex()
|
|
589
|
+
// 保存的筛选(schema v1,saved-filters.json):filters 为不透明 JSON,host
|
|
590
|
+
// 只限条数/名称/序列化体积,绝不解析其内容。
|
|
591
|
+
const savedFilters = createSavedFilters()
|
|
470
592
|
// 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
|
|
471
593
|
// 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
|
|
472
594
|
const pendingMoves = createPendingMoveStore()
|
|
595
|
+
// T2:排队移动的后台终局(完成/放弃)落成持久通知,sidebar-state 带出、
|
|
596
|
+
// client 弹出后 ack 清除。独立于队列文件(终局与队列项同生死,挂条目上观察不到)。
|
|
597
|
+
const moveNotices = createMoveNoticeStore()
|
|
473
598
|
// Auto-archive settings live in their own schema-v4 store, off by default.
|
|
474
599
|
const autoArchive = createAutoArchiveStore()
|
|
475
600
|
// 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
|
|
@@ -485,6 +610,17 @@ export function apply(ctx) {
|
|
|
485
610
|
} catch (e) { /* best-effort */ }
|
|
486
611
|
}
|
|
487
612
|
|
|
613
|
+
// 标签的会话侧引用同样要 GC:会话被外部移除(不在 list 基线里)时,
|
|
614
|
+
// assignments 里的键会变成永远列不出来的幽灵。与 gcStars 同挂点、同信任条件。
|
|
615
|
+
async function gcTags(validIds) {
|
|
616
|
+
try {
|
|
617
|
+
const store = await tags.read()
|
|
618
|
+
const valid = new Set(validIds.map(String))
|
|
619
|
+
const gone = Object.keys(store.assignments).filter((id) => !valid.has(id))
|
|
620
|
+
if (gone.length) await tags.removeIds(gone)
|
|
621
|
+
} catch (e) { /* best-effort */ }
|
|
622
|
+
}
|
|
623
|
+
|
|
488
624
|
// Soft-delete one session: record it in the recycle-bin index but KEEP its
|
|
489
625
|
// log in the original workspace directory. Moving the file out (and detaching
|
|
490
626
|
// it from the workspace) orphaned the session into DSH's "未分组" group and
|
|
@@ -514,25 +650,21 @@ export function apply(ctx) {
|
|
|
514
650
|
title = header.title || (header.meta && header.meta.title) || null
|
|
515
651
|
}
|
|
516
652
|
if (!title) {
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (snap && snap.title && snap.title.title) title = String(snap.title.title)
|
|
523
|
-
if (snap && snap.session) { if (!cwd) cwd = snap.session.cwd || null }
|
|
524
|
-
} catch (e) { /* fall through */ }
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if (!title) {
|
|
653
|
+
// v3.6.2 #7:删除路径**绝不**做标题投影/分块解码——issue #8 的教训:
|
|
654
|
+
// 大日志上单条兜底投影也是整本解压,删除 = 秒级卡宿主。只消费现成缓存:
|
|
655
|
+
// metaCache(用 list 快照算指纹)→ 持久索引 → 侧栏权威缓存。都没有就存
|
|
656
|
+
// null(诚实占位,不再把 cwd/裸 id 冒充标题永久写进回收站),
|
|
657
|
+
// 后台预热解出标题后由 backfillTrashTitles 补写。
|
|
528
658
|
try {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
659
|
+
const delStat = persistenceEntry && typeof persistenceEntry.revision === 'string' && persistenceEntry.revision
|
|
660
|
+
? { revision: persistenceEntry.revision, sizeBytes: Number.isFinite(persistenceEntry.sizeBytes) ? Number(persistenceEntry.sizeBytes) : undefined }
|
|
661
|
+
: (removedPath ? await stat(removedPath).then((st) => ({ mtimeMs: st.mtimeMs, size: st.size })) : null)
|
|
662
|
+
if (delStat) {
|
|
663
|
+
const m = metaCache.get(sid, delStat) || (await hydrateFromPersist([sid], new Map([[sid, delStat]]))).get(sid)
|
|
664
|
+
if (m && m.title) title = String(m.title)
|
|
665
|
+
}
|
|
535
666
|
} catch (e) { /* best-effort */ }
|
|
667
|
+
if (!title) { const at = authorityTitleCache.get(sid); if (at) title = String(at) }
|
|
536
668
|
}
|
|
537
669
|
} catch (e) { /* best-effort */ }
|
|
538
670
|
// Record in the trash index only — the log stays in its workspace dir.
|
|
@@ -544,7 +676,9 @@ export function apply(ctx) {
|
|
|
544
676
|
const archived = await mutateArchived((list) => ({ next: null, value: list.includes(sid) })).catch(() => false)
|
|
545
677
|
await mutateTrash((store) => {
|
|
546
678
|
const entry = {
|
|
547
|
-
|
|
679
|
+
// v3.6.2 #7:不再把 cwd/裸 id 冒充标题永久定格——存 null,预热解出后
|
|
680
|
+
// backfillTrashTitles 补写;渲染端(t.title || t.sessionId)自然降级。
|
|
681
|
+
sessionId: sid, title: title || null, cwd: cwd || null,
|
|
548
682
|
header: header || null, originalPath: removedPath || null,
|
|
549
683
|
sizeBytes: persistenceEntry && Number.isFinite(persistenceEntry.sizeBytes) ? persistenceEntry.sizeBytes : null,
|
|
550
684
|
wasArchived: archived, deletedAt: Date.now(),
|
|
@@ -763,6 +897,10 @@ export function apply(ctx) {
|
|
|
763
897
|
// 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
|
|
764
898
|
// 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
|
|
765
899
|
try { await stars.removeIds([sid]) } catch (e) { /* 星标清理失败不阻塞删除结果 */ }
|
|
900
|
+
// 标签 assignments 同理(S4 教训位):清理必须 await、不得浮动写——purgeFromTrash
|
|
901
|
+
// 是唯一的彻底删除收口(trash/purge、trash/purge-many、到期清理都经过它),
|
|
902
|
+
// 在这里落盘即覆盖所有路径,且"删除成功"不早于状态落盘。
|
|
903
|
+
try { await tags.removeIds([sid]) } catch (e) { /* 标签清理失败不阻塞删除结果 */ }
|
|
766
904
|
return { ok: true, purged: true }
|
|
767
905
|
}
|
|
768
906
|
|
|
@@ -1115,11 +1253,17 @@ export function apply(ctx) {
|
|
|
1115
1253
|
metaCache.invalidate(item.sessionId)
|
|
1116
1254
|
moved++
|
|
1117
1255
|
notes.push(`排队中的移动已完成:${String(item.sessionId).slice(0, 18)}…`)
|
|
1256
|
+
// T2:通知落盘失败不吞掉移动结果本身(队列项已删,回滚代价不值——
|
|
1257
|
+
// 保留 console.warn 作最后兜底,这是刻意接受的静默缺口)。
|
|
1258
|
+
try { await moveNotices.append({ kind: 'moved', sessionId: item.sessionId, targetPath: item.targetPath, at: Date.now() }) } catch (e) { /* best-effort */ }
|
|
1118
1259
|
} catch (e) {
|
|
1119
1260
|
if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
|
|
1120
1261
|
if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
|
|
1121
1262
|
const bumped = await pendingMoves.bumpAttempts(item.sessionId)
|
|
1122
|
-
if (bumped && bumped.dropped)
|
|
1263
|
+
if (bumped && bumped.dropped) {
|
|
1264
|
+
notes.push(`排队中的移动多次失败已放弃:${String(item.sessionId).slice(0, 18)}…(${String((e && e.message) || e)})`)
|
|
1265
|
+
try { await moveNotices.append({ kind: 'abandoned', sessionId: item.sessionId, targetPath: item.targetPath, at: Date.now(), attempts: bumped.attempts, reason: String((e && e.message) || e).split('\n')[0] }) } catch (err) { /* best-effort */ }
|
|
1266
|
+
}
|
|
1123
1267
|
}
|
|
1124
1268
|
}
|
|
1125
1269
|
if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
|
|
@@ -1210,24 +1354,53 @@ export function apply(ctx) {
|
|
|
1210
1354
|
})
|
|
1211
1355
|
}
|
|
1212
1356
|
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
|
|
1357
|
+
// 批量投影(v3.6.2 #1 三态版)。返回 Map<id, { resolved, snapshot|null }>:
|
|
1358
|
+
// resolved=true —— fulfilled 快照(title 可能为 null:会话真的没标题,
|
|
1359
|
+
// 这是事实,可以缓存/落盘);
|
|
1360
|
+
// resolved=false —— rejected / 批量异常 / 结果缺条 / 无投影 API。
|
|
1361
|
+
// 调用方(runWarm)只缓存 resolved 的条目;失败的进退避重试,绝不落盘——
|
|
1362
|
+
// 否则「一次失败」被固化为跨重启的永久占位(v3.6.1 审计 #1)。
|
|
1363
|
+
const warmHasApi = typeof sq.readTitleSnapshots === 'function' || typeof sq.readTitleSnapshot === 'function'
|
|
1364
|
+
|
|
1365
|
+
async function projectTitlesStatus(ids) {
|
|
1217
1366
|
const out = new Map()
|
|
1367
|
+
const markAll = (resolved) => { for (const id of ids) out.set(String(id), { resolved, snapshot: null }) }
|
|
1218
1368
|
if (!ids || !ids.length) return out
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1369
|
+
if (!warmHasApi) { markAll(false); return out }
|
|
1370
|
+
if (typeof sq.readTitleSnapshots === 'function') {
|
|
1371
|
+
let results = null
|
|
1372
|
+
try { results = await sq.readTitleSnapshots(ids) } catch (e) { results = null }
|
|
1373
|
+
if (Array.isArray(results)) {
|
|
1374
|
+
results.forEach((result, index) => {
|
|
1375
|
+
const id = String(ids[index])
|
|
1376
|
+
if (result && result.status === 'fulfilled') out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) })
|
|
1377
|
+
else if (result && result.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
|
|
1378
|
+
else if (result) out.set(id, { resolved: true, snapshot: unwrapSnapshot(result) }) // 最老形态:直接是快照
|
|
1379
|
+
else out.set(id, { resolved: false, snapshot: null })
|
|
1380
|
+
})
|
|
1381
|
+
// 结果数组短于请求(runtime 行为异常)→ 缺的条目按失败处理。
|
|
1382
|
+
for (const id of ids) if (!out.has(String(id))) out.set(String(id), { resolved: false, snapshot: null })
|
|
1383
|
+
return out
|
|
1384
|
+
}
|
|
1385
|
+
// 批量形态不认识:退回逐条。
|
|
1386
|
+
}
|
|
1387
|
+
if (typeof sq.readTitleSnapshot === 'function') {
|
|
1388
|
+
for (const raw of ids) {
|
|
1389
|
+
const id = String(raw)
|
|
1390
|
+
try {
|
|
1391
|
+
const r = await sq.readTitleSnapshot(id)
|
|
1392
|
+
if (r && r.status === 'rejected') out.set(id, { resolved: false, snapshot: null })
|
|
1393
|
+
else out.set(id, { resolved: true, snapshot: unwrapSnapshot(r) })
|
|
1394
|
+
} catch (e) { out.set(id, { resolved: false, snapshot: null }) }
|
|
1395
|
+
}
|
|
1396
|
+
return out
|
|
1397
|
+
}
|
|
1398
|
+
markAll(false)
|
|
1228
1399
|
return out
|
|
1229
1400
|
}
|
|
1230
1401
|
|
|
1402
|
+
// 兼容旧调用点(暂无):保留名字不导出,避免误用回「失败即 null」的旧语义。
|
|
1403
|
+
|
|
1231
1404
|
// opts.usage: expose sizeBytes + updatedAt on each item (storage analysis and
|
|
1232
1405
|
// the auto-archive sweep need them; the panel list does not).
|
|
1233
1406
|
//
|
|
@@ -1319,12 +1492,28 @@ export function apply(ctx) {
|
|
|
1319
1492
|
}
|
|
1320
1493
|
return { items: archivedItems, usage }
|
|
1321
1494
|
}
|
|
1322
|
-
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
1495
|
+
// Annotate stars & tags; GC only when we have a trustworthy id baseline, so a
|
|
1323
1496
|
// failing sp.list() can never wipe the whole index.
|
|
1324
1497
|
let starredSet = new Set()
|
|
1325
|
-
|
|
1326
|
-
|
|
1498
|
+
let tagIdsBySession = new Map()
|
|
1499
|
+
try {
|
|
1500
|
+
starredSet = new Set((await stars.read()).starredSessionIds)
|
|
1501
|
+
// Same one-shot read as stars: tag ids are plain metadata, no decoding.
|
|
1502
|
+
// Filter dangling tagIds against the live definitions so a half-deleted
|
|
1503
|
+
// tag can never leak into a rendered row.
|
|
1504
|
+
const tagStore = await tags.read()
|
|
1505
|
+
const knownTagIds = new Set(tagStore.tags.map((t) => t.id))
|
|
1506
|
+
for (const [sid, list] of Object.entries(tagStore.assignments)) {
|
|
1507
|
+
const kept = list.filter((id) => knownTagIds.has(id))
|
|
1508
|
+
if (kept.length) tagIdsBySession.set(sid, kept)
|
|
1509
|
+
}
|
|
1510
|
+
} catch (e) {}
|
|
1511
|
+
for (const it of items) {
|
|
1512
|
+
it.starred = starredSet.has(String(it.sessionId))
|
|
1513
|
+
it.tags = tagIdsBySession.get(String(it.sessionId)) || []
|
|
1514
|
+
}
|
|
1327
1515
|
if (headersOk) await gcStars(ids)
|
|
1516
|
+
if (headersOk) await gcTags(ids)
|
|
1328
1517
|
return { items, usage }
|
|
1329
1518
|
}
|
|
1330
1519
|
|
|
@@ -1432,78 +1621,143 @@ export function apply(ctx) {
|
|
|
1432
1621
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
1433
1622
|
}
|
|
1434
1623
|
}
|
|
1435
|
-
// 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list
|
|
1436
|
-
// 的 header
|
|
1437
|
-
//
|
|
1624
|
+
// 血缘分类(issue #6 + T1/3.7.0):结构分类(子代理 / fork 分支)来自 list
|
|
1625
|
+
// snapshot 的 header,零解码。空白(empty)是**三态**:null=未知(等后台
|
|
1626
|
+
// refine 队列精判)、true/false=已判定(来自进程内缓存或 empty-scan 持久层,
|
|
1627
|
+
// sz 指纹一致 + 14 天 TTL 内才可信)。请求路径零解码承诺由此完整成立:
|
|
1628
|
+
// 超过解码上限的日志「必有内容」按事实直接给 false;legacy 无 sizeBytes
|
|
1629
|
+
// 观测 → 永不候选、恒未知(与旧行为一致)。普通会话只在真判空时建条目,
|
|
1630
|
+
// 「误隐藏」在结构上不可能来自体积法(该路径已整体退役)。
|
|
1438
1631
|
const lineage = {}
|
|
1632
|
+
const refineWanted = []
|
|
1633
|
+
let emptyStore = null
|
|
1634
|
+
try { emptyStore = await emptyIndex.entries() } catch (e) { emptyStore = null }
|
|
1635
|
+
const nowTs = Date.now()
|
|
1439
1636
|
for (const entry of entries) {
|
|
1440
|
-
const
|
|
1441
|
-
|
|
1637
|
+
const id = String(entry.id)
|
|
1638
|
+
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1639
|
+
const candidate = emptyScanCandidate(size)
|
|
1640
|
+
let verdict = null
|
|
1641
|
+
if (!candidate) {
|
|
1642
|
+
if (size !== null) verdict = false // 超出解码上限 = 必有内容(事实,不是猜测)
|
|
1643
|
+
} else {
|
|
1644
|
+
const mem = emptyScanCache.get(id)
|
|
1645
|
+
if (mem && mem.sizeBytes === size) {
|
|
1646
|
+
verdict = !!mem.empty
|
|
1647
|
+
} else {
|
|
1648
|
+
const rec = emptyStore && emptyStore[id]
|
|
1649
|
+
if (rec && rec.fingerprint === `sz:${size}` && nowTs - (rec.updatedAt || 0) <= EMPTY_VERDICT_TTL_MS) {
|
|
1650
|
+
verdict = rec.empty === 1
|
|
1651
|
+
emptyScanCache.set(id, { sizeBytes: size, empty: verdict })
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
if (verdict === null && refineCapable() && !refineQueue.has(id)) refineWanted.push({ id, sizeBytes: size })
|
|
1655
|
+
}
|
|
1656
|
+
const info = classifyLineage(entry.header)
|
|
1657
|
+
if (info) lineage[id] = { origin: info.origin, parentSession: info.parentSession, delegationDepth: info.delegationDepth, empty: verdict }
|
|
1658
|
+
else if (verdict === true) lineage[id] = { origin: null, parentSession: null, delegationDepth: 0, empty: true }
|
|
1442
1659
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1660
|
+
enqueueRefine(refineWanted)
|
|
1661
|
+
// 缓存只保留「本轮仍是候选」的会话,防止 Map 随历史会话无限增长。
|
|
1662
|
+
for (const key of emptyScanCache.keys()) {
|
|
1663
|
+
const rec = emptyScanCache.get(key)
|
|
1664
|
+
if (!rec || !emptyScanCandidate(rec.sizeBytes)) emptyScanCache.delete(key)
|
|
1665
|
+
}
|
|
1666
|
+
const payload = {
|
|
1445
1667
|
titles: Object.fromEntries(authorityTitleCache),
|
|
1446
1668
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1447
1669
|
purgedSessionIds: activeTombstones,
|
|
1448
1670
|
lineage,
|
|
1671
|
+
// v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
|
|
1672
|
+
// 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
|
|
1673
|
+
warmPending: warmPendingNow(),
|
|
1674
|
+
// T1:空白精判后台队列是否还有活。无精判能力(旧 runtime 无 inspect 通道)
|
|
1675
|
+
// 时恒 false——永久 busy 会让 client 疯轮询,这是唯一现实的假忙陷阱。
|
|
1676
|
+
refinePending: refinePendingNow(),
|
|
1449
1677
|
}
|
|
1678
|
+
// T2:有未确认的排队移动终局才带出(空则省略字段——与「普通会话不产生条目」
|
|
1679
|
+
// 同风格的载荷最小化;旧 client 对未知字段天然忽略)。
|
|
1680
|
+
try {
|
|
1681
|
+
const noticeRows = await moveNotices.list()
|
|
1682
|
+
if (noticeRows.length) payload.moveNotices = noticeRows
|
|
1683
|
+
} catch (e) { /* 通知通道坏了不影响侧栏主数据 */ }
|
|
1684
|
+
return payload
|
|
1450
1685
|
}
|
|
1451
1686
|
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1687
|
+
// 空会话判定缓存(进程内):id → { sizeBytes, empty }。日志没变(sizeBytes
|
|
1688
|
+
// 相同)就不重复解码;T1 之后它只是 emptyIndex 持久层之上的热缓存。
|
|
1454
1689
|
const emptyScanCache = new Map()
|
|
1455
1690
|
|
|
1456
|
-
// 0.
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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))
|
|
1691
|
+
// T1(3.7.0):空白精判彻底移出请求路径。v3.6.2 的「预算+限流」只是过渡——
|
|
1692
|
+
// 冷启动(缓存全空)时首拍仍要为精判解码,这正是「⑂ 分支」等零解码血缘被
|
|
1693
|
+
// 挡在 return 之前的最后一块捆绑。现在:sidebar-state 先回三态数据,未判定
|
|
1694
|
+
// 的候选进这条与 runWarm 同型的后台队列(REFINE_CHUNK=2、批间 setImmediate
|
|
1695
|
+
// 让步、成功结论落盘、失败只在进程内按「非空」兜底且**不落盘**——宁可漏判
|
|
1696
|
+
// 一个空白,不能把有内容的会话错标成空,也不能把失败固化成跨重启的结论)。
|
|
1697
|
+
const REFINE_CHUNK = 2
|
|
1698
|
+
const refineQueue = new Map()
|
|
1699
|
+
let refineRunning = false
|
|
1700
|
+
let refineKickTimer = null
|
|
1701
|
+
|
|
1702
|
+
function refineCapable() {
|
|
1703
|
+
return !!persistence && typeof persistence.inspectSession === 'function'
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function refinePendingNow() {
|
|
1707
|
+
return !!(refineRunning || refineQueue.size)
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function scheduleRefine() {
|
|
1711
|
+
if (refineKickTimer || refineRunning || !refineQueue.size) return
|
|
1712
|
+
refineKickTimer = setTimeout(() => { refineKickTimer = null; runRefine() }, 25)
|
|
1713
|
+
if (typeof refineKickTimer.unref === 'function') refineKickTimer.unref()
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function enqueueRefine(items) {
|
|
1717
|
+
if (!items || !items.length || !refineCapable()) return
|
|
1718
|
+
for (const { id, sizeBytes } of items) {
|
|
1719
|
+
if (emptyScanCache.has(id) && emptyScanCache.get(id).sizeBytes === sizeBytes) continue
|
|
1720
|
+
refineQueue.set(id, { sizeBytes })
|
|
1499
1721
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1722
|
+
scheduleRefine()
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
async function runRefine() {
|
|
1726
|
+
if (refineRunning) return
|
|
1727
|
+
refineRunning = true
|
|
1728
|
+
try {
|
|
1729
|
+
while (refineQueue.size) {
|
|
1730
|
+
const batch = [...refineQueue.entries()].slice(0, REFINE_CHUNK)
|
|
1731
|
+
for (const [id] of batch) refineQueue.delete(id)
|
|
1732
|
+
const persistBatch = {}
|
|
1733
|
+
for (const [id, desc] of batch) {
|
|
1734
|
+
try {
|
|
1735
|
+
const types = []
|
|
1736
|
+
await persistence.inspectSession(id, { onEvents: (b) => {
|
|
1737
|
+
for (const ev of b || []) if (types.length < 64) types.push(ev && ev.type)
|
|
1738
|
+
} })
|
|
1739
|
+
const isEmpty = isEmptyEventTypes(types)
|
|
1740
|
+
emptyScanCache.set(id, { sizeBytes: desc.sizeBytes, empty: isEmpty })
|
|
1741
|
+
persistBatch[id] = { empty: isEmpty ? 1 : 0, fingerprint: `sz:${desc.sizeBytes}`, updatedAt: Date.now() }
|
|
1742
|
+
} catch (e) {
|
|
1743
|
+
// 失败兜底:非空(止 churn),但只进内存,不落盘。
|
|
1744
|
+
emptyScanCache.set(id, { sizeBytes: desc.sizeBytes, empty: false })
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
if (Object.keys(persistBatch).length) {
|
|
1748
|
+
try { await emptyIndex.merge(persistBatch) } catch (e) { /* 持久层只是加速器 */ }
|
|
1749
|
+
}
|
|
1750
|
+
// 批间让步:与 runWarm 同一纪律,绝不让后台精判独占宿主事件循环。
|
|
1751
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
1752
|
+
}
|
|
1753
|
+
} finally {
|
|
1754
|
+
refineRunning = false
|
|
1755
|
+
if (refineQueue.size) scheduleRefine()
|
|
1502
1756
|
}
|
|
1503
1757
|
}
|
|
1504
1758
|
|
|
1505
1759
|
// 聚合一条会话的详情(磁盘占用 / 轮次·步数·消息数 / 工具统计 / fetch /
|
|
1506
|
-
// write/edit 文件 /
|
|
1760
|
+
// write/edit 文件 / 血缘 parent/children/subagents)。live 与持久化会话都可读。
|
|
1507
1761
|
// 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
|
|
1508
1762
|
// 磁盘上已不存在的路径,避免详情面板列出已删除文件。
|
|
1509
1763
|
// 持久化会话走 inspectSession 分块折叠:大日志不再整本驻留内存(0.1.3-alpha
|
|
@@ -1855,6 +2109,7 @@ export function apply(ctx) {
|
|
|
1855
2109
|
metaCache.invalidate(sid)
|
|
1856
2110
|
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
1857
2111
|
try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
|
|
2112
|
+
try { await emptyIndex.remove([sid]) } catch (e) { /* 同上 */ }
|
|
1858
2113
|
json(res, out)
|
|
1859
2114
|
} catch (e) {
|
|
1860
2115
|
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
@@ -1872,7 +2127,7 @@ export function apply(ctx) {
|
|
|
1872
2127
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
1873
2128
|
const results = []
|
|
1874
2129
|
for (const sid of ids) {
|
|
1875
|
-
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}) }
|
|
2130
|
+
try { results.push({ sessionId: sid, ok: true, ...(await purgeFromTrash(sid)) }); metaCache.invalidate(sid); await titleIndex.remove([sid]).catch(() => {}); await emptyIndex.remove([sid]).catch(() => {}) }
|
|
1876
2131
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
1877
2132
|
}
|
|
1878
2133
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -1888,7 +2143,10 @@ export function apply(ctx) {
|
|
|
1888
2143
|
path: '/archived-sessions/sessions',
|
|
1889
2144
|
handler: async (req, res) => {
|
|
1890
2145
|
try {
|
|
1891
|
-
|
|
2146
|
+
// usage:true:updatedAt(=日志文件 mtime) 随行带出——3.7.0 分支聚拢的
|
|
2147
|
+
// createdAt 缺失回退键(logic.branchTimeKey)。collectUsage 本就在
|
|
2148
|
+
// allSessionItemsDetailed 内无条件执行,成本≈纯 payload 增加。
|
|
2149
|
+
json(res, { items: await allSessionItems({ usage: true }), warmPending: warmPendingNow() })
|
|
1892
2150
|
} catch (e) {
|
|
1893
2151
|
json(res, { error: String((e && e.message) || e) }, 500)
|
|
1894
2152
|
}
|
|
@@ -1907,11 +2165,145 @@ export function apply(ctx) {
|
|
|
1907
2165
|
if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
|
|
1908
2166
|
ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
|
|
1909
2167
|
}
|
|
1910
|
-
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
2168
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, code: 'DSM_STAR_SESSION_INVALID', error: 'missing sessionId' }, 400)
|
|
1911
2169
|
const starredSessionIds = await stars.setStarred(ids, starred)
|
|
1912
2170
|
json(res, { ok: true, starredSessionIds })
|
|
1913
2171
|
} catch (e) {
|
|
1914
|
-
|
|
2172
|
+
// 3.7.0:补 code 字段(响应形状向后兼容,只增字段)——新路由全部带 code,
|
|
2173
|
+
// 星标不应是例外。
|
|
2174
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2175
|
+
}
|
|
2176
|
+
},
|
|
2177
|
+
}))
|
|
2178
|
+
|
|
2179
|
+
// ---- Tags (标签, schema v4) — one route one action, same shape as star/set.
|
|
2180
|
+
// Pure user metadata: these routes never create/modify/delete a session.
|
|
2181
|
+
disposers.push(ctx.webServer.register({
|
|
2182
|
+
kind: 'exact',
|
|
2183
|
+
path: '/archived-sessions/tags/list',
|
|
2184
|
+
handler: async (req, res) => {
|
|
2185
|
+
try {
|
|
2186
|
+
const { tags: tagList, assignments } = await tags.list()
|
|
2187
|
+
json(res, { ok: true, tags: tagList, assignments })
|
|
2188
|
+
} catch (e) {
|
|
2189
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2190
|
+
}
|
|
2191
|
+
},
|
|
2192
|
+
}))
|
|
2193
|
+
|
|
2194
|
+
disposers.push(ctx.webServer.register({
|
|
2195
|
+
kind: 'exact',
|
|
2196
|
+
path: '/archived-sessions/tags/create',
|
|
2197
|
+
handler: async (req, res) => {
|
|
2198
|
+
try {
|
|
2199
|
+
const body = await readJsonBody(req)
|
|
2200
|
+
const tag = await tags.create(body && body.name)
|
|
2201
|
+
json(res, { ok: true, tag })
|
|
2202
|
+
} catch (e) {
|
|
2203
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2204
|
+
}
|
|
2205
|
+
},
|
|
2206
|
+
}))
|
|
2207
|
+
|
|
2208
|
+
disposers.push(ctx.webServer.register({
|
|
2209
|
+
kind: 'exact',
|
|
2210
|
+
path: '/archived-sessions/tags/rename',
|
|
2211
|
+
handler: async (req, res) => {
|
|
2212
|
+
try {
|
|
2213
|
+
const body = await readJsonBody(req)
|
|
2214
|
+
await tags.rename(body && body.id, body && body.name)
|
|
2215
|
+
json(res, { ok: true })
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
}))
|
|
2221
|
+
|
|
2222
|
+
disposers.push(ctx.webServer.register({
|
|
2223
|
+
kind: 'exact',
|
|
2224
|
+
path: '/archived-sessions/tags/merge',
|
|
2225
|
+
handler: async (req, res) => {
|
|
2226
|
+
try {
|
|
2227
|
+
const body = await readJsonBody(req)
|
|
2228
|
+
await tags.merge(body && body.fromId, body && body.toId)
|
|
2229
|
+
json(res, { ok: true })
|
|
2230
|
+
} catch (e) {
|
|
2231
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2232
|
+
}
|
|
2233
|
+
},
|
|
2234
|
+
}))
|
|
2235
|
+
|
|
2236
|
+
disposers.push(ctx.webServer.register({
|
|
2237
|
+
kind: 'exact',
|
|
2238
|
+
path: '/archived-sessions/tags/delete',
|
|
2239
|
+
handler: async (req, res) => {
|
|
2240
|
+
try {
|
|
2241
|
+
const body = await readJsonBody(req)
|
|
2242
|
+
await tags.removeTag(body && body.id)
|
|
2243
|
+
json(res, { ok: true })
|
|
2244
|
+
} catch (e) {
|
|
2245
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2246
|
+
}
|
|
2247
|
+
},
|
|
2248
|
+
}))
|
|
2249
|
+
|
|
2250
|
+
disposers.push(ctx.webServer.register({
|
|
2251
|
+
kind: 'exact',
|
|
2252
|
+
path: '/archived-sessions/tags/set',
|
|
2253
|
+
handler: async (req, res) => {
|
|
2254
|
+
try {
|
|
2255
|
+
const body = await readJsonBody(req)
|
|
2256
|
+
const sid = body && body.sessionId
|
|
2257
|
+
if (!isSafeSessionId(sid)) {
|
|
2258
|
+
return json(res, { ok: false, code: 'DSM_TAG_SESSION_INVALID', error: 'missing/invalid sessionId' }, 400)
|
|
2259
|
+
}
|
|
2260
|
+
const assignments = await tags.setTags(sid, body && body.tagIds)
|
|
2261
|
+
json(res, { ok: true, assignments })
|
|
2262
|
+
} catch (e) {
|
|
2263
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
2266
|
+
}))
|
|
2267
|
+
|
|
2268
|
+
// ---- Saved filters (保存筛选, schema v1) --------------------------------
|
|
2269
|
+
disposers.push(ctx.webServer.register({
|
|
2270
|
+
kind: 'exact',
|
|
2271
|
+
path: '/archived-sessions/filters/list',
|
|
2272
|
+
handler: async (req, res) => {
|
|
2273
|
+
try {
|
|
2274
|
+
json(res, { ok: true, items: await savedFilters.list() })
|
|
2275
|
+
} catch (e) {
|
|
2276
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
2279
|
+
}))
|
|
2280
|
+
|
|
2281
|
+
disposers.push(ctx.webServer.register({
|
|
2282
|
+
kind: 'exact',
|
|
2283
|
+
path: '/archived-sessions/filters/save',
|
|
2284
|
+
handler: async (req, res) => {
|
|
2285
|
+
try {
|
|
2286
|
+
const body = await readJsonBody(req)
|
|
2287
|
+
const item = await savedFilters.save(body && body.name, body && body.filters)
|
|
2288
|
+
json(res, { ok: true, item })
|
|
2289
|
+
} catch (e) {
|
|
2290
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
}))
|
|
2294
|
+
|
|
2295
|
+
disposers.push(ctx.webServer.register({
|
|
2296
|
+
kind: 'exact',
|
|
2297
|
+
path: '/archived-sessions/filters/delete',
|
|
2298
|
+
handler: async (req, res) => {
|
|
2299
|
+
try {
|
|
2300
|
+
const body = await readJsonBody(req)
|
|
2301
|
+
const ids = body && Array.isArray(body.ids) ? body.ids : null
|
|
2302
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, code: 'DSM_FILTER_IDS_INVALID', error: 'missing ids' }, 400)
|
|
2303
|
+
const removed = await savedFilters.remove(ids)
|
|
2304
|
+
json(res, { ok: true, removed })
|
|
2305
|
+
} catch (e) {
|
|
2306
|
+
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1915
2307
|
}
|
|
1916
2308
|
},
|
|
1917
2309
|
}))
|
|
@@ -1975,7 +2367,8 @@ export function apply(ctx) {
|
|
|
1975
2367
|
// 子代理血缘树(管理视图数据面):给定父会话,返回 origin==='subagent'
|
|
1976
2368
|
// 的递归子树。DSH 原生目录(ui-subagent)只读,这里补管理能力的数据来源。
|
|
1977
2369
|
// 只读官方 header 血缘字段(parentSession/origin/delegationDepth/createdAt),
|
|
1978
|
-
//
|
|
2370
|
+
// 标题走 v3.6.2 的纯缓存读取(metaCache/持久索引/权威缓存),未命中入后台
|
|
2371
|
+
// 预热、本轮返回 null;请求路径零解码。
|
|
1979
2372
|
// live 标记 = 会话在内存里活着(正在运行或已打开):批量清理必须跳过。
|
|
1980
2373
|
disposers.push(ctx.webServer.register({
|
|
1981
2374
|
kind: 'exact',
|
|
@@ -2011,9 +2404,29 @@ export function apply(ctx) {
|
|
|
2011
2404
|
})
|
|
2012
2405
|
if (Number.isFinite(sizeBytes)) sizeById.set(id, sizeBytes)
|
|
2013
2406
|
}
|
|
2407
|
+
// v3.6.2 #4:标题只读缓存(metaCache → 持久索引 → 权威缓存),未命中
|
|
2408
|
+
// 入后台预热、本轮返回 null。旧实现在请求路径直调 projectTitles——
|
|
2409
|
+
// 展开一个 N 子树的父会话 = N 次整本解码同步在请求路径上(issue #8
|
|
2410
|
+
// 要消灭的形态在本路由的残留)。这里顺手记下指纹与原始 header,供
|
|
2411
|
+
// 缓存读取与 enqueueWarm 使用。
|
|
2412
|
+
const lineageStats = new Map()
|
|
2413
|
+
const lineageHeaders = new Map()
|
|
2014
2414
|
try {
|
|
2015
2415
|
for (const entry of await persistence.listEntries()) {
|
|
2016
2416
|
addHeader(entry.header, entry.sizeBytes)
|
|
2417
|
+
const eid = entry && entry.id != null ? String(entry.id) : null
|
|
2418
|
+
if (eid) {
|
|
2419
|
+
lineageHeaders.set(eid, { header: (entry && entry.header) || null })
|
|
2420
|
+
// 只有 handle 世代(snapshot 带 revision)才建 stats;legacy 的列表快照
|
|
2421
|
+
// 本就没有这些观测,其标题经 sidebarAuthority 的 authorityTitleCache
|
|
2422
|
+
// 兜住。刻意不在这里合成 sz-only stat——会和列表构建的 mtime:size
|
|
2423
|
+
// 指纹在同一索引里乒乓覆盖,引发交替重复解码。
|
|
2424
|
+
if (entry && typeof entry.revision === 'string' && entry.revision) {
|
|
2425
|
+
lineageStats.set(eid, Number.isFinite(entry.sizeBytes)
|
|
2426
|
+
? { revision: entry.revision, sizeBytes: Number(entry.sizeBytes) }
|
|
2427
|
+
: { revision: entry.revision })
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2017
2430
|
if (entry && entry.id != null) persistedIds.add(String(entry.id))
|
|
2018
2431
|
}
|
|
2019
2432
|
} catch (e) { /* best-effort */ }
|
|
@@ -2061,11 +2474,19 @@ export function apply(ctx) {
|
|
|
2061
2474
|
const allIds = [rootId]
|
|
2062
2475
|
const walk = (n) => { allIds.push(n.sessionId); n.children.forEach(walk) }
|
|
2063
2476
|
nodes.forEach(walk)
|
|
2064
|
-
|
|
2477
|
+
// v3.6.2 #4:缓存读取 + 预热入队,请求路径零解码。#12:标题按
|
|
2478
|
+
// MAX_TITLE 截断,与列表输出口径一致。
|
|
2479
|
+
const lineageIds = allIds.map(String)
|
|
2480
|
+
const { cached: lineageCached, missing: lineageMissing } = metaCache.partition(lineageIds, lineageStats)
|
|
2481
|
+
const lineagePersisted = await hydrateFromPersist(lineageMissing, lineageStats)
|
|
2482
|
+
for (const [id, meta] of lineagePersisted) metaCache.set(id, lineageStats.get(id), meta)
|
|
2483
|
+
const lineageRest = lineageMissing.filter((id) => !lineagePersisted.has(id))
|
|
2484
|
+
if (lineageRest.length) enqueueWarm(lineageRest, lineageStats, lineageHeaders)
|
|
2485
|
+
const truncateTitle = (t) => (t ? (t.length > MAX_TITLE ? t.slice(0, MAX_TITLE) + '…' : t) : null)
|
|
2065
2486
|
const titleOf = (id) => {
|
|
2066
|
-
const
|
|
2067
|
-
const m =
|
|
2068
|
-
return (m && m.title) || null
|
|
2487
|
+
const key = String(id)
|
|
2488
|
+
const m = lineageCached.get(key) || lineagePersisted.get(key)
|
|
2489
|
+
return truncateTitle((m && m.title) || authorityTitleCache.get(key) || null)
|
|
2069
2490
|
}
|
|
2070
2491
|
const fill = (n) => { n.title = titleOf(n.sessionId); n.children.forEach(fill) }
|
|
2071
2492
|
nodes.forEach(fill)
|
|
@@ -2189,6 +2610,24 @@ export function apply(ctx) {
|
|
|
2189
2610
|
},
|
|
2190
2611
|
}))
|
|
2191
2612
|
|
|
2613
|
+
// T2:ack 已展示的排队移动通知(尽力清理;失败无害,client 端 localStorage
|
|
2614
|
+
// 已见集合负责多 tab 去重,7 天 TTL 兜底)。
|
|
2615
|
+
disposers.push(ctx.webServer.register({
|
|
2616
|
+
kind: 'exact',
|
|
2617
|
+
path: '/archived-sessions/pending-moves/notices/ack',
|
|
2618
|
+
handler: async (req, res) => {
|
|
2619
|
+
try {
|
|
2620
|
+
const body = await readJsonBody(req)
|
|
2621
|
+
const ids = body && Array.isArray(body.ids) ? body.ids : null
|
|
2622
|
+
if (!ids || !ids.length) return json(res, { ok: false, error: 'missing ids' }, 400)
|
|
2623
|
+
const removed = await moveNotices.ack(ids)
|
|
2624
|
+
json(res, { ok: true, removed })
|
|
2625
|
+
} catch (e) {
|
|
2626
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
2627
|
+
}
|
|
2628
|
+
},
|
|
2629
|
+
}))
|
|
2630
|
+
|
|
2192
2631
|
// Archive (hide) one session.
|
|
2193
2632
|
disposers.push(ctx.webServer.register({
|
|
2194
2633
|
kind: 'exact',
|