dsh-sessions-manager 3.6.2 → 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 +990 -32
- package/lib/client.js.map +2 -2
- package/lib/index.js +921 -151
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +816 -33
- package/src/client/logic.js +285 -0
- package/src/empty-scan-index.js +118 -0
- package/src/index.js +340 -79
- 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/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
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.
|
|
@@ -154,6 +159,10 @@ export function apply(ctx) {
|
|
|
154
159
|
// 仍要全库解码。索引按同样的 (mtime, size) 指纹存解码结果,指纹没变的会话
|
|
155
160
|
// 重启后也直接复用。见 src/title-persist-index.js。
|
|
156
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
|
|
157
166
|
|
|
158
167
|
// P4:对「内存缓存未命中」的会话查持久索引,指纹一致才可信。
|
|
159
168
|
// 返回 Map<id, meta>;调用方应把命中条目回填 metaCache 并从 missing 里剔除。
|
|
@@ -271,8 +280,9 @@ export function apply(ctx) {
|
|
|
271
280
|
workspaceGone: !!(cwd && !ws),
|
|
272
281
|
hasWorkspace: !!cwd,
|
|
273
282
|
}
|
|
274
|
-
// sizeBytes / updatedAt
|
|
275
|
-
//
|
|
283
|
+
// sizeBytes / updatedAt 只在需要的路由里带出(存储分析 / 自动归档 / 会话
|
|
284
|
+
// 列表——3.7.0 起列表也要:分支组时间回退键读 updatedAt)。它们本就来自
|
|
285
|
+
// usage,只是 payload 体积,不引入任何解码。
|
|
276
286
|
if (exposeUsage && usage) {
|
|
277
287
|
if (usage.sizeById && usage.sizeById.has(key)) base.sizeBytes = usage.sizeById.get(key)
|
|
278
288
|
if (usage.mtimeById && usage.mtimeById.has(key)) base.updatedAt = usage.mtimeById.get(key)
|
|
@@ -573,9 +583,18 @@ export function apply(ctx) {
|
|
|
573
583
|
// when the session is really gone (purge, or externally removed; the latter
|
|
574
584
|
// is caught by gcStars during list builds).
|
|
575
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()
|
|
576
592
|
// 待移动队列:会话被占用(DSH 打开中)时登记,等它释放后自动完成移动。
|
|
577
593
|
// 见 src/pending-moves.js 的模块注释(0.1.5 单写者锁没有公共释放 API)。
|
|
578
594
|
const pendingMoves = createPendingMoveStore()
|
|
595
|
+
// T2:排队移动的后台终局(完成/放弃)落成持久通知,sidebar-state 带出、
|
|
596
|
+
// client 弹出后 ack 清除。独立于队列文件(终局与队列项同生死,挂条目上观察不到)。
|
|
597
|
+
const moveNotices = createMoveNoticeStore()
|
|
579
598
|
// Auto-archive settings live in their own schema-v4 store, off by default.
|
|
580
599
|
const autoArchive = createAutoArchiveStore()
|
|
581
600
|
// 启动清扫:进程在「写临时文件 → rename」之间被杀会留下 .<name>-<pid>-<ts>.tmp
|
|
@@ -591,6 +610,17 @@ export function apply(ctx) {
|
|
|
591
610
|
} catch (e) { /* best-effort */ }
|
|
592
611
|
}
|
|
593
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
|
+
|
|
594
624
|
// Soft-delete one session: record it in the recycle-bin index but KEEP its
|
|
595
625
|
// log in the original workspace directory. Moving the file out (and detaching
|
|
596
626
|
// it from the workspace) orphaned the session into DSH's "未分组" group and
|
|
@@ -867,6 +897,10 @@ export function apply(ctx) {
|
|
|
867
897
|
// 状态目录清理 → `ENOTEMPTY: rmdir .../state`(全量测试里约 1/4 概率偶发失败)。
|
|
868
898
|
// 而且"删除成功"不该早于状态落盘:进程恰好在此刻退出就会留下指向已删会话的星标。
|
|
869
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) { /* 标签清理失败不阻塞删除结果 */ }
|
|
870
904
|
return { ok: true, purged: true }
|
|
871
905
|
}
|
|
872
906
|
|
|
@@ -1219,11 +1253,17 @@ export function apply(ctx) {
|
|
|
1219
1253
|
metaCache.invalidate(item.sessionId)
|
|
1220
1254
|
moved++
|
|
1221
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 */ }
|
|
1222
1259
|
} catch (e) {
|
|
1223
1260
|
if (isBusyError(e)) continue // 仍被占用:留在队列里,等下一次触发
|
|
1224
1261
|
if (Date.now() - bootedAt < BOOT_WARMUP_MS) continue // boot 期未就绪:不记失败
|
|
1225
1262
|
const bumped = await pendingMoves.bumpAttempts(item.sessionId)
|
|
1226
|
-
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
|
+
}
|
|
1227
1267
|
}
|
|
1228
1268
|
}
|
|
1229
1269
|
if (moved > 0) { try { await reindexRegistry() } catch (e) { /* best-effort */ } }
|
|
@@ -1452,12 +1492,28 @@ export function apply(ctx) {
|
|
|
1452
1492
|
}
|
|
1453
1493
|
return { items: archivedItems, usage }
|
|
1454
1494
|
}
|
|
1455
|
-
// 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
|
|
1456
1496
|
// failing sp.list() can never wipe the whole index.
|
|
1457
1497
|
let starredSet = new Set()
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
+
}
|
|
1460
1515
|
if (headersOk) await gcStars(ids)
|
|
1516
|
+
if (headersOk) await gcTags(ids)
|
|
1461
1517
|
return { items, usage }
|
|
1462
1518
|
}
|
|
1463
1519
|
|
|
@@ -1565,16 +1621,49 @@ export function apply(ctx) {
|
|
|
1565
1621
|
if (meta && meta.title) authorityTitleCache.set(id, String(meta.title))
|
|
1566
1622
|
}
|
|
1567
1623
|
}
|
|
1568
|
-
// 血缘分类(issue #6):结构分类(子代理 / fork 分支)来自 list
|
|
1569
|
-
// 的 header
|
|
1570
|
-
//
|
|
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
|
+
// 「误隐藏」在结构上不可能来自体积法(该路径已整体退役)。
|
|
1571
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()
|
|
1572
1636
|
for (const entry of entries) {
|
|
1573
|
-
const
|
|
1574
|
-
|
|
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 }
|
|
1575
1659
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
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 = {
|
|
1578
1667
|
titles: Object.fromEntries(authorityTitleCache),
|
|
1579
1668
|
trashedSessionIds: store.items.map((item) => String(item.sessionId)),
|
|
1580
1669
|
purgedSessionIds: activeTombstones,
|
|
@@ -1582,77 +1671,93 @@ export function apply(ctx) {
|
|
|
1582
1671
|
// v3.6.2:还有预热/退避重试在途 → client 缩短轮询节拍,预热一完成就把
|
|
1583
1672
|
// 补齐的标题送回(含面板自动刷新)。无预热 API 时恒 false,绝不假忙。
|
|
1584
1673
|
warmPending: warmPendingNow(),
|
|
1674
|
+
// T1:空白精判后台队列是否还有活。无精判能力(旧 runtime 无 inspect 通道)
|
|
1675
|
+
// 时恒 false——永久 busy 会让 client 疯轮询,这是唯一现实的假忙陷阱。
|
|
1676
|
+
refinePending: refinePendingNow(),
|
|
1585
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
|
|
1586
1685
|
}
|
|
1587
1686
|
|
|
1588
|
-
//
|
|
1589
|
-
//
|
|
1687
|
+
// 空会话判定缓存(进程内):id → { sizeBytes, empty }。日志没变(sizeBytes
|
|
1688
|
+
// 相同)就不重复解码;T1 之后它只是 emptyIndex 持久层之上的热缓存。
|
|
1590
1689
|
const emptyScanCache = new Map()
|
|
1591
1690
|
|
|
1592
|
-
// 0.
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
//
|
|
1596
|
-
//
|
|
1597
|
-
//
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
}
|
|
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'
|
|
1617
1704
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
})))
|
|
1642
|
-
// 缓存只保留「本轮仍参与判定」的会话:已彻底删除或日志膨胀超过解码
|
|
1643
|
-
// 上限的条目不再有用,淘汰掉防止 Map 随历史会话无限增长。
|
|
1644
|
-
const live = new Set()
|
|
1645
|
-
for (const entry of entries) {
|
|
1646
|
-
const size = entry && Number.isFinite(entry.sizeBytes) ? entry.sizeBytes : null
|
|
1647
|
-
if (size !== null && size <= EMPTY_DECODE_LIMIT) live.add(String(entry.id))
|
|
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 })
|
|
1648
1721
|
}
|
|
1649
|
-
|
|
1650
|
-
|
|
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()
|
|
1651
1756
|
}
|
|
1652
1757
|
}
|
|
1653
1758
|
|
|
1654
1759
|
// 聚合一条会话的详情(磁盘占用 / 轮次·步数·消息数 / 工具统计 / fetch /
|
|
1655
|
-
// write/edit 文件 /
|
|
1760
|
+
// write/edit 文件 / 血缘 parent/children/subagents)。live 与持久化会话都可读。
|
|
1656
1761
|
// 所有统计对未知事件类型容错;fetch 与 files 做上限截断,files 用 stat 过滤
|
|
1657
1762
|
// 磁盘上已不存在的路径,避免详情面板列出已删除文件。
|
|
1658
1763
|
// 持久化会话走 inspectSession 分块折叠:大日志不再整本驻留内存(0.1.3-alpha
|
|
@@ -2004,6 +2109,7 @@ export function apply(ctx) {
|
|
|
2004
2109
|
metaCache.invalidate(sid)
|
|
2005
2110
|
// 彻底删除:持久标题索引里的条目一并清掉(issue #1 P4)。
|
|
2006
2111
|
try { await titleIndex.remove([sid]) } catch (e) { /* 索引清理失败不阻塞删除结果 */ }
|
|
2112
|
+
try { await emptyIndex.remove([sid]) } catch (e) { /* 同上 */ }
|
|
2007
2113
|
json(res, out)
|
|
2008
2114
|
} catch (e) {
|
|
2009
2115
|
json(res, { ok: false, code: e && e.code, error: String((e && e.message) || e) }, errorStatus(e))
|
|
@@ -2021,7 +2127,7 @@ export function apply(ctx) {
|
|
|
2021
2127
|
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionIds' }, 400)
|
|
2022
2128
|
const results = []
|
|
2023
2129
|
for (const sid of ids) {
|
|
2024
|
-
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(() => {}) }
|
|
2025
2131
|
catch (e) { results.push({ sessionId: sid, ok: false, error: String((e && e.message) || e) }) }
|
|
2026
2132
|
}
|
|
2027
2133
|
json(res, { ok: true, purged: results.filter((r) => r.ok).length, results })
|
|
@@ -2037,7 +2143,10 @@ export function apply(ctx) {
|
|
|
2037
2143
|
path: '/archived-sessions/sessions',
|
|
2038
2144
|
handler: async (req, res) => {
|
|
2039
2145
|
try {
|
|
2040
|
-
|
|
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() })
|
|
2041
2150
|
} catch (e) {
|
|
2042
2151
|
json(res, { error: String((e && e.message) || e) }, 500)
|
|
2043
2152
|
}
|
|
@@ -2056,11 +2165,145 @@ export function apply(ctx) {
|
|
|
2056
2165
|
if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
|
|
2057
2166
|
ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
|
|
2058
2167
|
}
|
|
2059
|
-
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)
|
|
2060
2169
|
const starredSessionIds = await stars.setStarred(ids, starred)
|
|
2061
2170
|
json(res, { ok: true, starredSessionIds })
|
|
2062
2171
|
} catch (e) {
|
|
2063
|
-
|
|
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))
|
|
2064
2307
|
}
|
|
2065
2308
|
},
|
|
2066
2309
|
}))
|
|
@@ -2367,6 +2610,24 @@ export function apply(ctx) {
|
|
|
2367
2610
|
},
|
|
2368
2611
|
}))
|
|
2369
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
|
+
|
|
2370
2631
|
// Archive (hide) one session.
|
|
2371
2632
|
disposers.push(ctx.webServer.register({
|
|
2372
2633
|
kind: 'exact',
|
package/src/lineage.js
CHANGED
|
@@ -8,13 +8,11 @@
|
|
|
8
8
|
// - fork branch: header.parentSession set without the subagent origin
|
|
9
9
|
// - empty: the log holds only the generation header
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// size test: it flags candidates cheaply and its false positives get
|
|
17
|
-
// corrected by the precise pass.
|
|
11
|
+
// v3.7.0 (T1):体积法**不再对外发布任何空白结论**——它历史上就有假阳性
|
|
12
|
+
// (长 cwd 抬高阈值把有内容的小日志标空 → 侧栏误隐藏,误隐藏严格重于误闪现)。
|
|
13
|
+
// isEmptyLogSize 降级为纯参考实现(阈值单测仍锁着);空白与否现在只有三个来源:
|
|
14
|
+
// 解码精判的成功结论(true/false)、精判解码上限之外的日志(false = 必有内容)、
|
|
15
|
+
// 以及未知(null,交后台 refine 队列判定)。见 index.js refineEmpty.
|
|
18
16
|
|
|
19
17
|
const EMPTY_BASE = 190
|
|
20
18
|
|
|
@@ -60,10 +58,11 @@ export function isEmptyEventTypes(types) {
|
|
|
60
58
|
return true
|
|
61
59
|
}
|
|
62
60
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
61
|
+
// Structural lineage only (T1): a record is produced when the session is a
|
|
62
|
+
// subagent or a fork branch; `empty` is published as null (= not yet judged)
|
|
63
|
+
// and refined in the background. Ordinary top-level sessions return null —
|
|
64
|
+
// they only ever gain an entry once a real decode says empty:true.
|
|
65
|
+
export function classifyLineage(header) {
|
|
67
66
|
if (!header || typeof header !== 'object') return null
|
|
68
67
|
const origin = header.origin === 'subagent' ? 'subagent' : null
|
|
69
68
|
const parentSession = typeof header.parentSession === 'string' && header.parentSession
|
|
@@ -72,7 +71,12 @@ export function classifyLineage(header, sizeBytes) {
|
|
|
72
71
|
const delegationDepth = Number.isSafeInteger(header.delegationDepth) && header.delegationDepth > 0
|
|
73
72
|
? header.delegationDepth
|
|
74
73
|
: 0
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
if (!origin && !parentSession) return null
|
|
75
|
+
return { origin, parentSession, delegationDepth, empty: null }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 空白精判候选:压缩体积在解码上限内的小日志。sizeBytes 缺失(legacy 列表
|
|
79
|
+
// 快照没有该观测)时返回 false —— 与历史行为一致:legacy 世代空白判定不生效。
|
|
80
|
+
export function emptyScanCandidate(sizeBytes) {
|
|
81
|
+
return Number.isFinite(sizeBytes) && sizeBytes <= EMPTY_DECODE_LIMIT
|
|
78
82
|
}
|