dsh-recall-plugin 2.0.0 → 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 -29
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +1459 -1479
- package/lib/diagnostics.js +59 -0
- package/lib/errors.js +73 -0
- package/lib/index.js +426 -1129
- 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 +243 -39
- package/lib/store.js +157 -19
- package/package.json +65 -61
package/lib/index.js
CHANGED
|
@@ -1,1129 +1,426 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-recall-plugin — Host
|
|
3
|
-
*
|
|
4
|
-
* 职责:装配各域模块(config
|
|
5
|
-
*
|
|
6
|
-
* 供 Client
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
let readSettings = () => config
|
|
56
|
-
function applyResolvedConfig(resolved) {
|
|
57
|
-
Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
|
|
58
|
-
}
|
|
59
|
-
try {
|
|
60
|
-
installSettingsSection(ctx, 'dsh-recall', Config, config, {
|
|
61
|
-
setSource: (fn) => { readSettings = fn },
|
|
62
|
-
onChange: () => applyResolvedConfig(readSettings()),
|
|
63
|
-
})
|
|
64
|
-
} catch (error) {
|
|
65
|
-
rt.recordError('recall settings namespace skipped: ' + String(error))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
await Promise.all(Array.from(roots).map(async (root) => {
|
|
178
|
-
try {
|
|
179
|
-
const store = await rt.resolveStore(root)
|
|
180
|
-
if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
|
|
181
|
-
byFile.get(store.excludeFile).roots.push(root)
|
|
182
|
-
} catch (error) {
|
|
183
|
-
/* 单个根解析失败只影响它自己,不拖垮整个列表 */
|
|
184
|
-
}
|
|
185
|
-
}))
|
|
186
|
-
// 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
|
|
187
|
-
// 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
} catch (error) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const dump = await dumpStores()
|
|
319
|
-
const hints = new Map()
|
|
320
|
-
for (const [root, st] of state.stores.entries()) {
|
|
321
|
-
if (st && st.dir) hints.set(st.dir, root)
|
|
322
|
-
}
|
|
323
|
-
for (const [dir, info] of dump) {
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
await
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
let deleted = 0
|
|
428
|
-
await enqueue(async () => {
|
|
429
|
-
for (const [root, rootIds] of byRoot) {
|
|
430
|
-
let store = state.stores.get(root)
|
|
431
|
-
if (!store) {
|
|
432
|
-
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
433
|
-
}
|
|
434
|
-
if (!store) continue
|
|
435
|
-
try {
|
|
436
|
-
if (state.gitExe) {
|
|
437
|
-
// tag 分块删除:win32 命令行有 32767 字符上限,整批传大量 tag 会
|
|
438
|
-
// 在长历史工作区上爆掉;与 maintenance.purgeSession 同款 100 个/块。
|
|
439
|
-
const tags = rootIds.map((id) => 'snap-' + id)
|
|
440
|
-
for (let i = 0; i < tags.length; i += 100) {
|
|
441
|
-
await rt.runShell(rt.scripts.purgeTagsScript(store, state.gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
if (!state.indexLoaded.has(root)) {
|
|
445
|
-
try { await snaps.loadIndex(root, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
|
|
446
|
-
}
|
|
447
|
-
for (const id of rootIds) state.snapshots.delete(id)
|
|
448
|
-
await snaps.saveIndex(root, sessionId)
|
|
449
|
-
deleted += rootIds.length
|
|
450
|
-
} catch (error) {
|
|
451
|
-
// 单个 root 失败不阻断其他 root:与 maintenance.purgeSession 同款
|
|
452
|
-
// best-effort,错误进状态页可见的错误缓冲,剩余 root 继续清理。
|
|
453
|
-
rt.recordError('recall batch delete failed for ' + root + ': ' + String(error))
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
listCache.items = null
|
|
457
|
-
})
|
|
458
|
-
return deleted
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// 删除所有工作区的全部快照。树形管理的「工作区/会话」批量删除以
|
|
462
|
-
// index.json 中的记录为目标,适合保留其他节点;但「全部删除」必须把 git
|
|
463
|
-
// tag 当作真相源:index 可能因旧版/崩溃/手动修复而为空或过期,不能因为
|
|
464
|
-
// 索引里没有条目就漏删真实快照。磁盘枚举到的 store 即使 root.txt 丢失也
|
|
465
|
-
// 直接按目录操作,避免 resolveStore 新建同 root 的另一个空 store。
|
|
466
|
-
async function deleteAllSnapshots() {
|
|
467
|
-
return enqueue(async () => {
|
|
468
|
-
const stores = new Map()
|
|
469
|
-
for (const [root, store] of state.stores.entries()) {
|
|
470
|
-
if (store && store.dir) stores.set(store.dir, { store, root })
|
|
471
|
-
}
|
|
472
|
-
const dump = await dumpStores()
|
|
473
|
-
for (const [dir, info] of dump.entries()) {
|
|
474
|
-
const known = stores.get(dir)
|
|
475
|
-
if (known) {
|
|
476
|
-
if (!known.root && info.root) known.root = info.root
|
|
477
|
-
known.entries = info.entries || []
|
|
478
|
-
} else {
|
|
479
|
-
stores.set(dir, {
|
|
480
|
-
// 全局删除只动该目录下的 git/index;不必、也不能依赖可反解的 root。
|
|
481
|
-
store: rt.storeFromDir(dir, false),
|
|
482
|
-
root: info.root || null,
|
|
483
|
-
entries: info.entries || []
|
|
484
|
-
})
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (stores.size === 0) return { deleted: 0, stores: 0, failed: 0 }
|
|
489
|
-
|
|
490
|
-
const gitExe = await rt.resolveGit()
|
|
491
|
-
if (!gitExe) {
|
|
492
|
-
const message = '未检测到 git CLI,无法验证并删除快照 tag'
|
|
493
|
-
rt.recordError('recall delete all failed: ' + message)
|
|
494
|
-
return { deleted: 0, stores: 0, failed: stores.size || 1, message }
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
let deleted = 0
|
|
498
|
-
let clearedStores = 0
|
|
499
|
-
let failed = 0
|
|
500
|
-
for (const { store, root } of stores.values()) {
|
|
501
|
-
try {
|
|
502
|
-
// 先列出实际 tag;不要使用 entries 推导 tag,entries 是可丢失缓存。
|
|
503
|
-
const output = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
|
|
504
|
-
const tags = rt.scripts.stripBom(output).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
|
|
505
|
-
for (let i = 0; i < tags.length; i += 100) {
|
|
506
|
-
await rt.runShell(rt.scripts.purgeTagsScript(store, gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
507
|
-
}
|
|
508
|
-
// purgeTagsScript 为幂等 best-effort,故必须回读校验,避免脚本吞掉
|
|
509
|
-
// 个别失败后仍错误地把 index.json 清空。
|
|
510
|
-
const remainedOutput = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
|
|
511
|
-
const remained = rt.scripts.stripBom(remainedOutput).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
|
|
512
|
-
if (remained.length) throw new Error('仍有 ' + remained.length + ' 个快照 tag 未删除')
|
|
513
|
-
|
|
514
|
-
// tag 清理被确认后才清空索引。直接写已枚举的 store,兼容 root.txt
|
|
515
|
-
// 缺失/错位的旧仓库;不能调用 saveIndex(root),后者会重新按 root 寻址。
|
|
516
|
-
await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', '[]')
|
|
517
|
-
for (const tag of tags) state.snapshots.delete(tag.slice('snap-'.length))
|
|
518
|
-
if (root) {
|
|
519
|
-
for (const [id, snap] of state.snapshots.entries()) {
|
|
520
|
-
if (snap && snap.root === root) state.snapshots.delete(id)
|
|
521
|
-
}
|
|
522
|
-
state.indexLoaded.add(root)
|
|
523
|
-
}
|
|
524
|
-
deleted += tags.length
|
|
525
|
-
clearedStores += 1
|
|
526
|
-
} catch (error) {
|
|
527
|
-
failed += 1
|
|
528
|
-
rt.recordError('recall delete all failed for ' + store.dir + ': ' + String(error))
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
// list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
|
|
532
|
-
// 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
|
|
533
|
-
listCache.items = null
|
|
534
|
-
return { deleted, stores: clearedStores, failed }
|
|
535
|
-
})
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
const endpoints = {
|
|
539
|
-
'init': async (args) => {
|
|
540
|
-
if (!supported) {
|
|
541
|
-
return { ok: false, root: null, notice: { unsupported: true } }
|
|
542
|
-
}
|
|
543
|
-
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
544
|
-
const root = await rt.resolveRoot(sessionId)
|
|
545
|
-
let notice = null
|
|
546
|
-
if (root) {
|
|
547
|
-
let store = await rt.resolveStore(root)
|
|
548
|
-
store = await rt.tryUpgradeToHome(root)
|
|
549
|
-
await rt.ensureGit(root, store)
|
|
550
|
-
await snaps.loadIndex(root, sessionId)
|
|
551
|
-
await snaps.rebuildOrphans(root, sessionId)
|
|
552
|
-
rt.cleanupLegacy(root)
|
|
553
|
-
// 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
|
|
554
|
-
// gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
|
|
555
|
-
// 不可写,快照降级存进项目内 .dsh-recall-snapshots。
|
|
556
|
-
notice = {
|
|
557
|
-
gitMissing: state.gitExe === '',
|
|
558
|
-
homeFallback: store ? !store.home : false
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
// 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
|
|
562
|
-
// init 是每会话必经的预热通道
|
|
563
|
-
return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } }
|
|
564
|
-
},
|
|
565
|
-
|
|
566
|
-
'snapshot-info': async (args) => {
|
|
567
|
-
const id = args && args.messageId ? String(args.messageId) : ''
|
|
568
|
-
const snap = state.snapshots.get(id)
|
|
569
|
-
// 失败/跳过/熔断反馈(issue #7 失败可见性):客户端轮询到 failed 即
|
|
570
|
-
// 终止轮询并 toast,不再空等 20 次;has 时附带 skipped 让用户知道
|
|
571
|
-
// fail-open 跳过了哪些路径
|
|
572
|
-
const feedback = await snaps.feedbackFor(args && args.sessionId, id)
|
|
573
|
-
return { has: Boolean(snap), time: snap ? snap.time : null, id, ...feedback }
|
|
574
|
-
},
|
|
575
|
-
|
|
576
|
-
'preview': async (args) => {
|
|
577
|
-
const id = args && args.messageId ? String(args.messageId) : ''
|
|
578
|
-
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
579
|
-
// P0-1:目标工作区 agent 运行中直接拒绝预览(避免用户确认时文件被
|
|
580
|
-
// agent 改动,预览清单与实际回退内容脱节)。同会话优先命中(最常见
|
|
581
|
-
// 场景),快照存在时叠加跨会话同工作区检查。
|
|
582
|
-
const snap = state.snapshots.get(id)
|
|
583
|
-
if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: 'AGENT_BUSY', message: 'Agent 正在运行中,请先停止后再撤回' }
|
|
584
|
-
const result = await enqueue(() => snaps.diffFor(id))
|
|
585
|
-
if (result === null) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
|
|
586
|
-
const snap2 = state.snapshots.get(id)
|
|
587
|
-
const cutSeq = await snaps.resolveCutSeq(sessionId, id)
|
|
588
|
-
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
|
|
589
|
-
},
|
|
590
|
-
|
|
591
|
-
'execute': async (args) => {
|
|
592
|
-
const id = args && args.messageId ? String(args.messageId) : ''
|
|
593
|
-
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
594
|
-
const result = await enqueue(async () => {
|
|
595
|
-
const snap = state.snapshots.get(id)
|
|
596
|
-
if (!snap) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
|
|
597
|
-
const store = state.stores.get(snap.root)
|
|
598
|
-
if (!store) return { ok: false, code: 'NO_STORE', message: '快照存储不可用' }
|
|
599
|
-
// P0-1:队列内第一步——执行前再查一次 agent 状态。检查放在互斥
|
|
600
|
-
// 队列内,检查后紧接执行,中间不可能插进别的操作,窗口为零。
|
|
601
|
-
if (agentBusy(sessionId, snap.root)) return { ok: false, code: 'AGENT_BUSY', message: 'Agent 正在运行中,请先停止后再撤回' }
|
|
602
|
-
// P0-3:preview→execute 失效校验。只由带 previewTotal 的新版
|
|
603
|
-
// Client 触发(老版本/直调 API 不带则跳过,向后兼容)。校验失败
|
|
604
|
-
// 连安全快照都不打——省一次全量 add。同数不同文件的边缘情形由
|
|
605
|
-
// 下方 pre-rollback 安全快照兜底。
|
|
606
|
-
if (args && typeof args.previewTotal === 'number') {
|
|
607
|
-
const fresh = await snaps.diffFor(id)
|
|
608
|
-
if (!fresh || fresh.total !== args.previewTotal) {
|
|
609
|
-
return { ok: false, code: 'STALE', message: '预览后项目文件发生了变化,请重新预览确认' }
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
// 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
|
|
613
|
-
// 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
|
|
614
|
-
// 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
|
|
615
|
-
// (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
|
|
616
|
-
// 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
|
|
617
|
-
const safetyId = 'pre-rollback-' + Date.now()
|
|
618
|
-
try {
|
|
619
|
-
await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
620
|
-
} catch (error) {
|
|
621
|
-
// 安全快照失败不阻断回退本身:用户已确认覆盖,记录后照原计划执行
|
|
622
|
-
rt.recordError('recall safety snapshot failed: ' + String(error))
|
|
623
|
-
}
|
|
624
|
-
return snaps.rollbackFor(id)
|
|
625
|
-
})
|
|
626
|
-
if (!result.ok) return result
|
|
627
|
-
// 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
|
|
628
|
-
const cutSeq = await snaps.resolveCutSeq(sessionId, id)
|
|
629
|
-
return { ok: true, count: result.count, cutSeq }
|
|
630
|
-
},
|
|
631
|
-
|
|
632
|
-
'exclude-get': async () => {
|
|
633
|
-
// 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
|
|
634
|
-
// 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
|
|
635
|
-
if (!supported) return { ok: false, unsupported: true }
|
|
636
|
-
// 30s 结果缓存:首次进入要并行 resolveStore + 逐文件 shell 读,
|
|
637
|
-
// 二次打开/切标签不应重复付出这份代价;exclude-set 写入后失效。
|
|
638
|
-
if (excludeCache.payload && Date.now() - excludeCache.at < 30000) return excludeCache.payload
|
|
639
|
-
const byFile = await listExcludeFiles()
|
|
640
|
-
// 并行读取各 exclude 文件内容:每个文件一条 shell,串行会放大延迟
|
|
641
|
-
const files = await Promise.all(Array.from(byFile.entries()).map(async ([path, info]) => {
|
|
642
|
-
let content = ''
|
|
643
|
-
try { content = await snaps.readExclude(info.store) } catch (error) { content = '' }
|
|
644
|
-
return { path, home: Boolean(info.store.home), roots: info.roots, content }
|
|
645
|
-
}))
|
|
646
|
-
const payload = { ok: true, files }
|
|
647
|
-
excludeCache = { at: Date.now(), payload }
|
|
648
|
-
return payload
|
|
649
|
-
},
|
|
650
|
-
|
|
651
|
-
'exclude-set': async (args) => {
|
|
652
|
-
if (!supported) return { ok: false, unsupported: true }
|
|
653
|
-
const path = args && args.path ? String(args.path) : ''
|
|
654
|
-
const content = args && typeof args.content === 'string' ? args.content : ''
|
|
655
|
-
// 路径白名单:重新枚举当前已知 exclude 文件并要求精确命中,
|
|
656
|
-
// 客户端伪造的任意路径在这里被拒(见 listExcludeFiles 注释)
|
|
657
|
-
const byFile = await listExcludeFiles()
|
|
658
|
-
const info = byFile.get(path)
|
|
659
|
-
if (!info) return { ok: false, code: 'UNKNOWN_PATH', message: '未知的排除文件路径' }
|
|
660
|
-
await snaps.writeExclude(info.store, content)
|
|
661
|
-
// 写入后立即失效:设置页保存后刷新必须看到最新内容
|
|
662
|
-
excludeCache.payload = null
|
|
663
|
-
return { ok: true }
|
|
664
|
-
},
|
|
665
|
-
|
|
666
|
-
// 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段(来自
|
|
667
|
-
// settings.describe 的 user 层,字段在层里出现即用户覆盖)+ env 锁定
|
|
668
|
-
// 字段(环境变量优先级最高,设置改不动)+ 可写性(只读 provider 禁存)。
|
|
669
|
-
'config-get': async () => {
|
|
670
|
-
const envLocks = {
|
|
671
|
-
gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
|
|
672
|
-
gcHours: Boolean(process.env && process.env.DSH_RECALL_GC_HOURS),
|
|
673
|
-
}
|
|
674
|
-
let overridden = {}
|
|
675
|
-
let writable = false
|
|
676
|
-
try {
|
|
677
|
-
const settings = ctx.get('settings')
|
|
678
|
-
if (settings && typeof settings.describe === 'function') {
|
|
679
|
-
const list = settings.describe()
|
|
680
|
-
const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === 'dsh-recall')
|
|
681
|
-
if (ours && ours.user && typeof ours.user === 'object') overridden = ours.user
|
|
682
|
-
writable = settings.writable !== false
|
|
683
|
-
}
|
|
684
|
-
} catch (error) { /* describe 不可用按「无覆盖」处理 */ }
|
|
685
|
-
return {
|
|
686
|
-
ok: true,
|
|
687
|
-
values: {
|
|
688
|
-
gcSnaps: cfg.gcSnaps,
|
|
689
|
-
gcHours: cfg.gcHours,
|
|
690
|
-
maxFileBytes: cfg.maxFileBytes,
|
|
691
|
-
maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
|
|
692
|
-
baseExcludes: cfg.baseExcludes.slice(),
|
|
693
|
-
refillDraft: cfg.refillDraft,
|
|
694
|
-
snapshotEnabled: cfg.snapshotEnabled,
|
|
695
|
-
archiveOriginal: cfg.archiveOriginal,
|
|
696
|
-
retentionDays: cfg.retentionDays,
|
|
697
|
-
},
|
|
698
|
-
overridden,
|
|
699
|
-
envLocks,
|
|
700
|
-
writable,
|
|
701
|
-
}
|
|
702
|
-
},
|
|
703
|
-
|
|
704
|
-
// 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
|
|
705
|
-
// 写进用户层(schema 校验失败会在持久化前 reject,错误信息回显卡片),
|
|
706
|
-
// watch 链路把新值热更新进 cfg,无需重启。
|
|
707
|
-
'config-set': async (args) => {
|
|
708
|
-
const patch = args && args.patch && typeof args.patch === 'object' ? args.patch : {}
|
|
709
|
-
const clean = {}
|
|
710
|
-
if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
|
|
711
|
-
if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
|
|
712
|
-
if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
|
|
713
|
-
if (patch.maxSnapshotsPerWorkspace !== undefined) {
|
|
714
|
-
const n = Number(patch.maxSnapshotsPerWorkspace)
|
|
715
|
-
// 0 或负值 = 不限制(schema 由 number 校验,非法 NaN 在 settings.write 层被拒)
|
|
716
|
-
if (!Number.isFinite(n)) return { ok: false, code: 'BAD_TYPE', message: '快照总量上限必须是数字' }
|
|
717
|
-
clean.maxSnapshotsPerWorkspace = Math.max(0, n)
|
|
718
|
-
}
|
|
719
|
-
if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
|
|
720
|
-
if (patch.snapshotEnabled !== undefined) clean.snapshotEnabled = Boolean(patch.snapshotEnabled)
|
|
721
|
-
if (patch.archiveOriginal !== undefined) clean.archiveOriginal = Boolean(patch.archiveOriginal)
|
|
722
|
-
if (patch.retentionDays !== undefined) {
|
|
723
|
-
const n = Number(patch.retentionDays)
|
|
724
|
-
// 0/负值 = 不启用(schema 校验 base 由 number 承担,NaN 由 settings.write 拒)
|
|
725
|
-
if (!Number.isFinite(n) || n < 0) return { ok: false, code: 'BAD_TYPE', message: '保留天数必须是 >= 0 的数字(0 表示不启用)' }
|
|
726
|
-
clean.retentionDays = Math.trunc(n)
|
|
727
|
-
}
|
|
728
|
-
if (patch.baseExcludes !== undefined) {
|
|
729
|
-
if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
|
|
730
|
-
clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
|
|
731
|
-
}
|
|
732
|
-
if (!Object.keys(clean).length) return { ok: false, code: 'EMPTY_PATCH', message: '没有可写入的配置字段' }
|
|
733
|
-
let settings = null
|
|
734
|
-
try { settings = ctx.get('settings') } catch (error) { settings = null }
|
|
735
|
-
if (!settings || typeof settings.update !== 'function') {
|
|
736
|
-
return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
|
|
737
|
-
}
|
|
738
|
-
try {
|
|
739
|
-
await settings.update('dsh-recall', clean)
|
|
740
|
-
} catch (error) {
|
|
741
|
-
return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '配置写入失败:' + String(error && error.message ? error.message : error) }
|
|
742
|
-
}
|
|
743
|
-
return { ok: true }
|
|
744
|
-
},
|
|
745
|
-
|
|
746
|
-
// 设置页「快照管理」卡片:列表 / 磁盘占用 / 单条删除 / 手动 gc。
|
|
747
|
-
// 全部走串行队列——删除 tag 与 gc 与快照争的是同一个 git 仓库。
|
|
748
|
-
'manage': async (args) => {
|
|
749
|
-
if (!supported) return { ok: false, unsupported: true }
|
|
750
|
-
const op = args && args.op ? String(args.op) : 'list'
|
|
751
|
-
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
752
|
-
if (op === 'list') {
|
|
753
|
-
// 结果缓存(30s + 删除/新快照失效):设置页反复打开、删除后刷新
|
|
754
|
-
// 都会重拉列表,缓存让二次打开零 shell。缓存存的是全量排序数组,
|
|
755
|
-
// 响应按请求的 limit 切片——「加载更多」无需重扫磁盘(S1-2)。
|
|
756
|
-
const limitRaw = args && args.limit !== undefined ? Number(args.limit) : 200
|
|
757
|
-
const safeLimit = Math.min(Math.max(Number.isFinite(limitRaw) ? Math.trunc(limitRaw) : 200, 1), 2000)
|
|
758
|
-
if (listCache.items && Date.now() - listCache.at < 30000) {
|
|
759
|
-
return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length }
|
|
760
|
-
}
|
|
761
|
-
const allItems = []
|
|
762
|
-
|
|
763
|
-
// 磁盘全量:一条 shell dump(dumpStores 见其注释——旧实现每目录
|
|
764
|
-
// 2-3 条 shell 串行跑,20 秒级慢的根因)。root 解析链:条目自带
|
|
765
|
-
// root(新数据)→ root.txt → 内存 store 映射(store 目录名是
|
|
766
|
-
// root 的单向 SHA256,磁盘上只有持久化记录能反查)。
|
|
767
|
-
// 标题只查 live/缓存(liveTitleFast,同步瞬时)——冷会话标题由
|
|
768
|
-
// Client 拿到列表后异步调 titles 补齐,列表首屏不等日志解压。
|
|
769
|
-
const dump = await dumpStores()
|
|
770
|
-
const hints = new Map()
|
|
771
|
-
for (const [root, st] of state.stores.entries()) {
|
|
772
|
-
if (st && st.dir) hints.set(st.dir, root)
|
|
773
|
-
}
|
|
774
|
-
// 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照
|
|
775
|
-
// 因「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
|
|
776
|
-
const byId = new Map()
|
|
777
|
-
function push(id, time, root, sessionId) {
|
|
778
|
-
if (!id || typeof id !== 'string') return
|
|
779
|
-
const old = byId.get(id)
|
|
780
|
-
if (!old) {
|
|
781
|
-
const rec = {
|
|
782
|
-
id,
|
|
783
|
-
time: typeof time === 'number' ? time : 0,
|
|
784
|
-
root: root || null,
|
|
785
|
-
workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
|
|
786
|
-
sessionId: sessionId || null,
|
|
787
|
-
sessionTitle: liveTitleFast(sessionId)
|
|
788
|
-
}
|
|
789
|
-
// 消息文本只放已确认值:live 命中字符串则带,否则不设字段。
|
|
790
|
-
// 客户端据此判断「还没请求冷日志」;messages 端点补齐后字符串
|
|
791
|
-
// 或 null 都会写入,null 表示确实无文本,避免每次刷新重复请求。
|
|
792
|
-
const liveText = liveMessageTextFast(sessionId, id)
|
|
793
|
-
if (liveText) rec.messageText = liveText
|
|
794
|
-
byId.set(id, rec)
|
|
795
|
-
allItems.push(rec)
|
|
796
|
-
return
|
|
797
|
-
}
|
|
798
|
-
// 与 collectAllSnapshotRecords 同款补全:磁盘先占位、内存后补全
|
|
799
|
-
// root 时,若按「首次命中即丢弃」会让树形一级节点落进未知工作区。
|
|
800
|
-
if (!old.root && root) { old.root = root; old.workspace = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || null }
|
|
801
|
-
if (!old.sessionId && sessionId) { old.sessionId = sessionId; old.sessionTitle = liveTitleFast(sessionId) }
|
|
802
|
-
if (!old.messageText && id) { old.messageText = liveMessageTextFast(sessionId, id) }
|
|
803
|
-
if (!old.time && time) old.time = time
|
|
804
|
-
}
|
|
805
|
-
for (const [dir, info] of dump) {
|
|
806
|
-
const baseRoot = info.root || hints.get(dir) || null
|
|
807
|
-
for (const e of info.entries || []) {
|
|
808
|
-
if (!e || typeof e.id !== 'string') continue
|
|
809
|
-
push(e.id, e.time, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId)
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
// 内存兜底(刚拍未落盘的保险,正常已被磁盘 dump 覆盖)
|
|
813
|
-
for (const [id, s] of state.snapshots.entries()) {
|
|
814
|
-
push(id, s.time, s.root, s.sessionId)
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
|
|
818
|
-
listCache = { at: Date.now(), items: allItems }
|
|
819
|
-
return { ok: true, items: allItems.slice(0, safeLimit), total: allItems.length }
|
|
820
|
-
}
|
|
821
|
-
if (op === 'titles') {
|
|
822
|
-
// 冷会话标题补齐(Client 异步二次请求):readSession 整日志解压 +
|
|
823
|
-
// 重放校验,大日志 10 秒级——独立于列表让首屏即时。并发交给
|
|
824
|
-
// sessionQuery 自带的 inspect 并发闸,这里全量并行发车。
|
|
825
|
-
if (!supported) return { ok: false, unsupported: true }
|
|
826
|
-
const ids = Array.from(new Set(
|
|
827
|
-
(Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String) : []).filter(Boolean)
|
|
828
|
-
)).slice(0, 100)
|
|
829
|
-
const out = {}
|
|
830
|
-
// 并发限 4:冷标题 readSession 是重 IO,全量并发会把首次设置页
|
|
831
|
-
// 的 shell/日志解压都挤在一起;限制后列表本身不受影响,标题渐进补齐。
|
|
832
|
-
await runLimited(ids.map((sid) => async () => {
|
|
833
|
-
if (out[sid] !== undefined) return
|
|
834
|
-
let title = liveTitleFast(sid)
|
|
835
|
-
if (title === null) {
|
|
836
|
-
const query = ctx.get('sessionQuery')
|
|
837
|
-
if (query && typeof query.readSession === 'function') {
|
|
838
|
-
try {
|
|
839
|
-
const log = await query.readSession(sid)
|
|
840
|
-
title = titleFromEvents(log && log.events)
|
|
841
|
-
} catch (error) { title = null }
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
sessionTitles.set(sid, title)
|
|
845
|
-
out[sid] = title
|
|
846
|
-
}), 4)
|
|
847
|
-
return { ok: true, titles: out }
|
|
848
|
-
}
|
|
849
|
-
if (op === 'messages') {
|
|
850
|
-
// 冷会话消息文本补齐:与 titles 同款两段式,独立端点避免和标题
|
|
851
|
-
// 请求的 sessionIds/messageIds 对应关系纠缠。输入 [{sessionId, messageId}],
|
|
852
|
-
// 缺 live 文本的消息才需要补;同一会话多个消息共享一次 readSession。
|
|
853
|
-
if (!supported) return { ok: false, unsupported: true }
|
|
854
|
-
const reqs = Array.isArray(args && args.requests) ? args.requests.slice(0, 200) : []
|
|
855
|
-
const bySession = new Map()
|
|
856
|
-
for (const r of reqs) {
|
|
857
|
-
const sid = r && r.sessionId ? String(r.sessionId) : null
|
|
858
|
-
const mid = r && r.messageId ? String(r.messageId) : null
|
|
859
|
-
if (!sid || !mid) continue
|
|
860
|
-
if (!bySession.has(sid)) bySession.set(sid, [])
|
|
861
|
-
bySession.get(sid).push(mid)
|
|
862
|
-
}
|
|
863
|
-
const texts = {}
|
|
864
|
-
await runLimited(Array.from(bySession.entries()).map(([sid, mids]) => async () => {
|
|
865
|
-
// 该会话所有消息都已缓存(含 null)时,不必 readSession 冷读
|
|
866
|
-
const allCached = mids.every((mid) => messageTexts.has(String(sid) + '\u0000' + String(mid)))
|
|
867
|
-
let log = null
|
|
868
|
-
if (!allCached) {
|
|
869
|
-
const query = ctx.get('sessionQuery')
|
|
870
|
-
if (query && typeof query.readSession === 'function') {
|
|
871
|
-
try {
|
|
872
|
-
log = await query.readSession(sid)
|
|
873
|
-
} catch (error) { log = null }
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
for (const mid of mids) {
|
|
877
|
-
const key = String(sid) + '\u0000' + String(mid)
|
|
878
|
-
// 缓存命中(含 null)直接复用,避免已确认无文本的消息反复冷读
|
|
879
|
-
if (messageTexts.has(key)) {
|
|
880
|
-
texts[mid] = messageTexts.get(key)
|
|
881
|
-
continue
|
|
882
|
-
}
|
|
883
|
-
let text = liveMessageTextFast(sid, mid)
|
|
884
|
-
if (text === null && log && Array.isArray(log.events)) {
|
|
885
|
-
text = messageTextFromEvents(log.events, mid)
|
|
886
|
-
}
|
|
887
|
-
// 与 sessionTitles 同款缓存策略:null 也缓存,避免冷会话反复
|
|
888
|
-
// readSession 查同一个查不到文本的消息。
|
|
889
|
-
messageTexts.set(key, text)
|
|
890
|
-
texts[mid] = text
|
|
891
|
-
}
|
|
892
|
-
}), 4)
|
|
893
|
-
return { ok: true, messageTexts: texts }
|
|
894
|
-
}
|
|
895
|
-
if (op === 'usage') {
|
|
896
|
-
let bytes = 0
|
|
897
|
-
// 存储健康统计(S2-4):仅对内存已知 store 计数——与汇总同源,
|
|
898
|
-
// 冷启动预热未完成时不完整,属已知限制(见 plan-settings-ux S2-4)
|
|
899
|
-
let homeStores = 0
|
|
900
|
-
let fallbackStores = 0
|
|
901
|
-
if (sessionId) {
|
|
902
|
-
// 旧调用方(带会话上下文):单工作区占用
|
|
903
|
-
const root = await rt.resolveRoot(sessionId)
|
|
904
|
-
if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
|
|
905
|
-
const store = state.stores.get(root)
|
|
906
|
-
if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
|
|
907
|
-
if (store.home) homeStores++
|
|
908
|
-
else fallbackStores++
|
|
909
|
-
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
910
|
-
bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
911
|
-
} else {
|
|
912
|
-
// 新调用方(settings.plugin.item 卡片无会话上下文):全部已知
|
|
913
|
-
// store 汇总。store 全集取内存缓存(启动预热填齐);单个失败
|
|
914
|
-
// 不影响汇总,best-effort。
|
|
915
|
-
for (const store of state.stores.values()) {
|
|
916
|
-
if (!store || !store.dir) continue
|
|
917
|
-
if (store.home) homeStores++
|
|
918
|
-
else fallbackStores++
|
|
919
|
-
try {
|
|
920
|
-
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
921
|
-
bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
922
|
-
} catch (error) { /* 单 store 失败跳过 */ }
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
return { ok: true, bytes, gitAvailable: state.gitExe !== '', homeStores, fallbackStores }
|
|
926
|
-
}
|
|
927
|
-
if (op === 'delete') {
|
|
928
|
-
// 统一删除入口:scope=workspace 删除整个工作区全部快照;
|
|
929
|
-
// scope=session 删除某会话全部快照;scope=snapshot(缺省)单条删除。
|
|
930
|
-
// 树形管理每级右侧都有删除按钮,三种粒度共用此端点。
|
|
931
|
-
const scope = args && args.scope ? String(args.scope) : 'snapshot'
|
|
932
|
-
const root = args && args.root ? String(args.root) : null
|
|
933
|
-
const targetSessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
934
|
-
const id = args && args.messageId ? String(args.messageId) : ''
|
|
935
|
-
if (scope === 'workspace') {
|
|
936
|
-
if (!root) return { ok: false, code: 'NO_ROOT', message: '缺少工作区路径' }
|
|
937
|
-
const deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId)
|
|
938
|
-
return { ok: true, deleted }
|
|
939
|
-
}
|
|
940
|
-
if (scope === 'session') {
|
|
941
|
-
if (!targetSessionId) return { ok: false, code: 'NO_SESSION', message: '缺少会话 ID' }
|
|
942
|
-
// 树形中会话挂在具体工作区下,客户端会传 root 限定范围;不传则
|
|
943
|
-
// 保持旧语义(删该会话全部工作区的快照),兼容老调用方。
|
|
944
|
-
const deleted = await deleteSnapshotsByFilter(
|
|
945
|
-
(rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
|
|
946
|
-
sessionId
|
|
947
|
-
)
|
|
948
|
-
return { ok: true, deleted }
|
|
949
|
-
}
|
|
950
|
-
// 管理列表来自磁盘(跨工作区全量),而内存 state.snapshots 只含
|
|
951
|
-
// 当前工作区 + 预热过的——冷启动或别的会话先点开列表时,列表里有、
|
|
952
|
-
// 内存里没有,只查内存会把可删的快照误报「不存在」。解析链:
|
|
953
|
-
// 内存命中 → Client 透传的条目 root → 磁盘 index 反查归属 store。
|
|
954
|
-
let snap = state.snapshots.get(id) || null
|
|
955
|
-
let snapRoot = snap ? snap.root : root
|
|
956
|
-
let store = null
|
|
957
|
-
if (snapRoot) {
|
|
958
|
-
try { store = await rt.resolveStore(snapRoot) } catch (error) { store = null }
|
|
959
|
-
}
|
|
960
|
-
if (!store) {
|
|
961
|
-
// 兜底:扫 home 容器与降级目录的 index.json,找到含该 id 的 store
|
|
962
|
-
const found = await locateSnapshotOnDisk(id)
|
|
963
|
-
if (found) { store = found.store; snapRoot = found.root }
|
|
964
|
-
}
|
|
965
|
-
if (!store) return { ok: false, code: 'NO_SNAPSHOT', message: '该快照不存在' }
|
|
966
|
-
const finalStore = store
|
|
967
|
-
const finalRoot = snapRoot
|
|
968
|
-
await enqueue(async () => {
|
|
969
|
-
if (state.gitExe) {
|
|
970
|
-
await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ['snap-' + id]), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
971
|
-
}
|
|
972
|
-
// 兜底路径到这里时内存可能还没载入过该 root 的索引——直接
|
|
973
|
-
// saveIndex 会用「只有内存条目」的列表覆盖 index.json,把同
|
|
974
|
-
// store 其余磁盘快照一并抹掉。先 loadIndex 补齐内存视图(幂等,
|
|
975
|
-
// indexLoaded 命中则零成本),再删目标条目后重写。
|
|
976
|
-
if (!state.indexLoaded.has(finalRoot)) {
|
|
977
|
-
try { await snaps.loadIndex(finalRoot, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
|
|
978
|
-
}
|
|
979
|
-
state.snapshots.delete(id)
|
|
980
|
-
await snaps.saveIndex(finalRoot, sessionId)
|
|
981
|
-
// 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
|
|
982
|
-
listCache.items = null
|
|
983
|
-
})
|
|
984
|
-
return { ok: true }
|
|
985
|
-
}
|
|
986
|
-
if (op === 'deleteAll') {
|
|
987
|
-
const result = await deleteAllSnapshots()
|
|
988
|
-
if (result.failed > 0) {
|
|
989
|
-
return {
|
|
990
|
-
ok: false,
|
|
991
|
-
code: 'PARTIAL_DELETE',
|
|
992
|
-
deleted: result.deleted,
|
|
993
|
-
message: result.message || ('已删除 ' + result.deleted + ' 条快照,但有 ' + result.failed + ' 个存储未完成;请查看最近错误后重试')
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
return { ok: true, deleted: result.deleted, stores: result.stores }
|
|
997
|
-
}
|
|
998
|
-
if (op === 'gc') {
|
|
999
|
-
// 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):
|
|
1000
|
-
// 全部已知 store 逐个 gc。两者都排进串行队列,与快照互斥。
|
|
1001
|
-
const done = sessionId
|
|
1002
|
-
? await enqueue(() => maint.runGc(sessionId, true))
|
|
1003
|
-
: await enqueue(() => maint.runGcAll())
|
|
1004
|
-
return { ok: true, gc: Boolean(done) }
|
|
1005
|
-
}
|
|
1006
|
-
return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
|
|
1007
|
-
},
|
|
1008
|
-
|
|
1009
|
-
// 设置页「插件配置」卡片恢复默认:整段清空 user 层回组合 base——官方
|
|
1010
|
-
// settings RPC 的 replace 明确是「restoration/reset 路径」(section:{}
|
|
1011
|
-
// 重置为组合默认,见 dsh-host-apiproxy api/settings.d.ts S1-3 核验)。
|
|
1012
|
-
// 比逐字段写默认值干净:重置后字段不再被标 user-overridden,schema 或
|
|
1013
|
-
// cordis patch 行默认值变更时 reset 跟随,不冻结历史值。老版本服务
|
|
1014
|
-
// 没有 replace 时降级 settings.update 写 DEFAULTS(user 层仍出现标记,
|
|
1015
|
-
// 行为等价,缺陷见 plan-settings-ux S1-3)。
|
|
1016
|
-
'config-reset': async () => {
|
|
1017
|
-
let settings = null
|
|
1018
|
-
try { settings = ctx.get('settings') } catch (error) { settings = null }
|
|
1019
|
-
if (!settings || typeof settings.update !== 'function') {
|
|
1020
|
-
return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
|
|
1021
|
-
}
|
|
1022
|
-
try {
|
|
1023
|
-
if (typeof settings.replace === 'function') {
|
|
1024
|
-
await settings.replace('dsh-recall', {})
|
|
1025
|
-
} else {
|
|
1026
|
-
await settings.update('dsh-recall', Object.assign({}, DEFAULTS, { baseExcludes: DEFAULTS.baseExcludes.slice() }))
|
|
1027
|
-
}
|
|
1028
|
-
} catch (error) {
|
|
1029
|
-
return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '恢复默认失败:' + String(error && error.message ? error.message : error) }
|
|
1030
|
-
}
|
|
1031
|
-
// 重置后热更运行中的 cfg(与 config-set 同链路的 watch 触发,这里做
|
|
1032
|
-
// 双保险:descriptor 已变更,applyResolvedConfig 立即落地)
|
|
1033
|
-
applyResolvedConfig(readSettings())
|
|
1034
|
-
return { ok: true }
|
|
1035
|
-
},
|
|
1036
|
-
|
|
1037
|
-
// 设置页排障:最近错误(Host 侧 console.error 的页面可见副本)。
|
|
1038
|
-
// S3-5:支持 { op: 'clear' } 清空页面可见缓冲——只清 state.errors,
|
|
1039
|
-
// 不影响 console 本身的留痕;清空后设置页「最近错误」为空。
|
|
1040
|
-
'status': async (args) => {
|
|
1041
|
-
if (args && args.op === 'clear') {
|
|
1042
|
-
state.errors.length = 0
|
|
1043
|
-
return { ok: true, errors: [] }
|
|
1044
|
-
}
|
|
1045
|
-
return { ok: true, errors: state.errors.slice(-20).reverse() }
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
ctx.effect(() => webServer.register({
|
|
1050
|
-
kind: 'prefix',
|
|
1051
|
-
path: '/api/recall',
|
|
1052
|
-
handler: async (req, res) => {
|
|
1053
|
-
const path = (req.url || '').split('?')[0]
|
|
1054
|
-
const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
|
|
1055
|
-
const endpoint = endpoints[name]
|
|
1056
|
-
if (!endpoint) {
|
|
1057
|
-
sendJson(res, 404, { ok: false, code: 'UNKNOWN_ENDPOINT', message: 'unknown endpoint: ' + name })
|
|
1058
|
-
return
|
|
1059
|
-
}
|
|
1060
|
-
try {
|
|
1061
|
-
const args = await readJsonBody(req)
|
|
1062
|
-
sendJson(res, 200, await endpoint(args))
|
|
1063
|
-
} catch (error) {
|
|
1064
|
-
sendJson(res, 200, errBody(error))
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
}))
|
|
1068
|
-
|
|
1069
|
-
// 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
|
|
1070
|
-
if (!supported) return
|
|
1071
|
-
|
|
1072
|
-
// 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
|
|
1073
|
-
// (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
|
|
1074
|
-
ctx.on('session/event', (session, event) => {
|
|
1075
|
-
if (!event || event.type !== 'user/message') return
|
|
1076
|
-
const data = event.data
|
|
1077
|
-
if (!data || typeof data.id !== 'string' || !data.id) return
|
|
1078
|
-
const source = data.source
|
|
1079
|
-
if (!source || source.kind !== 'user') return
|
|
1080
|
-
if (session && session.header && session.header.origin === 'subagent') return
|
|
1081
|
-
const messageId = data.id
|
|
1082
|
-
const time = event.time
|
|
1083
|
-
state.queue = state.queue
|
|
1084
|
-
// 快照总开关(S2-1):cfg 按调用时读取,设置页热更即时生效。
|
|
1085
|
-
// 关闭时只冻结新建,maybeMaintain 照常跑——已停增的存储仍需被
|
|
1086
|
-
// gc/清理治理。
|
|
1087
|
-
.then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
|
|
1088
|
-
.then(() => maint.maybeMaintain(session.id))
|
|
1089
|
-
.then(() => { listCache.items = null })
|
|
1090
|
-
.catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
|
|
1091
|
-
})
|
|
1092
|
-
|
|
1093
|
-
// 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
|
|
1094
|
-
// 并清理旧版项目内 blobs 目录(home 可用时)。
|
|
1095
|
-
// 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
|
|
1096
|
-
// 冷启动时 ctx.sessions.list() 常为空(惰性载入),必须叠加
|
|
1097
|
-
// sessionQuery.listSessions() 冷元数据,否则设置页首次打开仍要现场建 store。
|
|
1098
|
-
// apply 不是 async,这里用 IIFE 把冷元数据扫描包成异步任务。
|
|
1099
|
-
;(async () => {
|
|
1100
|
-
const warmupRoots = new Map()
|
|
1101
|
-
for (const session of ctx.sessions.list()) {
|
|
1102
|
-
const cwd = session && session.header && session.header.cwd
|
|
1103
|
-
if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
|
|
1104
|
-
}
|
|
1105
|
-
const querySvc = ctx.get('sessionQuery')
|
|
1106
|
-
if (querySvc && typeof querySvc.listSessions === 'function') {
|
|
1107
|
-
try {
|
|
1108
|
-
const records = await querySvc.listSessions()
|
|
1109
|
-
for (const record of records || []) {
|
|
1110
|
-
// listSessions 记录形如 {header, live, persisted},会话 id 在
|
|
1111
|
-
// header.id——此前误用顶层 record.id(恒 undefined),预热重建
|
|
1112
|
-
// 的孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
|
|
1113
|
-
const id = record && record.header && record.header.id ? record.header.id : null
|
|
1114
|
-
const cwd = record && record.header && record.header.cwd
|
|
1115
|
-
if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
|
|
1116
|
-
}
|
|
1117
|
-
} catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
|
|
1118
|
-
}
|
|
1119
|
-
for (const [cwd, sessionId] of warmupRoots) {
|
|
1120
|
-
Promise.resolve(rt.resolveStore(cwd))
|
|
1121
|
-
.then(() => rt.tryUpgradeToHome(cwd))
|
|
1122
|
-
.then((store) => rt.ensureGit(cwd, store))
|
|
1123
|
-
.then(() => snaps.loadIndex(cwd, sessionId))
|
|
1124
|
-
.then(() => snaps.rebuildOrphans(cwd, sessionId))
|
|
1125
|
-
.then(() => rt.cleanupLegacy(cwd))
|
|
1126
|
-
.catch(() => {})
|
|
1127
|
-
}
|
|
1128
|
-
})()
|
|
1129
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — Host 入口(持久插件形态,bundle 行挂载)
|
|
3
|
+
*
|
|
4
|
+
* 职责:装配各域模块(config / store / snapshots / maintenance / session-info /
|
|
5
|
+
* routes-core / routes-manage),通过 webServer 注册 /api/recall/* HTTP API
|
|
6
|
+
* 供 Client 半调用,并接线 session/event 快照触发与启动预热。
|
|
7
|
+
*
|
|
8
|
+
* 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml 的
|
|
9
|
+
* insert 行挂载进 profile composition,DSH 重启后自动生效。业务逻辑已拆到
|
|
10
|
+
* lib/ 各域模块(routes-core / routes-manage / session-info),本文件只做
|
|
11
|
+
* 接线与 store 发现/执行工具,不承载端点业务。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createConfig, Config, DEFAULTS } from './config.js'
|
|
15
|
+
import { createRuntime } from './store.js'
|
|
16
|
+
import { createSnapshots, rescueRollback } from './snapshots.js'
|
|
17
|
+
import { createMaintenance } from './maintenance.js'
|
|
18
|
+
import { createSessionInfo, titleFromEvents, messageTextFromEvents } from './session-info.js'
|
|
19
|
+
import { createRoutesCore } from './routes-core.js'
|
|
20
|
+
import { createRoutesManage } from './routes-manage.js'
|
|
21
|
+
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
|
22
|
+
import * as E from './errors.js'
|
|
23
|
+
|
|
24
|
+
export const name = 'dsh-recall-plugin'
|
|
25
|
+
|
|
26
|
+
// 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
|
|
27
|
+
// webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
|
|
28
|
+
// agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
|
|
29
|
+
// 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
|
|
30
|
+
// "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
|
|
31
|
+
// 其余服务按需 ctx.get。
|
|
32
|
+
export const inject = ['shell', 'sessions', 'webServer', 'agents']
|
|
33
|
+
|
|
34
|
+
// 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
|
|
35
|
+
// 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
|
|
36
|
+
export { Config }
|
|
37
|
+
|
|
38
|
+
// config 由 cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
|
|
39
|
+
// 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
|
|
40
|
+
// (见下方 installSettingsSection 接线)
|
|
41
|
+
export function apply(ctx, config) {
|
|
42
|
+
const webServer = ctx.webServer
|
|
43
|
+
|
|
44
|
+
const cfg = createConfig(config)
|
|
45
|
+
const rt = createRuntime(ctx, cfg)
|
|
46
|
+
const snaps = createSnapshots(ctx, rt, cfg)
|
|
47
|
+
const maint = createMaintenance(ctx, rt, snaps, cfg)
|
|
48
|
+
const state = rt.state
|
|
49
|
+
|
|
50
|
+
// ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
|
|
51
|
+
// installSettingsSection(dsh-settings 官方辅助):settings 服务挂载后以
|
|
52
|
+
// 真 Config schema 注册 namespace、组合 base 取入口 config;服务卸载时
|
|
53
|
+
// 源回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置
|
|
54
|
+
// 卡片写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg。
|
|
55
|
+
let readSettings = () => config
|
|
56
|
+
function applyResolvedConfig(resolved) {
|
|
57
|
+
Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
installSettingsSection(ctx, 'dsh-recall', Config, config, {
|
|
61
|
+
setSource: (fn) => { readSettings = fn },
|
|
62
|
+
onChange: () => applyResolvedConfig(readSettings()),
|
|
63
|
+
})
|
|
64
|
+
} catch (error) {
|
|
65
|
+
rt.recordError('recall settings namespace skipped: ' + String(error))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板。
|
|
69
|
+
// 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示。
|
|
70
|
+
const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
|
|
71
|
+
|
|
72
|
+
// 请求体上限:端点里 exclude-set 接受用户任意文本,无上限时可被无限
|
|
73
|
+
// POST 撑爆内存。1MB 远超正常配置体量,超限干净报错而不是悄悄截断。
|
|
74
|
+
const MAX_BODY_BYTES = 1048576
|
|
75
|
+
|
|
76
|
+
// 快照管理列表的结果缓存(apply 级跨请求共享):30s 缓存让二次打开即时;
|
|
77
|
+
// delete 与新快照落地时失效。listCache/excludeCache 是可变 holder——routes
|
|
78
|
+
// 层改属性(items/payload),本文件的事件接线读同一引用。
|
|
79
|
+
const listCache = { at: 0, items: null }
|
|
80
|
+
// 排除配置枚举缓存(30s):exclude-set 成功写入后立即失效。
|
|
81
|
+
const excludeCache = { at: 0, payload: null }
|
|
82
|
+
|
|
83
|
+
// 会话标题/文本两段式读取(live 秒回,冷会话由 Client 异步补齐)
|
|
84
|
+
const sessionInfo = createSessionInfo(ctx)
|
|
85
|
+
|
|
86
|
+
async function readJsonBody(req) {
|
|
87
|
+
const chunks = []
|
|
88
|
+
let size = 0
|
|
89
|
+
for await (const chunk of req) {
|
|
90
|
+
size += chunk.length
|
|
91
|
+
if (size > MAX_BODY_BYTES) throw new Error(E.RECALL_BODY_TOO_LARGE)
|
|
92
|
+
chunks.push(chunk)
|
|
93
|
+
}
|
|
94
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
95
|
+
if (!text.trim()) return {}
|
|
96
|
+
return JSON.parse(text)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sendJson(res, status, body) {
|
|
100
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
101
|
+
res.end(JSON.stringify(body))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 统一错误映射:业务失败与系统异常分离,文案与诊断解耦。code 给
|
|
105
|
+
// Client 做分支判断,message 直接展示。
|
|
106
|
+
function errBody(error) {
|
|
107
|
+
const text = String(error && error.message ? error.message : error)
|
|
108
|
+
if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: '请求体超过 1MB 上限' }
|
|
109
|
+
return { ok: false, code: E.RECALL_ERROR, message: text }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 队列入队即占住后续快照,队列失败不堵队(catch 就地消化)。
|
|
113
|
+
function enqueue(task) {
|
|
114
|
+
const run = state.queue.then(task)
|
|
115
|
+
state.queue = run.catch(() => {})
|
|
116
|
+
return run
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
|
|
120
|
+
// 全量 Promise.all 会同时压垮磁盘/CPU,限制同时最多 concurrency 个任务。
|
|
121
|
+
async function runLimited(tasks, concurrency) {
|
|
122
|
+
const limit = concurrency > 0 ? concurrency : 4
|
|
123
|
+
let index = 0
|
|
124
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
|
|
125
|
+
while (index < tasks.length) {
|
|
126
|
+
const task = tasks[index++]
|
|
127
|
+
await task()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
await Promise.all(workers)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
|
|
134
|
+
// 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
|
|
135
|
+
function normalizeWorkdir(path) {
|
|
136
|
+
if (!path) return ''
|
|
137
|
+
let p = String(path)
|
|
138
|
+
return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
|
|
142
|
+
// 保守策略——不做自动取消,仅拦下操作并提示先停止。守卫式访问只为防御
|
|
143
|
+
// 「未来版本改名 / agent 服务未装配」,失败视为「不忙」(fail-open)。
|
|
144
|
+
function agentBusy(sessionId, root) {
|
|
145
|
+
let reg = null
|
|
146
|
+
try { reg = ctx.agents } catch (error) { return false }
|
|
147
|
+
if (!reg) return false
|
|
148
|
+
try {
|
|
149
|
+
if (typeof reg.list === 'function') {
|
|
150
|
+
for (const agent of reg.list()) {
|
|
151
|
+
if (!agent || agent.status !== 'running') continue
|
|
152
|
+
// 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
|
|
153
|
+
if (sessionId && String(agent.id) === String(sessionId)) return true
|
|
154
|
+
// 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
|
|
155
|
+
const cwd = agent.session && agent.session.header && agent.session.header.cwd
|
|
156
|
+
if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
if (sessionId && typeof reg.get === 'function') {
|
|
161
|
+
const agent = reg.get(sessionId)
|
|
162
|
+
return Boolean(agent && agent.status === 'running')
|
|
163
|
+
}
|
|
164
|
+
} catch (error) { /* fail-open */ }
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 枚举当前全部已知 exclude 文件并按路径去重。exclude-get 直接消费结果;
|
|
169
|
+
// exclude-set 用它做路径白名单校验,堵死「借 API 写任意文件」的通道。
|
|
170
|
+
async function listExcludeFiles() {
|
|
171
|
+
const roots = new Set(state.stores.keys())
|
|
172
|
+
for (const session of ctx.sessions.list()) {
|
|
173
|
+
const cwd = session && session.header && session.header.cwd
|
|
174
|
+
if (cwd) roots.add(cwd)
|
|
175
|
+
}
|
|
176
|
+
const byFile = new Map()
|
|
177
|
+
await Promise.all(Array.from(roots).map(async (root) => {
|
|
178
|
+
try {
|
|
179
|
+
const store = await rt.resolveStore(root)
|
|
180
|
+
if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
|
|
181
|
+
byFile.get(store.excludeFile).roots.push(root)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
/* 单个根解析失败只影响它自己,不拖垮整个列表 */
|
|
184
|
+
}
|
|
185
|
+
}))
|
|
186
|
+
// 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
|
|
187
|
+
// 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑。
|
|
188
|
+
try {
|
|
189
|
+
const container = await rt.resolveHomeContainer()
|
|
190
|
+
if (container) {
|
|
191
|
+
const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
|
|
192
|
+
if (probe === 'YES') {
|
|
193
|
+
const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
|
|
194
|
+
if (!byFile.has(excludeFile)) {
|
|
195
|
+
// 伪 store:仅承载 readExclude/writeExclude 用到的 excludeFile 与 home
|
|
196
|
+
byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
/* 兜底失败退回注册表结果 */
|
|
202
|
+
}
|
|
203
|
+
return byFile
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 工作区 cwd 全集:live 注册表只是子集,sessionQuery.listSessions 是
|
|
207
|
+
// 「live + 磁盘冷元数据」的完整语料。manage list 与 delete 兜底共用。
|
|
208
|
+
async function collectCwds() {
|
|
209
|
+
const cwds = new Set()
|
|
210
|
+
for (const session of ctx.sessions.list()) {
|
|
211
|
+
const cwd = session && session.header && session.header.cwd
|
|
212
|
+
if (cwd) cwds.add(cwd)
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const querySvc = ctx.get('sessionQuery')
|
|
216
|
+
if (querySvc && typeof querySvc.listSessions === 'function') {
|
|
217
|
+
for (const record of await querySvc.listSessions()) {
|
|
218
|
+
const cwd = record && record.header && record.header.cwd
|
|
219
|
+
if (cwd) cwds.add(cwd)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (error) { /* 冷元数据不可用时退回 live 注册表 */ }
|
|
223
|
+
return cwds
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
|
|
227
|
+
// (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store 的 JSON 损坏只丢它自己。
|
|
228
|
+
function parseStoresDump(text) {
|
|
229
|
+
const map = new Map()
|
|
230
|
+
let cur = null
|
|
231
|
+
let inIndex = false
|
|
232
|
+
let indexLines = []
|
|
233
|
+
function flush() {
|
|
234
|
+
if (!cur) return
|
|
235
|
+
const raw = indexLines.join('\n').trim()
|
|
236
|
+
if (raw) {
|
|
237
|
+
try {
|
|
238
|
+
const arr = JSON.parse(raw)
|
|
239
|
+
if (Array.isArray(arr)) cur.entries = arr
|
|
240
|
+
} catch (error) { /* index 损坏按无索引处理 */ }
|
|
241
|
+
}
|
|
242
|
+
map.set(cur.dir, cur)
|
|
243
|
+
cur = null
|
|
244
|
+
}
|
|
245
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
246
|
+
if (line.indexOf('==DIR ') === 0) { flush(); cur = { dir: line.slice(6).trim(), root: null, entries: null }; inIndex = false; indexLines = []; continue }
|
|
247
|
+
if (!cur) continue
|
|
248
|
+
if (line.indexOf('ROOT ') === 0) { const v = line.slice(5).trim(); cur.root = v || null; continue }
|
|
249
|
+
if (line === 'INDEXBEGIN') { inIndex = true; indexLines = []; continue }
|
|
250
|
+
if (line === 'INDEXEND') { inIndex = false; continue }
|
|
251
|
+
if (inIndex) indexLines.push(line)
|
|
252
|
+
}
|
|
253
|
+
flush()
|
|
254
|
+
return map
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的 root.txt
|
|
258
|
+
// 与 index.json),manage list 与 delete 兜底共用。
|
|
259
|
+
async function dumpStores() {
|
|
260
|
+
const container = await rt.resolveHomeContainer()
|
|
261
|
+
const extras = Array.from(await collectCwds()).map((cwd) => cwd + (rt.isWin ? '\\' : '/') + '.dsh-recall-snapshots')
|
|
262
|
+
try {
|
|
263
|
+
const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
|
|
264
|
+
return parseStoresDump(text)
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return new Map()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 磁盘反查某快照归属的 store:dump 全部 index 后按 id 查找。delete 的
|
|
271
|
+
// 兜底路径用它消灭「列表可见但内存缺失 ⇒ 误报不存在」。
|
|
272
|
+
async function locateSnapshotOnDisk(id) {
|
|
273
|
+
if (!id) return null
|
|
274
|
+
const dump = await dumpStores()
|
|
275
|
+
const hints = new Map()
|
|
276
|
+
for (const [root, st] of state.stores.entries()) {
|
|
277
|
+
if (st && st.dir) hints.set(st.dir, root)
|
|
278
|
+
}
|
|
279
|
+
for (const [dir, info] of dump) {
|
|
280
|
+
const hit = (info.entries || []).find((e) => e && e.id === id)
|
|
281
|
+
if (!hit) continue
|
|
282
|
+
const root = (typeof hit.root === 'string' && hit.root) || info.root || hints.get(dir) || null
|
|
283
|
+
if (!root) continue
|
|
284
|
+
try {
|
|
285
|
+
const store = await rt.resolveStore(root)
|
|
286
|
+
if (store) return { store, root }
|
|
287
|
+
} catch (error) { /* 单个 root 解析失败继续找 */ }
|
|
288
|
+
}
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/会话
|
|
293
|
+
// 批量删除使用。去重只按 id——同一消息 ID 全局唯一。
|
|
294
|
+
async function collectAllSnapshotRecords() {
|
|
295
|
+
const records = new Map()
|
|
296
|
+
function add(id, root, sessionId, time) {
|
|
297
|
+
if (!id || typeof id !== 'string') return
|
|
298
|
+
const old = records.get(id)
|
|
299
|
+
if (!old) {
|
|
300
|
+
records.set(id, {
|
|
301
|
+
id,
|
|
302
|
+
root: root || null,
|
|
303
|
+
sessionId: sessionId || null,
|
|
304
|
+
time: typeof time === 'number' ? time : 0
|
|
305
|
+
})
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
// 同一消息 ID 可能出现磁盘先占位、内存后补全的情况:用更全的
|
|
309
|
+
// root/sessionId/time 覆盖旧值,避免树形节点归到「未知」导致批量
|
|
310
|
+
// 删除按工作区/会话匹配不到。
|
|
311
|
+
if (!old.root && root) old.root = root
|
|
312
|
+
if (!old.sessionId && sessionId) old.sessionId = sessionId
|
|
313
|
+
if (!old.time && time) old.time = time
|
|
314
|
+
}
|
|
315
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
316
|
+
if (s) add(id, s.root, s.sessionId, s.time)
|
|
317
|
+
}
|
|
318
|
+
const dump = await dumpStores()
|
|
319
|
+
const hints = new Map()
|
|
320
|
+
for (const [root, st] of state.stores.entries()) {
|
|
321
|
+
if (st && st.dir) hints.set(st.dir, root)
|
|
322
|
+
}
|
|
323
|
+
for (const [dir, info] of dump) {
|
|
324
|
+
const baseRoot = info.root || hints.get(dir) || null
|
|
325
|
+
for (const e of info.entries || []) {
|
|
326
|
+
if (!e || typeof e.id !== 'string') continue
|
|
327
|
+
add(e.id, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId, e.time)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return records
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- 端点表组装:核心路由 + 管理路由,合并进单一 endpoints 对象供
|
|
334
|
+
// webServer 前缀路由分发(端点名是 path 第一段,故无跨域命名冲突)。
|
|
335
|
+
const deps = {
|
|
336
|
+
ctx, rt, snaps, maint, state, cfg, supported,
|
|
337
|
+
enqueue, agentBusy, runLimited, readJsonBody, sendJson, errBody,
|
|
338
|
+
listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
|
|
339
|
+
listCache, excludeCache, sessionInfo, titleFromEvents, messageTextFromEvents,
|
|
340
|
+
// readSettings 传活绑定而非当前引用(A1):dsh-settings 服务晚挂载时
|
|
341
|
+
// setSource 会重绑定 readSettings——按值捕获的副本停在旧闭包(入口
|
|
342
|
+
// config),config-reset 会按旧值「恢复默认」。活绑定让消费者每次调用
|
|
343
|
+
// 都取到当前闭包。
|
|
344
|
+
applyResolvedConfig, readSettings: () => readSettings(), DEFAULTS, rescueRollback, E,
|
|
345
|
+
}
|
|
346
|
+
const endpoints = {
|
|
347
|
+
...createRoutesCore(deps),
|
|
348
|
+
...createRoutesManage(deps),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
ctx.effect(() => webServer.register({
|
|
352
|
+
kind: 'prefix',
|
|
353
|
+
path: '/api/recall',
|
|
354
|
+
handler: async (req, res) => {
|
|
355
|
+
const path = (req.url || '').split('?')[0]
|
|
356
|
+
const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
|
|
357
|
+
const endpoint = endpoints[name]
|
|
358
|
+
if (!endpoint) {
|
|
359
|
+
sendJson(res, 404, { ok: false, code: E.RECALL_UNKNOWN_ENDPOINT, message: 'unknown endpoint: ' + name })
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const args = await readJsonBody(req)
|
|
364
|
+
sendJson(res, 200, await endpoint(args))
|
|
365
|
+
} catch (error) {
|
|
366
|
+
sendJson(res, 200, errBody(error))
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}))
|
|
370
|
+
|
|
371
|
+
// 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
|
|
372
|
+
if (!supported) return
|
|
373
|
+
|
|
374
|
+
// 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
|
|
375
|
+
// (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
|
|
376
|
+
ctx.on('session/event', (session, event) => {
|
|
377
|
+
if (!event || event.type !== 'user/message') return
|
|
378
|
+
const data = event.data
|
|
379
|
+
if (!data || typeof data.id !== 'string' || !data.id) return
|
|
380
|
+
const source = data.source
|
|
381
|
+
if (!source || source.kind !== 'user') return
|
|
382
|
+
if (session && session.header && session.header.origin === 'subagent') return
|
|
383
|
+
const messageId = data.id
|
|
384
|
+
const time = event.time
|
|
385
|
+
state.queue = state.queue
|
|
386
|
+
// 快照总开关:cfg 按调用时读取,设置页热更即时生效。关闭时只冻结新建,
|
|
387
|
+
// maybeMaintain 照常跑——已停增的存储仍需被 gc/清理治理。
|
|
388
|
+
.then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
|
|
389
|
+
.then(() => maint.maybeMaintain(session.id))
|
|
390
|
+
.then(() => { listCache.items = null })
|
|
391
|
+
.catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
// 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,并清理旧版
|
|
395
|
+
// 项目内 blobs 目录(home 可用时)。不触发维护(开机预热应尽量轻)。
|
|
396
|
+
;(async () => {
|
|
397
|
+
const warmupRoots = new Map()
|
|
398
|
+
for (const session of ctx.sessions.list()) {
|
|
399
|
+
const cwd = session && session.header && session.header.cwd
|
|
400
|
+
if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
|
|
401
|
+
}
|
|
402
|
+
const querySvc = ctx.get('sessionQuery')
|
|
403
|
+
if (querySvc && typeof querySvc.listSessions === 'function') {
|
|
404
|
+
try {
|
|
405
|
+
const records = await querySvc.listSessions()
|
|
406
|
+
for (const record of records || []) {
|
|
407
|
+
// listSessions 记录形如 {header, live, persisted},会话 id 在
|
|
408
|
+
// header.id——此前误用顶层 record.id(恒 undefined),预热重建的
|
|
409
|
+
// 孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
|
|
410
|
+
const id = record && record.header && record.header.id ? record.header.id : null
|
|
411
|
+
const cwd = record && record.header && record.header.cwd
|
|
412
|
+
if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
|
|
413
|
+
}
|
|
414
|
+
} catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
|
|
415
|
+
}
|
|
416
|
+
for (const [cwd, sessionId] of warmupRoots) {
|
|
417
|
+
Promise.resolve(rt.resolveStore(cwd))
|
|
418
|
+
.then(() => rt.tryUpgradeToHome(cwd))
|
|
419
|
+
.then((store) => rt.ensureGit(cwd, store))
|
|
420
|
+
.then(() => snaps.loadIndex(cwd, sessionId))
|
|
421
|
+
.then(() => snaps.rebuildOrphans(cwd, sessionId))
|
|
422
|
+
.then(() => rt.cleanupLegacy(cwd))
|
|
423
|
+
.catch(() => {})
|
|
424
|
+
}
|
|
425
|
+
})()
|
|
426
|
+
}
|