dsh-recall-plugin 1.2.2 → 1.4.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/CHANGELOG.md +109 -0
- package/README.en.md +124 -118
- package/README.md +125 -119
- package/cordis.patch.yml +19 -11
- package/lib/client.js +780 -456
- package/lib/config.js +36 -0
- package/lib/index.js +541 -164
- package/lib/maintenance.js +120 -107
- package/lib/scripts.posix.js +366 -290
- package/lib/scripts.pwsh.js +432 -334
- package/lib/snapshots.js +214 -192
- package/lib/store.js +342 -249
- package/package.json +52 -57
package/lib/maintenance.js
CHANGED
|
@@ -1,107 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
-
*
|
|
4
|
-
* 职责:磁盘占用治理,两件事——
|
|
5
|
-
* 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
|
|
6
|
-
* 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
|
|
7
|
-
* 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
|
|
8
|
-
* 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
|
|
9
|
-
*
|
|
10
|
-
* 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
|
|
11
|
-
* 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// gc
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const sessions = ctx.sessions
|
|
22
|
-
const state = rt.state
|
|
23
|
-
// 平台选择的脚本模板(gc/purge 两套模板同名导出)
|
|
24
|
-
const S = rt.scripts
|
|
25
|
-
|
|
26
|
-
// 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
|
|
27
|
-
// tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
|
|
28
|
-
// best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
|
|
29
|
-
async function purgeSession(sessionId) {
|
|
30
|
-
const byRoot = new Map()
|
|
31
|
-
for (const [id, s] of state.snapshots.entries()) {
|
|
32
|
-
if (!s || s.sessionId !== sessionId) continue
|
|
33
|
-
if (!byRoot.has(s.root)) byRoot.set(s.root, [])
|
|
34
|
-
byRoot.get(s.root).push(id)
|
|
35
|
-
}
|
|
36
|
-
let purged = 0
|
|
37
|
-
for (const [root, ids] of byRoot) {
|
|
38
|
-
|
|
39
|
-
if (!store
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
state.
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
+
*
|
|
4
|
+
* 职责:磁盘占用治理,两件事——
|
|
5
|
+
* 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
|
|
6
|
+
* 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
|
|
7
|
+
* 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
|
|
8
|
+
* 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
|
|
9
|
+
*
|
|
10
|
+
* 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
|
|
11
|
+
* 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// gc 节流阈值来自 config 域(用户可在 profile 的 cordis.patch.yml 覆盖,
|
|
15
|
+
// 环境变量 DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
|
|
16
|
+
// 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
|
|
17
|
+
// 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
|
|
18
|
+
export function createMaintenance(ctx, rt, snaps, config) {
|
|
19
|
+
const GC_SNAPS = config.gcSnaps
|
|
20
|
+
const GC_MS = config.gcHours * 3600000
|
|
21
|
+
const sessions = ctx.sessions
|
|
22
|
+
const state = rt.state
|
|
23
|
+
// 平台选择的脚本模板(gc/purge 两套模板同名导出)
|
|
24
|
+
const S = rt.scripts
|
|
25
|
+
|
|
26
|
+
// 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
|
|
27
|
+
// tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
|
|
28
|
+
// best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
|
|
29
|
+
async function purgeSession(sessionId) {
|
|
30
|
+
const byRoot = new Map()
|
|
31
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
32
|
+
if (!s || s.sessionId !== sessionId) continue
|
|
33
|
+
if (!byRoot.has(s.root)) byRoot.set(s.root, [])
|
|
34
|
+
byRoot.get(s.root).push(id)
|
|
35
|
+
}
|
|
36
|
+
let purged = 0
|
|
37
|
+
for (const [root, ids] of byRoot) {
|
|
38
|
+
let store = state.stores.get(root)
|
|
39
|
+
if (!store) {
|
|
40
|
+
// 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
|
|
41
|
+
// 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
|
|
42
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
43
|
+
}
|
|
44
|
+
if (!store || !state.gitExe) continue
|
|
45
|
+
try {
|
|
46
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
47
|
+
await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
48
|
+
}
|
|
49
|
+
for (const id of ids) state.snapshots.delete(id)
|
|
50
|
+
await snaps.saveIndex(root, sessionId)
|
|
51
|
+
purged += ids.length
|
|
52
|
+
} catch (error) {
|
|
53
|
+
rt.recordError('recall purge session failed: ' + String(error))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
|
|
57
|
+
return purged
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
|
|
61
|
+
// 才认定「已删除」。两个保守闸门:
|
|
62
|
+
// - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
|
|
63
|
+
// 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
|
|
64
|
+
// - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
|
|
65
|
+
// 不会被误清——只有日志真正消失才触发。
|
|
66
|
+
async function sweepDeletedSessions() {
|
|
67
|
+
const ids = new Set()
|
|
68
|
+
for (const s of state.snapshots.values()) {
|
|
69
|
+
if (s && s.sessionId) ids.add(s.sessionId)
|
|
70
|
+
}
|
|
71
|
+
if (!ids.size) return
|
|
72
|
+
const query = ctx.get('sessionQuery')
|
|
73
|
+
if (!query || typeof query.readSession !== 'function') return
|
|
74
|
+
for (const id of ids) {
|
|
75
|
+
if (sessions.get(id)) continue
|
|
76
|
+
let alive = false
|
|
77
|
+
try {
|
|
78
|
+
const log = await query.readSession(id)
|
|
79
|
+
alive = Boolean(log)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
alive = false
|
|
82
|
+
}
|
|
83
|
+
if (!alive) await purgeSession(id)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
|
|
88
|
+
// 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
|
|
89
|
+
// 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
|
|
90
|
+
// 会让后续每条消息都重试一次重量级 gc,把队列堵住。
|
|
91
|
+
async function runGc(sessionId, force) {
|
|
92
|
+
const root = await rt.resolveRoot(sessionId)
|
|
93
|
+
if (!root) return false
|
|
94
|
+
const store = state.stores.get(root)
|
|
95
|
+
if (!store || !state.gitExe) return false
|
|
96
|
+
const now = Date.now()
|
|
97
|
+
const last = state.gcLastAt.get(store.git) || 0
|
|
98
|
+
const count = (state.gcCount.get(store.git) || 0) + 1
|
|
99
|
+
state.gcCount.set(store.git, count)
|
|
100
|
+
if (!force && count < GC_SNAPS && now - last < GC_MS) return false
|
|
101
|
+
state.gcCount.set(store.git, 0)
|
|
102
|
+
try {
|
|
103
|
+
await sweepDeletedSessions()
|
|
104
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
105
|
+
} catch (error) {
|
|
106
|
+
rt.recordError('recall maintenance failed: ' + String(error))
|
|
107
|
+
}
|
|
108
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 每条消息快照后串行调用(见 index.js 事件接线)
|
|
113
|
+
async function maybeMaintain(sessionId) {
|
|
114
|
+
await runGc(sessionId, false)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 模块收敛:只导出唯一被装配层消费的入口;sweepDeletedSessions /
|
|
118
|
+
// purgeSession 是 runGc 的内部步骤,不对外暴露面
|
|
119
|
+
return { maybeMaintain, runGc }
|
|
120
|
+
}
|