dsh-recall-plugin 2.3.1 → 2.3.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.
@@ -1,275 +1,204 @@
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 域(设置页「插件配置」卡片可实时改写 cfg,
15
- // 因此这里按调用时取值而不是工厂创建时快照;环境变量
16
- // DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
17
- // 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
18
- // 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
19
-
20
- // P1-3 纯逻辑:按 root 分组选出超限部分的最旧快照(time 升序,time=0 孤儿
21
- // 最旧优先),模块级导出供 tests/unit 直接钉边界;工厂内 enforceLimits 复用。
22
- export function selectOverLimitVictims(snapshots, limit) {
23
- if (!limit || limit <= 0) return new Map() // 0 或负值 = 不限制
24
- const byRoot = new Map()
25
- for (const [id, s] of snapshots.entries()) {
26
- if (!s || !s.root) continue
27
- if (!byRoot.has(s.root)) byRoot.set(s.root, [])
28
- byRoot.get(s.root).push({ id, time: s.time })
29
- }
30
- const victims = new Map()
31
- for (const [root, list] of byRoot) {
32
- if (list.length <= limit) continue
33
- const excess = list.length - limit
34
- // 按时间升序排:time=0 最旧,优先清;同时间保插入序(先入先出)
35
- list.sort((a, b) => (a.time || 0) - (b.time || 0))
36
- victims.set(root, list.slice(0, excess))
37
- }
38
- return victims
39
- }
40
-
41
- // S2-3 按时间保留的纯逻辑:retentionDays <= 0 不启用;按 root 分组,
42
- // 命中「time > 0 且早于 cutoff」的入选(time=0 孤儿视为最旧,一并最先
43
- // 清——与 selectOverLimitVictims 同构)。模块级导出供单测钉边界。
44
- export function selectExpiredVictims(snapshots, retentionDays, now) {
45
- if (!retentionDays || retentionDays <= 0) return new Map()
46
- const cutoff = (typeof now === 'number' ? now : Date.now()) - retentionDays * 86400000
47
- const byRoot = new Map()
48
- for (const [id, s] of snapshots.entries()) {
49
- if (!s || !s.root) continue
50
- // time=0 孤儿(rebuildOrphans 重建)无真实时间,视为最旧列入
51
- if (s.time > 0 && s.time >= cutoff) continue
52
- if (!byRoot.has(s.root)) byRoot.set(s.root, [])
53
- byRoot.get(s.root).push({ id, time: s.time })
54
- }
55
- return byRoot
56
- }
57
-
58
- export function createMaintenance(ctx, rt, snaps, config) {
59
- const sessions = ctx.sessions
60
- const state = rt.state
61
- // 平台选择的脚本模板(gc/purge 两套模板同名导出)
62
- const S = rt.scripts
63
-
64
- // 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
65
- // tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
66
- // best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
67
- async function purgeSession(sessionId) {
68
- const byRoot = new Map()
69
- for (const [id, s] of state.snapshots.entries()) {
70
- if (!s || s.sessionId !== sessionId) continue
71
- if (!byRoot.has(s.root)) byRoot.set(s.root, [])
72
- byRoot.get(s.root).push(id)
73
- }
74
- let purged = 0
75
- for (const [root, ids] of byRoot) {
76
- let store = state.stores.get(root)
77
- if (!store) {
78
- // 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
79
- // 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
80
- try { store = await rt.resolveStore(root) } catch (error) { store = null }
81
- }
82
- if (!store || !state.gitExe) continue
83
- try {
84
- for (let i = 0; i < ids.length; i += 100) {
85
- await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
86
- }
87
- for (const id of ids) state.snapshots.delete(id)
88
- await snaps.saveIndex(root, sessionId)
89
- purged += ids.length
90
- } catch (error) {
91
- rt.recordError('recall purge session failed: ' + String(error))
92
- }
93
- }
94
- if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
95
- return purged
96
- }
97
-
98
- // 扫描索引里出现过的全部会话:不在 sessions 注册表、也不在磁盘会话目录
99
- // 里的,才认定「已删除」。
100
- // PF-7:一次 listSessions 建 id 集合替代逐会话 readSession 冷读——后者
101
- // 对每个非 live 会话解压全量日志且跑在串行队列里,会话多的老工作区 gc
102
- // 一到就把后续快照/撤回全堵在队尾;listSessions 是「目录级 header 枚举、
103
- // 不触碰全量日志」(I8:记录 id 在 header.id),一次调用即得全部磁盘
104
- // 会话 id 集。判定语义与旧「readSession 成败」等价且更保守:归档会话
105
- // 日志仍在磁盘(集合中保留,不被误清——旧路径同样靠这一点)、日志损坏
106
- // 但文件在的也保留(purge 不可逆,宁可少清)。
107
- // 保守闸门保持:sessionQuery 服务(或 listSessions)不存在、枚举抛异常
108
- // 时整体跳过——无法枚举就无法区分「已删除」和「只是冷着」,误删快照
109
- // 不可逆,宁可不清理。
110
- // titles 半项(PF-7 原案):探针(tests/probe/api-surface.test.js)确认
111
- // SessionHeader 无 title 字段(标题住在事件日志里)→ 冷标题无法走
112
- // listSessions,titles 冷读维持 readSession 现状。
113
- async function sweepDeletedSessions() {
114
- const ids = new Set()
115
- for (const s of state.snapshots.values()) {
116
- if (s && s.sessionId) ids.add(s.sessionId)
117
- }
118
- if (!ids.size) return
119
- const query = ctx.get('sessionQuery')
120
- if (!query || typeof query.listSessions !== 'function') return
121
- let diskIds
122
- try {
123
- diskIds = new Set(((await query.listSessions()) || [])
124
- .map((r) => r && r.header && r.header.id)
125
- .filter(Boolean))
126
- } catch (error) {
127
- return
128
- }
129
- for (const id of ids) {
130
- if (sessions.get(id)) continue
131
- if (diskIds.has(id)) continue
132
- await purgeSession(id)
133
- }
134
- }
135
-
136
- // 存储总量上限(P1-3):按 root 分组统计,超限按 time 升序删最旧。
137
- // 调用点(runGc/runGcAll)都在串行队列里,与快照互斥,无 git 锁竞态。
138
- // 删除前 console.error 留痕(静默删历史撤回点必须可追溯,与 purgeSession
139
- // 同款);删除后重写索引。time=0 的孤儿条目(rebuildOrphans 重建)视为
140
- // 最旧优先清理——它们没有真实时间,先于有时间的快照被清。
141
- // 分组与选中逻辑在模块级 selectOverLimitVictims(单测直接钉边界)。
142
- async function enforceLimits() {
143
- const victimsMap = selectOverLimitVictims(state.snapshots, config.maxSnapshotsPerWorkspace)
144
- if (!victimsMap.size) return 0
145
- let dropped = 0
146
- for (const [root, victims] of victimsMap) {
147
- let store = state.stores.get(root)
148
- if (!store) {
149
- try { store = await rt.resolveStore(root) } catch (error) { store = null }
150
- }
151
- if (!store || !state.gitExe) continue
152
- try {
153
- for (let i = 0; i < victims.length; i += 100) {
154
- await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => 'snap-' + v.id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
155
- }
156
- for (const v of victims) state.snapshots.delete(v.id)
157
- await snaps.saveIndex(root, null)
158
- dropped += victims.length
159
- console.error('recall enforceLimits dropped ' + victims.length + ' oldest snapshots for: ' + root + ' (max ' + config.maxSnapshotsPerWorkspace + ')')
160
- } catch (error) {
161
- rt.recordError('recall enforceLimits failed for ' + root + ': ' + String(error))
162
- }
163
- }
164
- return dropped
165
- }
166
-
167
- // 按时间保留(S2-3):对每个 root 清掉早于保留窗口的快照。结构与
168
- // enforceLimits 同款(tag 分块删除 + saveIndex + 留痕),与条数上限
169
- // 各自独立触发,在同一轮 gc 周期里先后执行。时间维度的删除同样
170
- // 静默丢历史撤回点,故 console.error 留痕与 enforceLimits 一致。
171
- async function enforceRetention() {
172
- const victimsMap = selectExpiredVictims(state.snapshots, config.retentionDays, Date.now())
173
- if (!victimsMap.size) return 0
174
- let dropped = 0
175
- for (const [root, victims] of victimsMap) {
176
- let store = state.stores.get(root)
177
- if (!store) {
178
- try { store = await rt.resolveStore(root) } catch (error) { store = null }
179
- }
180
- if (!store || !state.gitExe) continue
181
- try {
182
- for (let i = 0; i < victims.length; i += 100) {
183
- await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => 'snap-' + v.id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
184
- }
185
- for (const v of victims) state.snapshots.delete(v.id)
186
- await snaps.saveIndex(root, null)
187
- dropped += victims.length
188
- console.error('recall enforceRetention dropped ' + victims.length + ' expired snapshots for: ' + root + ' (retention ' + config.retentionDays + 'd)')
189
- } catch (error) {
190
- rt.recordError('recall enforceRetention failed for ' + root + ': ' + String(error))
191
- }
192
- }
193
- return dropped
194
- }
195
-
196
- // 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
197
- // 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
198
- // 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
199
- // 会让后续每条消息都重试一次重量级 gc,把队列堵住。
200
- async function runGc(sessionId, force) {
201
- const root = await rt.resolveRoot(sessionId)
202
- if (!root) return false
203
- const store = state.stores.get(root)
204
- if (!store || !state.gitExe) return false
205
- const now = Date.now()
206
- const last = state.gcLastAt.get(store.git) || 0
207
- const count = (state.gcCount.get(store.git) || 0) + 1
208
- state.gcCount.set(store.git, count)
209
- if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
210
- state.gcCount.set(store.git, 0)
211
- try {
212
- await sweepDeletedSessions()
213
- // P1-3:总量上限清理(sweep 之后、gc 之前)——与快照在同一条串行
214
- // 队列里,删除 tag 与 gc 互斥,无 git 锁竞态;best-effort,
215
- // 自身失败不进 catch 的主错误路径(enforceLimits 内部已兜)。
216
- await enforceLimits()
217
- // S2-3:按时间保留清理(与条数上限维度各自独立,同条串行队列)
218
- await enforceRetention()
219
- await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
220
- } catch (error) {
221
- rt.recordError('recall maintenance failed: ' + String(error))
222
- }
223
- state.gcLastAt.set(store.git, Date.now())
224
- return true
225
- }
226
-
227
- // 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
228
- // store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
229
- // best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
230
- // 串行队列,与快照天然互斥,无 git 锁竞态。
231
- async function runGcAll() {
232
- const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
233
- if (!stores.length || !state.gitExe) return false
234
- try {
235
- await sweepDeletedSessions()
236
- } catch (error) {
237
- rt.recordError('recall sweep failed: ' + String(error))
238
- }
239
- try {
240
- // P1-3:全局清理一次(runGcAll 无会话上下文,enforceLimits 自身按
241
- // root 遍历内存快照,天然覆盖全部已知工作区)
242
- await enforceLimits()
243
- } catch (error) {
244
- rt.recordError('recall enforceLimits failed: ' + String(error))
245
- }
246
- try {
247
- // S2-3:按时间保留全局清理一次
248
- await enforceRetention()
249
- } catch (error) {
250
- rt.recordError('recall enforceRetention failed: ' + String(error))
251
- }
252
- let done = 0
253
- for (const store of stores) {
254
- try {
255
- await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
256
- done++
257
- } catch (error) {
258
- rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
259
- }
260
- state.gcLastAt.set(store.git, Date.now())
261
- state.gcCount.set(store.git, 0)
262
- }
263
- return true
264
- }
265
-
266
- // 每条消息快照后串行调用(见 index.js 事件接线)
267
- async function maybeMaintain(sessionId) {
268
- await runGc(sessionId, false)
269
- }
270
-
271
- // 模块收敛:runGc/runGcAll 之外的内部步骤不对外暴露面;enforceLimits /
272
- // sweepDeletedSessions 保留导出供单测以工厂形态驱动(注入假 rt/ctx 钉
273
- // 执行链路;PF-7 sweep 判定矩阵依赖导出)。
274
- return { maybeMaintain, runGc, runGcAll, enforceLimits, enforceRetention, sweepDeletedSessions }
275
- }
1
+ function selectOverLimitVictims(snapshots, limit) {
2
+ if (!limit || limit <= 0) return /* @__PURE__ */ new Map();
3
+ const byRoot = /* @__PURE__ */ new Map();
4
+ for (const [id, s] of snapshots.entries()) {
5
+ if (!s || !s.root) continue;
6
+ if (!byRoot.has(s.root)) byRoot.set(s.root, []);
7
+ byRoot.get(s.root).push({ id, time: s.time });
8
+ }
9
+ const victims = /* @__PURE__ */ new Map();
10
+ for (const [root, list] of byRoot) {
11
+ if (list.length <= limit) continue;
12
+ const excess = list.length - limit;
13
+ list.sort((a, b) => (a.time || 0) - (b.time || 0));
14
+ victims.set(root, list.slice(0, excess));
15
+ }
16
+ return victims;
17
+ }
18
+ function selectExpiredVictims(snapshots, retentionDays, now) {
19
+ if (!retentionDays || retentionDays <= 0) return /* @__PURE__ */ new Map();
20
+ const cutoff = (typeof now === "number" ? now : Date.now()) - retentionDays * 864e5;
21
+ const byRoot = /* @__PURE__ */ new Map();
22
+ for (const [id, s] of snapshots.entries()) {
23
+ if (!s || !s.root) continue;
24
+ if (s.time > 0 && s.time >= cutoff) continue;
25
+ if (!byRoot.has(s.root)) byRoot.set(s.root, []);
26
+ byRoot.get(s.root).push({ id, time: s.time });
27
+ }
28
+ return byRoot;
29
+ }
30
+ function createMaintenance(ctx, rt, snaps, config) {
31
+ const sessions = ctx.sessions;
32
+ const state = rt.state;
33
+ const S = rt.scripts;
34
+ async function purgeSession(sessionId) {
35
+ const byRoot = /* @__PURE__ */ new Map();
36
+ for (const [id, s] of state.snapshots.entries()) {
37
+ if (!s || s.sessionId !== sessionId) continue;
38
+ if (!byRoot.has(s.root)) byRoot.set(s.root, []);
39
+ byRoot.get(s.root).push(id);
40
+ }
41
+ let purged = 0;
42
+ for (const [root, ids] of byRoot) {
43
+ let store = state.stores.get(root) || null;
44
+ if (!store) {
45
+ try {
46
+ store = await rt.resolveStore(root);
47
+ } catch (error) {
48
+ store = null;
49
+ }
50
+ }
51
+ if (!store || !state.gitExe) continue;
52
+ try {
53
+ for (let i = 0; i < ids.length; i += 100) {
54
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => "snap-" + id)), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
55
+ }
56
+ for (const id of ids) state.snapshots.delete(id);
57
+ await snaps.saveIndex(root, sessionId);
58
+ purged += ids.length;
59
+ } catch (error) {
60
+ rt.recordError("recall purge session failed: " + String(error));
61
+ }
62
+ }
63
+ if (purged > 0) console.error("recall purged snapshots of deleted session:", sessionId, purged);
64
+ return purged;
65
+ }
66
+ async function sweepDeletedSessions() {
67
+ const ids = /* @__PURE__ */ 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.listSessions !== "function") return;
74
+ let diskIds;
75
+ try {
76
+ diskIds = new Set((await query.listSessions() || []).map((r) => r && r.header && r.header.id).filter((v) => Boolean(v)));
77
+ } catch (error) {
78
+ return;
79
+ }
80
+ for (const id of ids) {
81
+ if (sessions.get(id)) continue;
82
+ if (diskIds.has(id)) continue;
83
+ await purgeSession(id);
84
+ }
85
+ }
86
+ async function enforceLimits() {
87
+ const victimsMap = selectOverLimitVictims(state.snapshots, config.maxSnapshotsPerWorkspace);
88
+ if (!victimsMap.size) return 0;
89
+ let dropped = 0;
90
+ for (const [root, victims] of victimsMap) {
91
+ let store = state.stores.get(root) || null;
92
+ if (!store) {
93
+ try {
94
+ store = await rt.resolveStore(root);
95
+ } catch (error) {
96
+ store = null;
97
+ }
98
+ }
99
+ if (!store || !state.gitExe) continue;
100
+ try {
101
+ for (let i = 0; i < victims.length; i += 100) {
102
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => "snap-" + v.id)), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
103
+ }
104
+ for (const v of victims) state.snapshots.delete(v.id);
105
+ await snaps.saveIndex(root, null);
106
+ dropped += victims.length;
107
+ console.error("recall enforceLimits dropped " + victims.length + " oldest snapshots for: " + root + " (max " + config.maxSnapshotsPerWorkspace + ")");
108
+ } catch (error) {
109
+ rt.recordError("recall enforceLimits failed for " + root + ": " + String(error));
110
+ }
111
+ }
112
+ return dropped;
113
+ }
114
+ async function enforceRetention() {
115
+ const victimsMap = selectExpiredVictims(state.snapshots, config.retentionDays, Date.now());
116
+ if (!victimsMap.size) return 0;
117
+ let dropped = 0;
118
+ for (const [root, victims] of victimsMap) {
119
+ let store = state.stores.get(root) || null;
120
+ if (!store) {
121
+ try {
122
+ store = await rt.resolveStore(root);
123
+ } catch (error) {
124
+ store = null;
125
+ }
126
+ }
127
+ if (!store || !state.gitExe) continue;
128
+ try {
129
+ for (let i = 0; i < victims.length; i += 100) {
130
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => "snap-" + v.id)), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
131
+ }
132
+ for (const v of victims) state.snapshots.delete(v.id);
133
+ await snaps.saveIndex(root, null);
134
+ dropped += victims.length;
135
+ console.error("recall enforceRetention dropped " + victims.length + " expired snapshots for: " + root + " (retention " + config.retentionDays + "d)");
136
+ } catch (error) {
137
+ rt.recordError("recall enforceRetention failed for " + root + ": " + String(error));
138
+ }
139
+ }
140
+ return dropped;
141
+ }
142
+ async function runGc(sessionId, force) {
143
+ const root = await rt.resolveRoot(sessionId);
144
+ if (!root) return false;
145
+ const store = state.stores.get(root);
146
+ if (!store || !state.gitExe) return false;
147
+ const now = Date.now();
148
+ const last = state.gcLastAt.get(store.git) || 0;
149
+ const count = (state.gcCount.get(store.git) || 0) + 1;
150
+ state.gcCount.set(store.git, count);
151
+ if (!force && count < config.gcSnaps && now - last < config.gcHours * 36e5) return false;
152
+ state.gcCount.set(store.git, 0);
153
+ try {
154
+ await sweepDeletedSessions();
155
+ await enforceLimits();
156
+ await enforceRetention();
157
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 6e5, stdoutMaxBytes: 4096 });
158
+ } catch (error) {
159
+ rt.recordError("recall maintenance failed: " + String(error));
160
+ }
161
+ state.gcLastAt.set(store.git, Date.now());
162
+ return true;
163
+ }
164
+ async function runGcAll() {
165
+ const stores = Array.from(new Set(Array.from(state.stores.values()).filter((s) => Boolean(s))));
166
+ if (!stores.length || !state.gitExe) return false;
167
+ try {
168
+ await sweepDeletedSessions();
169
+ } catch (error) {
170
+ rt.recordError("recall sweep failed: " + String(error));
171
+ }
172
+ try {
173
+ await enforceLimits();
174
+ } catch (error) {
175
+ rt.recordError("recall enforceLimits failed: " + String(error));
176
+ }
177
+ try {
178
+ await enforceRetention();
179
+ } catch (error) {
180
+ rt.recordError("recall enforceRetention failed: " + String(error));
181
+ }
182
+ let done = 0;
183
+ for (const store of stores) {
184
+ try {
185
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 6e5, stdoutMaxBytes: 4096 });
186
+ done++;
187
+ } catch (error) {
188
+ rt.recordError("recall gc failed for " + (store && store.git) + ": " + String(error));
189
+ }
190
+ state.gcLastAt.set(store.git, Date.now());
191
+ state.gcCount.set(store.git, 0);
192
+ }
193
+ return true;
194
+ }
195
+ async function maybeMaintain(sessionId) {
196
+ await runGc(sessionId, false);
197
+ }
198
+ return { maybeMaintain, runGc, runGcAll, enforceLimits, enforceRetention, sweepDeletedSessions };
199
+ }
200
+ export {
201
+ createMaintenance,
202
+ selectExpiredVictims,
203
+ selectOverLimitVictims
204
+ };