dsh-recall-plugin 1.7.1 → 2.1.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 +55 -12
- package/README.en.md +23 -19
- package/README.md +30 -20
- package/lib/client.js +1459 -1266
- package/lib/config.js +43 -2
- package/lib/diagnostics.js +59 -0
- package/lib/errors.js +73 -0
- package/lib/index.js +426 -1000
- package/lib/maintenance.js +263 -145
- package/lib/routes-core.js +162 -0
- package/lib/routes-manage.js +541 -0
- package/lib/scripts.posix.js +165 -27
- package/lib/scripts.pwsh.js +148 -33
- package/lib/session-info.js +71 -0
- package/lib/snapshots.js +305 -78
- package/lib/store.js +157 -19
- package/package.json +65 -54
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
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 核心路由域(R2 从 index.js 拆出)
|
|
3
|
+
*
|
|
4
|
+
* init / snapshot-info / preview / execute / status / lineage-record 六个核心
|
|
5
|
+
* 端点。依赖经 deps 注入(rt/snaps/state/cfg/enqueue/agentBusy/rescueRollback
|
|
6
|
+
* 等),无模块级可变状态(HMR 假设)。preview/execute 内部跑 git add -A,
|
|
7
|
+
* 经 enqueue 排进与快照/gc 同一条串行队列,避免 index.lock 竞态。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ENV_HINTS } from './diagnostics.js'
|
|
11
|
+
|
|
12
|
+
export function createRoutesCore(deps) {
|
|
13
|
+
// ctx 不解构(A4):本域所有服务访问都已由 rt/snaps 封装,直接摸 ctx 会
|
|
14
|
+
// 绕过工厂分层——留空位只会诱导未来代码破坏依赖注入约定
|
|
15
|
+
const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, E } = deps
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
'init': async (args) => {
|
|
19
|
+
if (!supported) {
|
|
20
|
+
return { ok: false, root: null, notice: { unsupported: true } }
|
|
21
|
+
}
|
|
22
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
23
|
+
const root = await rt.resolveRoot(sessionId)
|
|
24
|
+
let notice = null
|
|
25
|
+
if (root) {
|
|
26
|
+
let store = await rt.resolveStore(root)
|
|
27
|
+
store = await rt.tryUpgradeToHome(root)
|
|
28
|
+
await rt.ensureGit(root, store)
|
|
29
|
+
await snaps.loadIndex(root, sessionId)
|
|
30
|
+
await snaps.rebuildOrphans(root, sessionId)
|
|
31
|
+
rt.cleanupLegacy(root)
|
|
32
|
+
// 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
|
|
33
|
+
// gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
|
|
34
|
+
// 不可写,快照降级存进项目内 .dsh-recall-snapshots。
|
|
35
|
+
notice = {
|
|
36
|
+
gitMissing: state.gitExe === '',
|
|
37
|
+
homeFallback: store ? !store.home : false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
|
|
41
|
+
// init 是每会话必经的预热通道
|
|
42
|
+
return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } }
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
'snapshot-info': async (args) => {
|
|
46
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
47
|
+
const snap = state.snapshots.get(id)
|
|
48
|
+
// 失败/跳过/熔断反馈(issue #7 失败可见性):客户端轮询到 failed 即
|
|
49
|
+
// 终止轮询并 toast,不再空等 20 次;has 时附带 skipped 让用户知道
|
|
50
|
+
// fail-open 跳过了哪些路径
|
|
51
|
+
const feedback = await snaps.feedbackFor(args && args.sessionId, id)
|
|
52
|
+
return { has: Boolean(snap), time: snap ? snap.time : null, id, ...feedback }
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
'preview': async (args) => {
|
|
56
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
57
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
58
|
+
// P0-1:目标工作区 agent 运行中直接拒绝预览(避免用户确认时文件被
|
|
59
|
+
// agent 改动,预览清单与实际回退内容脱节)。同会话优先命中(最常见
|
|
60
|
+
// 场景),快照存在时叠加跨会话同工作区检查。
|
|
61
|
+
const snap = state.snapshots.get(id)
|
|
62
|
+
if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: 'Agent 正在运行中,请先停止后再撤回' }
|
|
63
|
+
const result = await enqueue(() => snaps.diffFor(id))
|
|
64
|
+
if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: '该消息没有可用的项目快照' }
|
|
65
|
+
const snap2 = state.snapshots.get(id)
|
|
66
|
+
const cutSeq = await snaps.resolveCutSeq(sessionId, id)
|
|
67
|
+
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
'execute': async (args) => {
|
|
71
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
72
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
73
|
+
const result = await enqueue(async () => {
|
|
74
|
+
const snap = state.snapshots.get(id)
|
|
75
|
+
if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: '该消息没有可用的项目快照' }
|
|
76
|
+
const store = state.stores.get(snap.root)
|
|
77
|
+
if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: '快照存储不可用' }
|
|
78
|
+
// P0-1:队列内第一步——执行前再查一次 agent 状态。检查放在互斥
|
|
79
|
+
// 队列内,检查后紧接执行,中间不可能插进别的操作,窗口为零。
|
|
80
|
+
if (agentBusy(sessionId, snap.root)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: 'Agent 正在运行中,请先停止后再撤回' }
|
|
81
|
+
// P0-3:preview→execute 失效校验。只由带 previewTotal 的新版
|
|
82
|
+
// Client 触发(老版本/直调 API 不带则跳过,向后兼容)。校验失败
|
|
83
|
+
// 连安全快照都不打——省一次全量 add。同数不同文件的边缘情形由
|
|
84
|
+
// 下方 pre-rollback 安全快照兜底。
|
|
85
|
+
if (args && typeof args.previewTotal === 'number') {
|
|
86
|
+
const fresh = await snaps.diffFor(id)
|
|
87
|
+
if (!fresh || fresh.total !== args.previewTotal) {
|
|
88
|
+
return { ok: false, code: E.RECALL_STALE, message: '预览后项目文件发生了变化,请重新预览确认' }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
|
|
92
|
+
// 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
|
|
93
|
+
// 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
|
|
94
|
+
// (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
|
|
95
|
+
// 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
|
|
96
|
+
// 失败时 safetyOk 置 false:后续回退若也失败将无救援点(H1),
|
|
97
|
+
// 行为退化为现状(fail-loud),不更差。
|
|
98
|
+
const safetyId = 'pre-rollback-' + Date.now()
|
|
99
|
+
let safetyOk = false
|
|
100
|
+
try {
|
|
101
|
+
await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
102
|
+
safetyOk = true
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// 安全快照失败不阻断回退本身:用户已确认覆盖,记录后照原计划执行
|
|
105
|
+
rt.recordError('recall safety snapshot failed: ' + String(error))
|
|
106
|
+
}
|
|
107
|
+
const rolled = await snaps.rollbackFor(id)
|
|
108
|
+
if (rolled.ok) return rolled
|
|
109
|
+
// 回退失败(rollbackFor 返回 partial,工作区可能半回退):用安全快照
|
|
110
|
+
// 救援(H1)。rescueRollback 是 snapshots.js 模块级纯逻辑,副作用经
|
|
111
|
+
// deps 注入,三分支(无救援点/救援成功/救援失败)单测直接钉。
|
|
112
|
+
return rescueRollback(
|
|
113
|
+
{ runShell: rt.runShell, scripts: rt.scripts, gitExe: state.gitExe, recordError: rt.recordError },
|
|
114
|
+
{ root: snap.root, store, safetyId, safetyOk, rollbackError: rolled.error }
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
if (!result.ok) return result
|
|
118
|
+
// 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
|
|
119
|
+
const cutSeq = await snaps.resolveCutSeq(sessionId, id)
|
|
120
|
+
return { ok: true, count: result.count, cutSeq }
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// 设置页排障:最近错误(Host 侧 console.error 的页面可见副本)。
|
|
124
|
+
// M1-D3/D5:条目自带 count/kind(recordError 富集)——count 在服务端
|
|
125
|
+
// 拼成「(×N)」展示文本,设置页按 message 渲染即显示重复计数,零
|
|
126
|
+
// Client 改动;hint 是分类后的可行动提示(API 自描述,本次无客户端
|
|
127
|
+
// 消费,设置页未来展示零成本)。storeBase(M2-D3)暴露快照存储根,
|
|
128
|
+
// 供设置页未来展示「快照存在哪里」,失败为 null。
|
|
129
|
+
'status': async (args) => {
|
|
130
|
+
const storeBase = await rt.resolveHomeContainer()
|
|
131
|
+
if (args && args.op === 'clear') {
|
|
132
|
+
state.errors.length = 0
|
|
133
|
+
return { ok: true, errors: [], storeBase }
|
|
134
|
+
}
|
|
135
|
+
const errors = state.errors.slice(-20).reverse().map((e) => ({
|
|
136
|
+
...e,
|
|
137
|
+
message: e.message + (e.count > 1 ? '(×' + e.count + ')' : ''),
|
|
138
|
+
hint: ENV_HINTS[e.kind] || null
|
|
139
|
+
}))
|
|
140
|
+
return { ok: true, errors, storeBase }
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// F1:client fork 成功后上报撤回链(childId ↔ parentId),Host 持久化到
|
|
144
|
+
// lineage.json 供快照管理树聚族展示「版本家族」。root 优先按 fork 源
|
|
145
|
+
// parentId 解析(fork 时它仍是 live 会话;归档只隐藏列表、对象在内存),
|
|
146
|
+
// 失败回退 childId。
|
|
147
|
+
'lineage-record': async (args) => {
|
|
148
|
+
const childId = args && args.childId ? String(args.childId) : ''
|
|
149
|
+
const parentId = args && args.parentId ? String(args.parentId) : ''
|
|
150
|
+
if (!childId || !parentId) return { ok: false, code: E.RECALL_BAD_TYPE, message: '缺少会话 ID' }
|
|
151
|
+
const root = (await rt.resolveRoot(parentId)) || (await rt.resolveRoot(childId))
|
|
152
|
+
if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: '无法解析工作区' }
|
|
153
|
+
let store = state.stores.get(root)
|
|
154
|
+
if (!store) {
|
|
155
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
156
|
+
}
|
|
157
|
+
if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: '快照存储不可用' }
|
|
158
|
+
await snaps.recordLineage(root, childId, parentId)
|
|
159
|
+
return { ok: true }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|