dsh-recall-plugin 1.7.0 → 2.0.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 +23 -0
- package/README.en.md +21 -18
- package/README.md +28 -19
- package/lib/client.js +284 -108
- package/lib/config.js +43 -2
- package/lib/index.js +153 -24
- package/lib/maintenance.js +263 -145
- package/lib/snapshots.js +73 -50
- package/package.json +15 -8
package/lib/maintenance.js
CHANGED
|
@@ -1,145 +1,263 @@
|
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
if (!
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
// - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
|
|
101
|
+
// 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
|
|
102
|
+
// - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
|
|
103
|
+
// 不会被误清——只有日志真正消失才触发。
|
|
104
|
+
async function sweepDeletedSessions() {
|
|
105
|
+
const ids = new Set()
|
|
106
|
+
for (const s of state.snapshots.values()) {
|
|
107
|
+
if (s && s.sessionId) ids.add(s.sessionId)
|
|
108
|
+
}
|
|
109
|
+
if (!ids.size) return
|
|
110
|
+
const query = ctx.get('sessionQuery')
|
|
111
|
+
if (!query || typeof query.readSession !== 'function') return
|
|
112
|
+
for (const id of ids) {
|
|
113
|
+
if (sessions.get(id)) continue
|
|
114
|
+
let alive = false
|
|
115
|
+
try {
|
|
116
|
+
const log = await query.readSession(id)
|
|
117
|
+
alive = Boolean(log)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
alive = false
|
|
120
|
+
}
|
|
121
|
+
if (!alive) await purgeSession(id)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 存储总量上限(P1-3):按 root 分组统计,超限按 time 升序删最旧。
|
|
126
|
+
// 调用点(runGc/runGcAll)都在串行队列里,与快照互斥,无 git 锁竞态。
|
|
127
|
+
// 删除前 console.error 留痕(静默删历史撤回点必须可追溯,与 purgeSession
|
|
128
|
+
// 同款);删除后重写索引。time=0 的孤儿条目(rebuildOrphans 重建)视为
|
|
129
|
+
// 最旧优先清理——它们没有真实时间,先于有时间的快照被清。
|
|
130
|
+
// 分组与选中逻辑在模块级 selectOverLimitVictims(单测直接钉边界)。
|
|
131
|
+
async function enforceLimits() {
|
|
132
|
+
const victimsMap = selectOverLimitVictims(state.snapshots, config.maxSnapshotsPerWorkspace)
|
|
133
|
+
if (!victimsMap.size) return 0
|
|
134
|
+
let dropped = 0
|
|
135
|
+
for (const [root, victims] of victimsMap) {
|
|
136
|
+
let store = state.stores.get(root)
|
|
137
|
+
if (!store) {
|
|
138
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
139
|
+
}
|
|
140
|
+
if (!store || !state.gitExe) continue
|
|
141
|
+
try {
|
|
142
|
+
for (let i = 0; i < victims.length; i += 100) {
|
|
143
|
+
await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => 'snap-' + v.id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
144
|
+
}
|
|
145
|
+
for (const v of victims) state.snapshots.delete(v.id)
|
|
146
|
+
await snaps.saveIndex(root, null)
|
|
147
|
+
dropped += victims.length
|
|
148
|
+
console.error('recall enforceLimits dropped ' + victims.length + ' oldest snapshots for: ' + root + ' (max ' + config.maxSnapshotsPerWorkspace + ')')
|
|
149
|
+
} catch (error) {
|
|
150
|
+
rt.recordError('recall enforceLimits failed for ' + root + ': ' + String(error))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return dropped
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 按时间保留(S2-3):对每个 root 清掉早于保留窗口的快照。结构与
|
|
157
|
+
// enforceLimits 同款(tag 分块删除 + saveIndex + 留痕),与条数上限
|
|
158
|
+
// 各自独立触发,在同一轮 gc 周期里先后执行。时间维度的删除同样
|
|
159
|
+
// 静默丢历史撤回点,故 console.error 留痕与 enforceLimits 一致。
|
|
160
|
+
async function enforceRetention() {
|
|
161
|
+
const victimsMap = selectExpiredVictims(state.snapshots, config.retentionDays, Date.now())
|
|
162
|
+
if (!victimsMap.size) return 0
|
|
163
|
+
let dropped = 0
|
|
164
|
+
for (const [root, victims] of victimsMap) {
|
|
165
|
+
let store = state.stores.get(root)
|
|
166
|
+
if (!store) {
|
|
167
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
168
|
+
}
|
|
169
|
+
if (!store || !state.gitExe) continue
|
|
170
|
+
try {
|
|
171
|
+
for (let i = 0; i < victims.length; i += 100) {
|
|
172
|
+
await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => 'snap-' + v.id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
173
|
+
}
|
|
174
|
+
for (const v of victims) state.snapshots.delete(v.id)
|
|
175
|
+
await snaps.saveIndex(root, null)
|
|
176
|
+
dropped += victims.length
|
|
177
|
+
console.error('recall enforceRetention dropped ' + victims.length + ' expired snapshots for: ' + root + ' (retention ' + config.retentionDays + 'd)')
|
|
178
|
+
} catch (error) {
|
|
179
|
+
rt.recordError('recall enforceRetention failed for ' + root + ': ' + String(error))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return dropped
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
|
|
186
|
+
// 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
|
|
187
|
+
// 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
|
|
188
|
+
// 会让后续每条消息都重试一次重量级 gc,把队列堵住。
|
|
189
|
+
async function runGc(sessionId, force) {
|
|
190
|
+
const root = await rt.resolveRoot(sessionId)
|
|
191
|
+
if (!root) return false
|
|
192
|
+
const store = state.stores.get(root)
|
|
193
|
+
if (!store || !state.gitExe) return false
|
|
194
|
+
const now = Date.now()
|
|
195
|
+
const last = state.gcLastAt.get(store.git) || 0
|
|
196
|
+
const count = (state.gcCount.get(store.git) || 0) + 1
|
|
197
|
+
state.gcCount.set(store.git, count)
|
|
198
|
+
if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
|
|
199
|
+
state.gcCount.set(store.git, 0)
|
|
200
|
+
try {
|
|
201
|
+
await sweepDeletedSessions()
|
|
202
|
+
// P1-3:总量上限清理(sweep 之后、gc 之前)——与快照在同一条串行
|
|
203
|
+
// 队列里,删除 tag 与 gc 互斥,无 git 锁竞态;best-effort,
|
|
204
|
+
// 自身失败不进 catch 的主错误路径(enforceLimits 内部已兜)。
|
|
205
|
+
await enforceLimits()
|
|
206
|
+
// S2-3:按时间保留清理(与条数上限维度各自独立,同条串行队列)
|
|
207
|
+
await enforceRetention()
|
|
208
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
209
|
+
} catch (error) {
|
|
210
|
+
rt.recordError('recall maintenance failed: ' + String(error))
|
|
211
|
+
}
|
|
212
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
|
|
217
|
+
// store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
|
|
218
|
+
// best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
|
|
219
|
+
// 串行队列,与快照天然互斥,无 git 锁竞态。
|
|
220
|
+
async function runGcAll() {
|
|
221
|
+
const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
|
|
222
|
+
if (!stores.length || !state.gitExe) return false
|
|
223
|
+
try {
|
|
224
|
+
await sweepDeletedSessions()
|
|
225
|
+
} catch (error) {
|
|
226
|
+
rt.recordError('recall sweep failed: ' + String(error))
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
// P1-3:全局清理一次(runGcAll 无会话上下文,enforceLimits 自身按
|
|
230
|
+
// root 遍历内存快照,天然覆盖全部已知工作区)
|
|
231
|
+
await enforceLimits()
|
|
232
|
+
} catch (error) {
|
|
233
|
+
rt.recordError('recall enforceLimits failed: ' + String(error))
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
// S2-3:按时间保留全局清理一次
|
|
237
|
+
await enforceRetention()
|
|
238
|
+
} catch (error) {
|
|
239
|
+
rt.recordError('recall enforceRetention failed: ' + String(error))
|
|
240
|
+
}
|
|
241
|
+
let done = 0
|
|
242
|
+
for (const store of stores) {
|
|
243
|
+
try {
|
|
244
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
245
|
+
done++
|
|
246
|
+
} catch (error) {
|
|
247
|
+
rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
|
|
248
|
+
}
|
|
249
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
250
|
+
state.gcCount.set(store.git, 0)
|
|
251
|
+
}
|
|
252
|
+
return true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 每条消息快照后串行调用(见 index.js 事件接线)
|
|
256
|
+
async function maybeMaintain(sessionId) {
|
|
257
|
+
await runGc(sessionId, false)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 模块收敛:runGc/runGcAll 之外的内部步骤不对外暴露面;enforceLimits
|
|
261
|
+
// 保留导出供单测以工厂形态驱动(注入假 rt/ctx 钉执行链路)
|
|
262
|
+
return { maybeMaintain, runGc, runGcAll, enforceLimits, enforceRetention }
|
|
263
|
+
}
|
package/lib/snapshots.js
CHANGED
|
@@ -7,6 +7,58 @@
|
|
|
7
7
|
* 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// ---- 纯逻辑(模块级导出,供 tests/unit 直接钉住;工厂内沿用同一实现)----
|
|
11
|
+
|
|
12
|
+
// 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
|
|
13
|
+
// 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
|
|
14
|
+
// 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
|
|
15
|
+
export function parseSkipped(out) {
|
|
16
|
+
const skipped = []
|
|
17
|
+
for (const line of String(out || '').split(/\r?\n/)) {
|
|
18
|
+
if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
|
|
19
|
+
}
|
|
20
|
+
return skipped
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
|
|
24
|
+
// 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
|
|
25
|
+
export function parseChanges(text, isWin) {
|
|
26
|
+
if (isWin) {
|
|
27
|
+
const parsed = JSON.parse(text)
|
|
28
|
+
if (Array.isArray(parsed)) return parsed
|
|
29
|
+
if (parsed && typeof parsed === 'object') return [parsed]
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
const out = []
|
|
33
|
+
for (const line of text.split(/\r?\n/)) {
|
|
34
|
+
if (!line) continue
|
|
35
|
+
const tab = line.indexOf('\t')
|
|
36
|
+
if (tab < 0) continue
|
|
37
|
+
out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
|
|
43
|
+
export function scanCutSeq(events, messageId) {
|
|
44
|
+
let anchor = -1
|
|
45
|
+
for (let i = 0; i < events.length; i++) {
|
|
46
|
+
const e = events[i]
|
|
47
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
48
|
+
anchor = i
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (anchor < 0) return null
|
|
53
|
+
for (let i = anchor - 1; i >= 0; i--) {
|
|
54
|
+
const e = events[i]
|
|
55
|
+
if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- 配置工厂 ----
|
|
61
|
+
|
|
10
62
|
export function createSnapshots(ctx, rt, config) {
|
|
11
63
|
const sessions = ctx.sessions
|
|
12
64
|
const state = rt.state
|
|
@@ -39,9 +91,18 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
39
91
|
// 是 root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
|
|
40
92
|
// 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
|
|
41
93
|
// 旧版本插件读新索引也只取已知字段,双向兼容。
|
|
94
|
+
// feedback 落盘(P1-2):只对「需要解释」的消息写 feedback 字段(失败/
|
|
95
|
+
// 有跳过),正常快照不带——省空间;重启后 snapshot-info 仍能解释
|
|
96
|
+
// 「这条消息为什么没有/缺了快照」。与 root 字段当年的兼容策略一致:
|
|
97
|
+
// 老版本插件读新索引忽略未知字段。
|
|
42
98
|
const entries = Array.from(state.snapshots.entries())
|
|
43
99
|
.filter(([, s]) => s.root === root)
|
|
44
|
-
.map(([id, s]) =>
|
|
100
|
+
.map(([id, s]) => {
|
|
101
|
+
const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId }
|
|
102
|
+
const fb = state.snapFeedback.get(id)
|
|
103
|
+
if (fb && (fb.failed || (Array.isArray(fb.skipped) && fb.skipped.length))) rec.feedback = fb
|
|
104
|
+
return rec
|
|
105
|
+
})
|
|
45
106
|
try {
|
|
46
107
|
await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
|
|
47
108
|
} catch (error) {
|
|
@@ -65,6 +126,16 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
65
126
|
time: typeof entry.time === 'number' ? entry.time : Date.now(),
|
|
66
127
|
sessionId: entry.sessionId || sessionId
|
|
67
128
|
})
|
|
129
|
+
// feedback 回填(P1-2):重启后仍能解释「这条消息为什么没有/缺了
|
|
130
|
+
// 快照」。复用 setFeedback 落内存(保持 FIFO 上限),只回填「需要
|
|
131
|
+
// 解释」的记录;旧版索引无 feedback 字段时天然跳过。
|
|
132
|
+
const fb = entry.feedback
|
|
133
|
+
if (fb && typeof fb === 'object') {
|
|
134
|
+
const rec = {}
|
|
135
|
+
if (fb.failed) { rec.failed = true; if (typeof fb.error === 'string') rec.error = fb.error }
|
|
136
|
+
if (Array.isArray(fb.skipped)) rec.skipped = fb.skipped.filter((p) => typeof p === 'string')
|
|
137
|
+
if (rec.failed || (Array.isArray(rec.skipped) && rec.skipped.length)) setFeedback(entry.id, rec)
|
|
138
|
+
}
|
|
68
139
|
}
|
|
69
140
|
// 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
|
|
70
141
|
// runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
|
|
@@ -137,17 +208,6 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
137
208
|
}
|
|
138
209
|
}
|
|
139
210
|
|
|
140
|
-
// 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
|
|
141
|
-
// 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
|
|
142
|
-
// 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
|
|
143
|
-
function parseSkipped(out) {
|
|
144
|
-
const skipped = []
|
|
145
|
-
for (const line of String(out || '').split(/\r?\n/)) {
|
|
146
|
-
if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
|
|
147
|
-
}
|
|
148
|
-
return skipped
|
|
149
|
-
}
|
|
150
|
-
|
|
151
211
|
// 逐消息反馈写入(issue #7 失败可见性):成功无跳过 → 清除(重试成功
|
|
152
212
|
// 自愈);失败/有跳过 → 记录。上限防泄漏:交替成功失败的长会话可以无限
|
|
153
213
|
// 积累,Map 保插入序做 FIFO 淘汰。
|
|
@@ -202,25 +262,6 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
202
262
|
snapFailures.set(root, f)
|
|
203
263
|
}
|
|
204
264
|
|
|
205
|
-
// POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
|
|
206
|
-
// 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
|
|
207
|
-
function parseChanges(text) {
|
|
208
|
-
if (rt.isWin) {
|
|
209
|
-
const parsed = JSON.parse(text)
|
|
210
|
-
if (Array.isArray(parsed)) return parsed
|
|
211
|
-
if (parsed && typeof parsed === 'object') return [parsed]
|
|
212
|
-
return []
|
|
213
|
-
}
|
|
214
|
-
const out = []
|
|
215
|
-
for (const line of text.split(/\r?\n/)) {
|
|
216
|
-
if (!line) continue
|
|
217
|
-
const tab = line.indexOf('\t')
|
|
218
|
-
if (tab < 0) continue
|
|
219
|
-
out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
|
|
220
|
-
}
|
|
221
|
-
return out
|
|
222
|
-
}
|
|
223
|
-
|
|
224
265
|
// 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
|
|
225
266
|
// 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
|
|
226
267
|
// 汇总展示;total 保留完整计数让面板文案仍准确。
|
|
@@ -236,7 +277,7 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
236
277
|
const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
|
|
237
278
|
const trimmed = text.trim()
|
|
238
279
|
if (!trimmed) return { changes: [], total: 0, truncated: false }
|
|
239
|
-
const all = parseChanges(trimmed)
|
|
280
|
+
const all = parseChanges(trimmed, rt.isWin)
|
|
240
281
|
return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
|
|
241
282
|
}
|
|
242
283
|
|
|
@@ -252,24 +293,6 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
252
293
|
return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
|
|
253
294
|
}
|
|
254
295
|
|
|
255
|
-
// 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
|
|
256
|
-
function scanCutSeq(events, messageId) {
|
|
257
|
-
let anchor = -1
|
|
258
|
-
for (let i = 0; i < events.length; i++) {
|
|
259
|
-
const e = events[i]
|
|
260
|
-
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
261
|
-
anchor = i
|
|
262
|
-
break
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (anchor < 0) return null
|
|
266
|
-
for (let i = anchor - 1; i >= 0; i--) {
|
|
267
|
-
const e = events[i]
|
|
268
|
-
if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
|
|
269
|
-
}
|
|
270
|
-
return null
|
|
271
|
-
}
|
|
272
|
-
|
|
273
296
|
// 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
|
|
274
297
|
// 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
|
|
275
298
|
// 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recall-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=20"
|
|
29
29
|
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "vitest run tests/unit",
|
|
32
|
+
"test:probe": "vitest run tests/probe"
|
|
33
|
+
},
|
|
30
34
|
"dsh": {
|
|
31
35
|
"bundle": {
|
|
32
36
|
"patch": "./cordis.patch.yml"
|
|
@@ -40,15 +44,18 @@
|
|
|
40
44
|
},
|
|
41
45
|
"peerDependencies": {
|
|
42
46
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
43
|
-
"@deepseek-ai/dsh-
|
|
44
|
-
"@deepseek-ai/
|
|
45
|
-
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
46
|
-
"@deepseek-ai/dsh-shell": "^0.1.1-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-client-web-react": "^0.1.1-rc.2",
|
|
48
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
47
49
|
"@deepseek-ai/dsh-sandbox-policy": "^0.1.1-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
48
51
|
"@deepseek-ai/dsh-session-query": "^0.1.1-rc.2",
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
52
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-shell": "^0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
51
55
|
"react": "^18.2.0"
|
|
52
56
|
},
|
|
53
|
-
"license": "MIT"
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"vitest": "^4.1.11"
|
|
60
|
+
}
|
|
54
61
|
}
|