dsh-recall-plugin 2.2.0 → 2.3.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 +19 -0
- package/README.md +2 -8
- package/lib/client.js +1476 -1472
- package/lib/index.js +446 -411
- package/lib/routes-manage.js +624 -619
- package/package.json +65 -65
package/lib/routes-manage.js
CHANGED
|
@@ -1,619 +1,624 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-recall-plugin — 管理路由域(R2 从 index.js 拆出)
|
|
3
|
-
*
|
|
4
|
-
* exclude-get/set、config-get/set/reset、manage(列表/标题/文本/占用/删除/
|
|
5
|
-
* 删除全部/gc/lineage)端点,以及 manage 用的删除辅助(deleteSnapshotsByFilter /
|
|
6
|
-
* deleteAllSnapshots)。依赖经 deps 注入;listCache/excludeCache 是 apply 级
|
|
7
|
-
* 可变 holder(改属性而非重绑定),与 index.js 的事件接线共享同一引用。
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { isSafetySnapshotId } from './snapshots.js'
|
|
11
|
-
import { parseExcludeDump } from './dump-parse.js'
|
|
12
|
-
|
|
13
|
-
export function createRoutesManage(deps) {
|
|
14
|
-
const {
|
|
15
|
-
ctx, rt, snaps, maint, state, cfg, supported, enqueue, runLimited,
|
|
16
|
-
listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
|
|
17
|
-
listCache, excludeCache, usageCache, sessionInfo, titleFromEvents, messageTextFromEvents,
|
|
18
|
-
applyResolvedConfig, readSettings, DEFAULTS, E,
|
|
19
|
-
} = deps
|
|
20
|
-
const { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast } = sessionInfo
|
|
21
|
-
|
|
22
|
-
// PF-6:list items 构建(磁盘 dump + 内存并集 + 排序)从 list 分支抽出——
|
|
23
|
-
// 同步路径与 stale 后台刷新共用同一实现(改一处漏一处的风险随合并消失)。
|
|
24
|
-
async function buildListItems() {
|
|
25
|
-
const allItems = []
|
|
26
|
-
|
|
27
|
-
// 磁盘全量:一条 shell dump。标题只查 live/缓存(liveTitleFast,同步
|
|
28
|
-
// 瞬时)——冷会话标题由 Client 拿到列表后异步调 titles 补齐。
|
|
29
|
-
const dump = await dumpStores()
|
|
30
|
-
const hints = new Map()
|
|
31
|
-
for (const [root, st] of state.stores.entries()) {
|
|
32
|
-
if (st && st.dir) hints.set(st.dir, root)
|
|
33
|
-
}
|
|
34
|
-
// 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照因
|
|
35
|
-
// 「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
|
|
36
|
-
const byId = new Map()
|
|
37
|
-
function push(id, time, root, sessionId) {
|
|
38
|
-
if (!id || typeof id !== 'string') return
|
|
39
|
-
// F-G1 防御性展示过滤:修复前 rebuildOrphans 曾把 safety tag
|
|
40
|
-
// (pre-rollback-<ts>)strip 前缀后写进 index.json——存量污染条目
|
|
41
|
-
// 不做迁移清理(一次性数据,代价收益不划算),这里挡住可见性:
|
|
42
|
-
// 安全快照不是消息快照,本就不该出现在管理列表/树里。
|
|
43
|
-
if (isSafetySnapshotId(id)) return
|
|
44
|
-
const old = byId.get(id)
|
|
45
|
-
if (!old) {
|
|
46
|
-
const rec = {
|
|
47
|
-
id,
|
|
48
|
-
time: typeof time === 'number' ? time : 0,
|
|
49
|
-
root: root || null,
|
|
50
|
-
workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
|
|
51
|
-
sessionId: sessionId || null,
|
|
52
|
-
sessionTitle: liveTitleFast(sessionId)
|
|
53
|
-
}
|
|
54
|
-
// 消息文本只放已确认值:live 命中字符串则带,否则不设字段。
|
|
55
|
-
const liveText = liveMessageTextFast(sessionId, id)
|
|
56
|
-
if (liveText) rec.messageText = liveText
|
|
57
|
-
byId.set(id, rec)
|
|
58
|
-
allItems.push(rec)
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
// 与 collectAllSnapshotRecords 同款补全:磁盘先占位、内存后补全 root
|
|
62
|
-
if (!old.root && root) { old.root = root; old.workspace = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || null }
|
|
63
|
-
if (!old.sessionId && sessionId) { old.sessionId = sessionId; old.sessionTitle = liveTitleFast(sessionId) }
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
return { ok: false, code: E.
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (scope === '
|
|
506
|
-
if (!
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
if (!store)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
// gc
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 管理路由域(R2 从 index.js 拆出)
|
|
3
|
+
*
|
|
4
|
+
* exclude-get/set、config-get/set/reset、manage(列表/标题/文本/占用/删除/
|
|
5
|
+
* 删除全部/gc/lineage)端点,以及 manage 用的删除辅助(deleteSnapshotsByFilter /
|
|
6
|
+
* deleteAllSnapshots)。依赖经 deps 注入;listCache/excludeCache 是 apply 级
|
|
7
|
+
* 可变 holder(改属性而非重绑定),与 index.js 的事件接线共享同一引用。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isSafetySnapshotId } from './snapshots.js'
|
|
11
|
+
import { parseExcludeDump } from './dump-parse.js'
|
|
12
|
+
|
|
13
|
+
export function createRoutesManage(deps) {
|
|
14
|
+
const {
|
|
15
|
+
ctx, rt, snaps, maint, state, cfg, supported, enqueue, runLimited,
|
|
16
|
+
listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
|
|
17
|
+
listCache, excludeCache, usageCache, sessionInfo, titleFromEvents, messageTextFromEvents,
|
|
18
|
+
applyResolvedConfig, readSettings, DEFAULTS, E,
|
|
19
|
+
} = deps
|
|
20
|
+
const { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast } = sessionInfo
|
|
21
|
+
|
|
22
|
+
// PF-6:list items 构建(磁盘 dump + 内存并集 + 排序)从 list 分支抽出——
|
|
23
|
+
// 同步路径与 stale 后台刷新共用同一实现(改一处漏一处的风险随合并消失)。
|
|
24
|
+
async function buildListItems() {
|
|
25
|
+
const allItems = []
|
|
26
|
+
|
|
27
|
+
// 磁盘全量:一条 shell dump。标题只查 live/缓存(liveTitleFast,同步
|
|
28
|
+
// 瞬时)——冷会话标题由 Client 拿到列表后异步调 titles 补齐。
|
|
29
|
+
const dump = await dumpStores()
|
|
30
|
+
const hints = new Map()
|
|
31
|
+
for (const [root, st] of state.stores.entries()) {
|
|
32
|
+
if (st && st.dir) hints.set(st.dir, root)
|
|
33
|
+
}
|
|
34
|
+
// 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照因
|
|
35
|
+
// 「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
|
|
36
|
+
const byId = new Map()
|
|
37
|
+
function push(id, time, root, sessionId) {
|
|
38
|
+
if (!id || typeof id !== 'string') return
|
|
39
|
+
// F-G1 防御性展示过滤:修复前 rebuildOrphans 曾把 safety tag
|
|
40
|
+
// (pre-rollback-<ts>)strip 前缀后写进 index.json——存量污染条目
|
|
41
|
+
// 不做迁移清理(一次性数据,代价收益不划算),这里挡住可见性:
|
|
42
|
+
// 安全快照不是消息快照,本就不该出现在管理列表/树里。
|
|
43
|
+
if (isSafetySnapshotId(id)) return
|
|
44
|
+
const old = byId.get(id)
|
|
45
|
+
if (!old) {
|
|
46
|
+
const rec = {
|
|
47
|
+
id,
|
|
48
|
+
time: typeof time === 'number' ? time : 0,
|
|
49
|
+
root: root || null,
|
|
50
|
+
workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
|
|
51
|
+
sessionId: sessionId || null,
|
|
52
|
+
sessionTitle: liveTitleFast(sessionId)
|
|
53
|
+
}
|
|
54
|
+
// 消息文本只放已确认值:live 命中字符串则带,否则不设字段。
|
|
55
|
+
const liveText = liveMessageTextFast(sessionId, id)
|
|
56
|
+
if (liveText) rec.messageText = liveText
|
|
57
|
+
byId.set(id, rec)
|
|
58
|
+
allItems.push(rec)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
// 与 collectAllSnapshotRecords 同款补全:磁盘先占位、内存后补全 root
|
|
62
|
+
if (!old.root && root) { old.root = root; old.workspace = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || null }
|
|
63
|
+
if (!old.sessionId && sessionId) { old.sessionId = sessionId; old.sessionTitle = liveTitleFast(sessionId) }
|
|
64
|
+
// 与 !old 分支同规(live 命中才写字段):null 落进属性会让 client
|
|
65
|
+
// 误判「已查过」而跳过 messages 冷读——冷会话快照永远只显示消息 ID
|
|
66
|
+
if (!old.messageText && id) { const t = liveMessageTextFast(sessionId, id); if (t) old.messageText = t }
|
|
67
|
+
if (!old.time && time) old.time = time
|
|
68
|
+
}
|
|
69
|
+
for (const [dir, info] of dump) {
|
|
70
|
+
const baseRoot = info.root || hints.get(dir) || null
|
|
71
|
+
for (const e of info.entries || []) {
|
|
72
|
+
if (!e || typeof e.id !== 'string') continue
|
|
73
|
+
// root 优先取 root.txt(info.root,resolveStore 写下的权威映射):index.json
|
|
74
|
+
// 条目里的 e.root 曾出现丢失反斜杠的坏数据(哈希↔root 错位,删除落到空
|
|
75
|
+
// store),root.txt 是每次 resolveStore 都重写的规范源,兜底才用条目值
|
|
76
|
+
push(e.id, e.time, baseRoot || (typeof e.root === 'string' && e.root) || null, e.sessionId)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 内存兜底(刚拍未落盘的保险,正常已被磁盘 dump 覆盖)
|
|
80
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
81
|
+
push(id, s.time, s.root, s.sessionId)
|
|
82
|
+
}
|
|
83
|
+
allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
|
|
84
|
+
return allItems
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// PF-6:stale 时的后台缓存刷新(in-flight 去重)——stale 期间重复 list
|
|
88
|
+
// 复用同一进行中的 dump,不重复起进程(否则进程数反而放大)。完成后清
|
|
89
|
+
// stale 标记;失败静默(下次 stale 触发自然重试)。
|
|
90
|
+
function refreshListCacheInBackground() {
|
|
91
|
+
if (listCache.refreshing) return listCache.refreshing
|
|
92
|
+
listCache.refreshing = buildListItems()
|
|
93
|
+
.then((allItems) => {
|
|
94
|
+
listCache.items = allItems
|
|
95
|
+
listCache.at = Date.now()
|
|
96
|
+
listCache.stale = false
|
|
97
|
+
})
|
|
98
|
+
// 冒烟实证:这里的静默吞错曾让 stale 卡死近半小时无任何观测点——
|
|
99
|
+
// 失败必须留痕(console),否则只能靠行为异常反推
|
|
100
|
+
.catch((error) => { console.error('recall list refresh failed:', String(error && error.stack || error)) })
|
|
101
|
+
.finally(() => { listCache.refreshing = null })
|
|
102
|
+
return listCache.refreshing
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 按过滤条件批量删除快照(工作区/会话两个树节点共用):先收集匹配
|
|
106
|
+
// id 并按 root 分组,再整体进串行队列——与快照/gc 互斥,避免 git 锁
|
|
107
|
+
// 竞态。每个 root 先 purge tag 再补载索引后重写 index.json,防止冷启动
|
|
108
|
+
// 时用残缺内存覆盖同 store 其余磁盘快照。
|
|
109
|
+
// PF-6:缓存非空(含 stale)时直接由缓存 items 构造 records,省一次
|
|
110
|
+
// 全量 dumpStores——删除以「用户当前所见」为准(stale 说明有新快照未
|
|
111
|
+
// 入列表,用户没看到的也不在删除预期内);缓存为空才全量收集。
|
|
112
|
+
async function deleteSnapshotsByFilter(match, sessionId) {
|
|
113
|
+
let records
|
|
114
|
+
if (Array.isArray(listCache.items) && listCache.items.length) {
|
|
115
|
+
records = new Map()
|
|
116
|
+
for (const it of listCache.items) {
|
|
117
|
+
if (!it || typeof it.id !== 'string') continue
|
|
118
|
+
records.set(it.id, { id: it.id, root: it.root || null, sessionId: it.sessionId || null, time: typeof it.time === 'number' ? it.time : 0 })
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
records = await collectAllSnapshotRecords()
|
|
122
|
+
}
|
|
123
|
+
const byRoot = new Map()
|
|
124
|
+
for (const rec of records.values()) {
|
|
125
|
+
if (!match(rec) || !rec.root) continue
|
|
126
|
+
if (!byRoot.has(rec.root)) byRoot.set(rec.root, [])
|
|
127
|
+
byRoot.get(rec.root).push(rec.id)
|
|
128
|
+
}
|
|
129
|
+
let deleted = 0
|
|
130
|
+
await enqueue(async () => {
|
|
131
|
+
for (const [root, rootIds] of byRoot) {
|
|
132
|
+
let store = state.stores.get(root)
|
|
133
|
+
if (!store) {
|
|
134
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
135
|
+
}
|
|
136
|
+
if (!store) continue
|
|
137
|
+
try {
|
|
138
|
+
if (state.gitExe) {
|
|
139
|
+
// tag 分块删除:win32 命令行有 32767 字符上限,整批传大量 tag 会
|
|
140
|
+
// 在长历史工作区上爆掉;与 maintenance.purgeSession 同款 100 个/块。
|
|
141
|
+
const tags = rootIds.map((id) => 'snap-' + id)
|
|
142
|
+
for (let i = 0; i < tags.length; i += 100) {
|
|
143
|
+
await rt.runShell(rt.scripts.purgeTagsScript(store, state.gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!state.indexLoaded.has(root)) {
|
|
147
|
+
try { await snaps.loadIndex(root, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
|
|
148
|
+
}
|
|
149
|
+
for (const id of rootIds) state.snapshots.delete(id)
|
|
150
|
+
await snaps.saveIndex(root, sessionId)
|
|
151
|
+
deleted += rootIds.length
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// 单个 root 失败不阻断其他 root:best-effort,错误进状态页可见的
|
|
154
|
+
// 错误缓冲,剩余 root 继续清理。
|
|
155
|
+
rt.recordError('recall batch delete failed for ' + root + ': ' + String(error))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
listCache.items = null
|
|
159
|
+
usageCache.payload = null
|
|
160
|
+
})
|
|
161
|
+
return deleted
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 删除所有工作区的全部快照。树形管理的「工作区/会话」批量删除以
|
|
165
|
+
// index.json 中的记录为目标;但「全部删除」必须把 git tag 当作真相源:
|
|
166
|
+
// index 可能因旧版/崩溃/手动修复而为空或过期,不能因为索引里没有条目就
|
|
167
|
+
// 漏删真实快照。磁盘枚举到的 store 即使 root.txt 丢失也直接按目录操作。
|
|
168
|
+
async function deleteAllSnapshots() {
|
|
169
|
+
return enqueue(async () => {
|
|
170
|
+
const stores = new Map()
|
|
171
|
+
for (const [root, store] of state.stores.entries()) {
|
|
172
|
+
if (store && store.dir) stores.set(store.dir, { store, root })
|
|
173
|
+
}
|
|
174
|
+
const dump = await dumpStores()
|
|
175
|
+
for (const [dir, info] of dump.entries()) {
|
|
176
|
+
const known = stores.get(dir)
|
|
177
|
+
if (known) {
|
|
178
|
+
if (!known.root && info.root) known.root = info.root
|
|
179
|
+
known.entries = info.entries || []
|
|
180
|
+
} else {
|
|
181
|
+
stores.set(dir, {
|
|
182
|
+
// 全局删除只动该目录下的 git/index;不必、也不能依赖可反解的 root。
|
|
183
|
+
store: rt.storeFromDir(dir, false),
|
|
184
|
+
root: info.root || null,
|
|
185
|
+
entries: info.entries || []
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (stores.size === 0) return { deleted: 0, stores: 0, failed: 0 }
|
|
191
|
+
|
|
192
|
+
const gitExe = await rt.resolveGit()
|
|
193
|
+
if (!gitExe) {
|
|
194
|
+
const message = '未检测到 git CLI,无法验证并删除快照 tag'
|
|
195
|
+
rt.recordError('recall delete all failed: ' + message)
|
|
196
|
+
return { deleted: 0, stores: 0, failed: stores.size || 1, message }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let deleted = 0
|
|
200
|
+
let clearedStores = 0
|
|
201
|
+
let failed = 0
|
|
202
|
+
for (const { store, root } of stores.values()) {
|
|
203
|
+
try {
|
|
204
|
+
// 先列出实际 tag;不要使用 entries 推导 tag,entries 是可丢失缓存。
|
|
205
|
+
const output = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
|
|
206
|
+
const tags = rt.scripts.stripBom(output).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
|
|
207
|
+
for (let i = 0; i < tags.length; i += 100) {
|
|
208
|
+
await rt.runShell(rt.scripts.purgeTagsScript(store, gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
209
|
+
}
|
|
210
|
+
// purgeTagsScript 为幂等 best-effort,故必须回读校验,避免脚本吞掉
|
|
211
|
+
// 个别失败后仍错误地把 index.json 清空。
|
|
212
|
+
const remainedOutput = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
|
|
213
|
+
const remained = rt.scripts.stripBom(remainedOutput).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
|
|
214
|
+
if (remained.length) throw new Error('仍有 ' + remained.length + ' 个快照 tag 未删除')
|
|
215
|
+
|
|
216
|
+
// tag 清理被确认后才清空索引。直接写已枚举的 store,兼容 root.txt
|
|
217
|
+
// 缺失/错位的旧仓库;不能调用 saveIndex(root),后者会重新按 root 寻址。
|
|
218
|
+
await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', '[]')
|
|
219
|
+
for (const tag of tags) state.snapshots.delete(tag.slice('snap-'.length))
|
|
220
|
+
if (root) {
|
|
221
|
+
for (const [id, snap] of state.snapshots.entries()) {
|
|
222
|
+
if (snap && snap.root === root) state.snapshots.delete(id)
|
|
223
|
+
}
|
|
224
|
+
state.indexLoaded.add(root)
|
|
225
|
+
}
|
|
226
|
+
deleted += tags.length
|
|
227
|
+
clearedStores += 1
|
|
228
|
+
} catch (error) {
|
|
229
|
+
failed += 1
|
|
230
|
+
rt.recordError('recall delete all failed for ' + store.dir + ': ' + String(error))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
|
|
234
|
+
// 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
|
|
235
|
+
listCache.items = null
|
|
236
|
+
usageCache.payload = null
|
|
237
|
+
return { deleted, stores: clearedStores, failed }
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
'exclude-get': async () => {
|
|
243
|
+
// 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
|
|
244
|
+
// 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
|
|
245
|
+
if (!supported) return { ok: false, unsupported: true }
|
|
246
|
+
// 30s 结果缓存:首次进入要 resolveStore 链 + 读取,二次打开/切标签
|
|
247
|
+
// 不应重复付出这份代价;exclude-set 写入后失效。
|
|
248
|
+
if (excludeCache.payload && Date.now() - excludeCache.at < 30000) return excludeCache.payload
|
|
249
|
+
const byFile = await listExcludeFiles()
|
|
250
|
+
// PF-8:一条脚本 base64 读全部 exclude 文件——原每文件一条 Get-Content/
|
|
251
|
+
// cat 进程是首开 4-6 条链路里的大头;内容走 base64 对任意用户文本
|
|
252
|
+
// 免疫(定界不会被内容行打乱),parse 失败/文件缺失按空内容处理
|
|
253
|
+
// (与原 readExclude 对不存在文件输出空串的语义一致)。
|
|
254
|
+
let contents = new Map()
|
|
255
|
+
try {
|
|
256
|
+
const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.excludeDumpScript(Array.from(byFile.keys())), { stdoutMaxBytes: 1048576 }))
|
|
257
|
+
contents = parseExcludeDump(text)
|
|
258
|
+
} catch (error) { /* dump 失败退回空内容列表(读失败的原语义) */ }
|
|
259
|
+
const payload = {
|
|
260
|
+
ok: true,
|
|
261
|
+
files: Array.from(byFile.entries()).map(([path, info]) => ({
|
|
262
|
+
path,
|
|
263
|
+
home: Boolean(info.store.home),
|
|
264
|
+
roots: info.roots,
|
|
265
|
+
content: contents.get(path) || ''
|
|
266
|
+
}))
|
|
267
|
+
}
|
|
268
|
+
excludeCache.at = Date.now()
|
|
269
|
+
excludeCache.payload = payload
|
|
270
|
+
return payload
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
'exclude-set': async (args) => {
|
|
274
|
+
if (!supported) return { ok: false, unsupported: true }
|
|
275
|
+
const path = args && args.path ? String(args.path) : ''
|
|
276
|
+
const content = args && typeof args.content === 'string' ? args.content : ''
|
|
277
|
+
// 路径白名单:重新枚举当前已知 exclude 文件并要求精确命中,
|
|
278
|
+
// 客户端伪造的任意路径在这里被拒(见 listExcludeFiles 注释)
|
|
279
|
+
const byFile = await listExcludeFiles()
|
|
280
|
+
const info = byFile.get(path)
|
|
281
|
+
if (!info) return { ok: false, code: E.RECALL_UNKNOWN_PATH, message: '未知的排除文件路径' }
|
|
282
|
+
await snaps.writeExclude(info.store, content)
|
|
283
|
+
// 写入后立即失效:设置页保存后刷新必须看到最新内容
|
|
284
|
+
excludeCache.payload = null
|
|
285
|
+
return { ok: true }
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
// 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段 + env
|
|
289
|
+
// 锁定字段(环境变量优先级最高)+ 可写性(只读 provider 禁存)。
|
|
290
|
+
'config-get': async () => {
|
|
291
|
+
const envLocks = {
|
|
292
|
+
gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
|
|
293
|
+
gcHours: Boolean(process.env && process.env.DSH_RECALL_GC_HOURS),
|
|
294
|
+
}
|
|
295
|
+
let overridden = {}
|
|
296
|
+
let writable = false
|
|
297
|
+
try {
|
|
298
|
+
const settings = ctx.get('settings')
|
|
299
|
+
if (settings && typeof settings.describe === 'function') {
|
|
300
|
+
const list = settings.describe()
|
|
301
|
+
const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === 'dsh-recall')
|
|
302
|
+
if (ours && ours.user && typeof ours.user === 'object') overridden = ours.user
|
|
303
|
+
writable = settings.writable !== false
|
|
304
|
+
}
|
|
305
|
+
} catch (error) { /* describe 不可用按「无覆盖」处理 */ }
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
values: {
|
|
309
|
+
gcSnaps: cfg.gcSnaps,
|
|
310
|
+
gcHours: cfg.gcHours,
|
|
311
|
+
maxFileBytes: cfg.maxFileBytes,
|
|
312
|
+
maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
|
|
313
|
+
baseExcludes: cfg.baseExcludes.slice(),
|
|
314
|
+
refillDraft: cfg.refillDraft,
|
|
315
|
+
snapshotEnabled: cfg.snapshotEnabled,
|
|
316
|
+
archiveOriginal: cfg.archiveOriginal,
|
|
317
|
+
retentionDays: cfg.retentionDays,
|
|
318
|
+
},
|
|
319
|
+
overridden,
|
|
320
|
+
envLocks,
|
|
321
|
+
writable,
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
// 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
|
|
326
|
+
// 写进用户层,watch 链路把新值热更新进 cfg,无需重启。
|
|
327
|
+
'config-set': async (args) => {
|
|
328
|
+
const patch = args && args.patch && typeof args.patch === 'object' ? args.patch : {}
|
|
329
|
+
const clean = {}
|
|
330
|
+
if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
|
|
331
|
+
if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
|
|
332
|
+
if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
|
|
333
|
+
if (patch.maxSnapshotsPerWorkspace !== undefined) {
|
|
334
|
+
const n = Number(patch.maxSnapshotsPerWorkspace)
|
|
335
|
+
// 0 或负值 = 不限制(schema 由 number 校验,非法 NaN 在 settings.write 层被拒)
|
|
336
|
+
if (!Number.isFinite(n)) return { ok: false, code: E.RECALL_BAD_TYPE, message: '快照总量上限必须是数字' }
|
|
337
|
+
clean.maxSnapshotsPerWorkspace = Math.max(0, n)
|
|
338
|
+
}
|
|
339
|
+
if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
|
|
340
|
+
if (patch.snapshotEnabled !== undefined) clean.snapshotEnabled = Boolean(patch.snapshotEnabled)
|
|
341
|
+
if (patch.archiveOriginal !== undefined) clean.archiveOriginal = Boolean(patch.archiveOriginal)
|
|
342
|
+
if (patch.retentionDays !== undefined) {
|
|
343
|
+
const n = Number(patch.retentionDays)
|
|
344
|
+
// 0/负值 = 不启用(schema 校验 base 由 number 承担,NaN 由 settings.write 拒)
|
|
345
|
+
if (!Number.isFinite(n) || n < 0) return { ok: false, code: E.RECALL_BAD_TYPE, message: '保留天数必须是 >= 0 的数字(0 表示不启用)' }
|
|
346
|
+
clean.retentionDays = Math.trunc(n)
|
|
347
|
+
}
|
|
348
|
+
if (patch.baseExcludes !== undefined) {
|
|
349
|
+
if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: E.RECALL_BAD_TYPE, message: 'baseExcludes 必须是字符串数组' }
|
|
350
|
+
clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
|
|
351
|
+
}
|
|
352
|
+
if (!Object.keys(clean).length) return { ok: false, code: E.RECALL_EMPTY_PATCH, message: '没有可写入的配置字段' }
|
|
353
|
+
let settings = null
|
|
354
|
+
try { settings = ctx.get('settings') } catch (error) { settings = null }
|
|
355
|
+
if (!settings || typeof settings.update !== 'function') {
|
|
356
|
+
return { ok: false, code: E.RECALL_SETTINGS_UNAVAILABLE, message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
await settings.update('dsh-recall', clean)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
return { ok: false, code: E.RECALL_SETTINGS_WRITE_FAILED, message: '配置写入失败:' + String(error && error.message ? error.message : error) }
|
|
362
|
+
}
|
|
363
|
+
return { ok: true }
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// 设置页「快照管理」卡片:列表 / 磁盘占用 / 单条删除 / 手动 gc。
|
|
367
|
+
// 全部走串行队列——删除 tag 与 gc 与快照争的是同一个 git 仓库。
|
|
368
|
+
'manage': async (args) => {
|
|
369
|
+
if (!supported) return { ok: false, unsupported: true }
|
|
370
|
+
const op = args && args.op ? String(args.op) : 'list'
|
|
371
|
+
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
372
|
+
if (op === 'list') {
|
|
373
|
+
const limitRaw = args && args.limit !== undefined ? Number(args.limit) : 200
|
|
374
|
+
const safeLimit = Math.min(Math.max(Number.isFinite(limitRaw) ? Math.trunc(limitRaw) : 200, 1), 2000)
|
|
375
|
+
// PF-6:缓存非空且(fresh 或 stale)→ 立即用旧 items 应答,对话中
|
|
376
|
+
// 打开快照管理不再等全量 dump(30s TTL 曾被每条消息的清空形同虚设)。
|
|
377
|
+
// stale 时后台刷新(in-flight 去重),Client 凭 stale 标记静默再拉
|
|
378
|
+
// 一次渐进补新。缓存为空 → 同步 dump(首开现状)。
|
|
379
|
+
if (listCache.items && (Date.now() - listCache.at < 30000 || listCache.stale)) {
|
|
380
|
+
const stale = Boolean(listCache.stale)
|
|
381
|
+
if (stale) refreshListCacheInBackground()
|
|
382
|
+
return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length, stale }
|
|
383
|
+
}
|
|
384
|
+
const allItems = await buildListItems()
|
|
385
|
+
listCache.at = Date.now()
|
|
386
|
+
listCache.items = allItems
|
|
387
|
+
listCache.stale = false
|
|
388
|
+
return { ok: true, items: allItems.slice(0, safeLimit), total: allItems.length }
|
|
389
|
+
}
|
|
390
|
+
if (op === 'titles') {
|
|
391
|
+
// supported 已在 manage 入口短路(A3:此处重复检查是死代码)
|
|
392
|
+
const ids = Array.from(new Set(
|
|
393
|
+
(Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String) : []).filter(Boolean)
|
|
394
|
+
)).slice(0, 100)
|
|
395
|
+
const out = {}
|
|
396
|
+
// 并发限 4:冷标题 readSession 是重 IO,限制后列表不受影响、标题渐进补齐
|
|
397
|
+
await runLimited(ids.map((sid) => async () => {
|
|
398
|
+
if (out[sid] !== undefined) return
|
|
399
|
+
let title = liveTitleFast(sid)
|
|
400
|
+
if (title === null) {
|
|
401
|
+
const query = ctx.get('sessionQuery')
|
|
402
|
+
if (query && typeof query.readSession === 'function') {
|
|
403
|
+
try {
|
|
404
|
+
const log = await query.readSession(sid)
|
|
405
|
+
title = titleFromEvents(log && log.events)
|
|
406
|
+
} catch (error) { title = null }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
sessionTitles.set(sid, title)
|
|
410
|
+
out[sid] = title
|
|
411
|
+
}), 4)
|
|
412
|
+
return { ok: true, titles: out }
|
|
413
|
+
}
|
|
414
|
+
if (op === 'messages') {
|
|
415
|
+
// supported 已在 manage 入口短路(A3:此处重复检查是死代码)
|
|
416
|
+
const reqs = Array.isArray(args && args.requests) ? args.requests.slice(0, 200) : []
|
|
417
|
+
const bySession = new Map()
|
|
418
|
+
for (const r of reqs) {
|
|
419
|
+
const sid = r && r.sessionId ? String(r.sessionId) : null
|
|
420
|
+
const mid = r && r.messageId ? String(r.messageId) : null
|
|
421
|
+
if (!sid || !mid) continue
|
|
422
|
+
if (!bySession.has(sid)) bySession.set(sid, [])
|
|
423
|
+
bySession.get(sid).push(mid)
|
|
424
|
+
}
|
|
425
|
+
const texts = {}
|
|
426
|
+
await runLimited(Array.from(bySession.entries()).map(([sid, mids]) => async () => {
|
|
427
|
+
// 该会话所有消息都已缓存(含 null)时,不必 readSession 冷读
|
|
428
|
+
const allCached = mids.every((mid) => messageTexts.has(String(sid) + '\u0000' + String(mid)))
|
|
429
|
+
let log = null
|
|
430
|
+
if (!allCached) {
|
|
431
|
+
const query = ctx.get('sessionQuery')
|
|
432
|
+
if (query && typeof query.readSession === 'function') {
|
|
433
|
+
try {
|
|
434
|
+
log = await query.readSession(sid)
|
|
435
|
+
} catch (error) { log = null }
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const mid of mids) {
|
|
439
|
+
const key = String(sid) + '\u0000' + String(mid)
|
|
440
|
+
// 缓存命中(含 null)直接复用,避免已确认无文本的消息反复冷读
|
|
441
|
+
if (messageTexts.has(key)) {
|
|
442
|
+
texts[mid] = messageTexts.get(key)
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
let text = liveMessageTextFast(sid, mid)
|
|
446
|
+
if (text === null && log && Array.isArray(log.events)) {
|
|
447
|
+
text = messageTextFromEvents(log.events, mid)
|
|
448
|
+
}
|
|
449
|
+
messageTexts.set(key, text)
|
|
450
|
+
texts[mid] = text
|
|
451
|
+
}
|
|
452
|
+
}), 4)
|
|
453
|
+
return { ok: true, messageTexts: texts }
|
|
454
|
+
}
|
|
455
|
+
if (op === 'usage') {
|
|
456
|
+
// PF-3 顺带:全量 usage 结果 30s TTL(与 listCache 同款,删除/gc 后
|
|
457
|
+
// 由调用点失效)——ManageCard 每次 refresh 都重算的话,枚举再快
|
|
458
|
+
// 也是白付。仅缓存无 sessionId 的全量分支(client 唯一调用形态;
|
|
459
|
+
// 单工作区分支无调用方,不值得引入 key 维度)。
|
|
460
|
+
if (!sessionId && usageCache.payload && Date.now() - usageCache.at < 30000) {
|
|
461
|
+
return usageCache.payload
|
|
462
|
+
}
|
|
463
|
+
let bytes = 0
|
|
464
|
+
let homeStores = 0
|
|
465
|
+
let fallbackStores = 0
|
|
466
|
+
if (sessionId) {
|
|
467
|
+
const root = await rt.resolveRoot(sessionId)
|
|
468
|
+
if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: '无法解析当前工作区' }
|
|
469
|
+
const store = state.stores.get(root)
|
|
470
|
+
if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: '当前工作区尚未创建快照存储' }
|
|
471
|
+
if (store.home) homeStores++
|
|
472
|
+
else fallbackStores++
|
|
473
|
+
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
474
|
+
bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
475
|
+
} else {
|
|
476
|
+
// PF-3 顺带:多 store 并行——读操作不碰 index.lock,但为防极端
|
|
477
|
+
// 磁盘争抢仍走 runLimited(并发 4)而不是裸 Promise.all;单 store
|
|
478
|
+
// 失败跳过的既有语义不变。
|
|
479
|
+
const knownStores = Array.from(state.stores.values()).filter((s) => s && s.dir)
|
|
480
|
+
const perStore = new Map()
|
|
481
|
+
await runLimited(knownStores.map((store) => async () => {
|
|
482
|
+
try {
|
|
483
|
+
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
484
|
+
perStore.set(store.dir, parseInt(rt.scripts.stripBom(out).trim(), 10) || 0)
|
|
485
|
+
} catch (error) { /* 单 store 失败跳过 */ }
|
|
486
|
+
}), 4)
|
|
487
|
+
for (const store of knownStores) {
|
|
488
|
+
if (store.home) homeStores++
|
|
489
|
+
else fallbackStores++
|
|
490
|
+
bytes += perStore.get(store.dir) || 0
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const payload = { ok: true, bytes, gitAvailable: state.gitExe !== '', homeStores, fallbackStores }
|
|
494
|
+
if (!sessionId) {
|
|
495
|
+
usageCache.at = Date.now()
|
|
496
|
+
usageCache.payload = payload
|
|
497
|
+
}
|
|
498
|
+
return payload
|
|
499
|
+
}
|
|
500
|
+
if (op === 'delete') {
|
|
501
|
+
const scope = args && args.scope ? String(args.scope) : 'snapshot'
|
|
502
|
+
const root = args && args.root ? String(args.root) : null
|
|
503
|
+
const targetSessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
504
|
+
const id = args && args.messageId ? String(args.messageId) : ''
|
|
505
|
+
if (scope === 'workspace') {
|
|
506
|
+
if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: '缺少工作区路径' }
|
|
507
|
+
const deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId)
|
|
508
|
+
return { ok: true, deleted }
|
|
509
|
+
}
|
|
510
|
+
if (scope === 'session') {
|
|
511
|
+
if (!targetSessionId) return { ok: false, code: E.RECALL_NO_SESSION, message: '缺少会话 ID' }
|
|
512
|
+
// 树形中会话挂在具体工作区下,客户端会传 root 限定范围;不传则保持
|
|
513
|
+
// 旧语义(删该会话全部工作区的快照),兼容老调用方。
|
|
514
|
+
const deleted = await deleteSnapshotsByFilter(
|
|
515
|
+
(rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
|
|
516
|
+
sessionId
|
|
517
|
+
)
|
|
518
|
+
return { ok: true, deleted }
|
|
519
|
+
}
|
|
520
|
+
// 管理列表来自磁盘(跨工作区全量),而内存 state.snapshots 只含当前
|
|
521
|
+
// 工作区 + 预热过的——冷启动时列表里有、内存里没有,只查内存会误报
|
|
522
|
+
// 「不存在」。解析链:内存命中 → Client 透传的条目 root → 磁盘 index 反查。
|
|
523
|
+
let snap = state.snapshots.get(id) || null
|
|
524
|
+
let snapRoot = snap ? snap.root : root
|
|
525
|
+
let store = null
|
|
526
|
+
if (snapRoot) {
|
|
527
|
+
try { store = await rt.resolveStore(snapRoot) } catch (error) { store = null }
|
|
528
|
+
}
|
|
529
|
+
if (!store) {
|
|
530
|
+
// 兜底:扫 home 容器与降级目录的 index.json,找到含该 id 的 store
|
|
531
|
+
const found = await locateSnapshotOnDisk(id)
|
|
532
|
+
if (found) { store = found.store; snapRoot = found.root }
|
|
533
|
+
}
|
|
534
|
+
if (!store) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: '该快照不存在' }
|
|
535
|
+
const finalStore = store
|
|
536
|
+
const finalRoot = snapRoot
|
|
537
|
+
await enqueue(async () => {
|
|
538
|
+
if (state.gitExe) {
|
|
539
|
+
await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ['snap-' + id]), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
540
|
+
}
|
|
541
|
+
// 兜底路径到这里时内存可能还没载入过该 root 的索引——先 loadIndex
|
|
542
|
+
// 补齐内存视图,再删目标条目后重写,避免用残缺内存覆盖同 store
|
|
543
|
+
// 其余磁盘快照。
|
|
544
|
+
if (!state.indexLoaded.has(finalRoot)) {
|
|
545
|
+
try { await snaps.loadIndex(finalRoot, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
|
|
546
|
+
}
|
|
547
|
+
state.snapshots.delete(id)
|
|
548
|
+
await snaps.saveIndex(finalRoot, sessionId)
|
|
549
|
+
// 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
|
|
550
|
+
listCache.items = null
|
|
551
|
+
usageCache.payload = null
|
|
552
|
+
})
|
|
553
|
+
return { ok: true }
|
|
554
|
+
}
|
|
555
|
+
if (op === 'deleteAll') {
|
|
556
|
+
const result = await deleteAllSnapshots()
|
|
557
|
+
if (result.failed > 0) {
|
|
558
|
+
return {
|
|
559
|
+
ok: false,
|
|
560
|
+
code: E.RECALL_PARTIAL_DELETE,
|
|
561
|
+
deleted: result.deleted,
|
|
562
|
+
message: result.message || ('已删除 ' + result.deleted + ' 条快照,但有 ' + result.failed + ' 个存储未完成;请查看最近错误后重试')
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { ok: true, deleted: result.deleted, stores: result.stores }
|
|
566
|
+
}
|
|
567
|
+
if (op === 'gc') {
|
|
568
|
+
// 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):全部已知
|
|
569
|
+
// store 逐个 gc。两者都排进串行队列,与快照互斥。
|
|
570
|
+
const done = sessionId
|
|
571
|
+
? await enqueue(() => maint.runGc(sessionId, true))
|
|
572
|
+
: await enqueue(() => maint.runGcAll())
|
|
573
|
+
// gc 后占用显著下降:立即失效占用缓存,设置页 refresh 必须看到新值
|
|
574
|
+
usageCache.payload = null
|
|
575
|
+
return { ok: true, gc: Boolean(done) }
|
|
576
|
+
}
|
|
577
|
+
if (op === 'lineage') {
|
|
578
|
+
// F1 / PF-4:返回全部已知工作区的 fork lineage(childId ↔ parentId
|
|
579
|
+
// 撤回链),供快照管理树聚族。原实现对每个 root 串行 loadLineage
|
|
580
|
+
// (每 root 一条进程,20 工作区 ≈ 10s,版本家族标记最后才亮)——
|
|
581
|
+
// LINEAGE 段并入 storesDump 后一次 dump 全拿,零新增进程。dump 的
|
|
582
|
+
// ==DIR 就是磁盘 store 目录(比 roots 全集更全,还免去对未知 root
|
|
583
|
+
// resolveStore 建目录的副作用);无 LINEAGE 段的旧输出按空 lineage
|
|
584
|
+
// 处理(parseStoresDump 容错)。
|
|
585
|
+
const hints = new Map()
|
|
586
|
+
for (const [root, st] of state.stores.entries()) {
|
|
587
|
+
if (st && st.dir) hints.set(st.dir, root)
|
|
588
|
+
}
|
|
589
|
+
let dump
|
|
590
|
+
try { dump = await dumpStores() } catch (error) { dump = new Map() }
|
|
591
|
+
const out = []
|
|
592
|
+
for (const info of dump.values()) {
|
|
593
|
+
for (const e of info.lineage || []) out.push(e)
|
|
594
|
+
}
|
|
595
|
+
return { ok: true, lineage: out }
|
|
596
|
+
}
|
|
597
|
+
return { ok: false, code: E.RECALL_UNKNOWN_OP, message: '未知的管理操作: ' + op }
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
// 设置页「插件配置」卡片恢复默认:整段清空 user 层回组合 base——官方
|
|
601
|
+
// settings RPC 的 replace 明确是「restoration/reset 路径」。老版本服务
|
|
602
|
+
// 没有 replace 时降级 settings.update 写 DEFAULTS。
|
|
603
|
+
'config-reset': async () => {
|
|
604
|
+
let settings = null
|
|
605
|
+
try { settings = ctx.get('settings') } catch (error) { settings = null }
|
|
606
|
+
if (!settings || typeof settings.update !== 'function') {
|
|
607
|
+
return { ok: false, code: E.RECALL_SETTINGS_UNAVAILABLE, message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
if (typeof settings.replace === 'function') {
|
|
611
|
+
await settings.replace('dsh-recall', {})
|
|
612
|
+
} else {
|
|
613
|
+
await settings.update('dsh-recall', Object.assign({}, DEFAULTS, { baseExcludes: DEFAULTS.baseExcludes.slice() }))
|
|
614
|
+
}
|
|
615
|
+
} catch (error) {
|
|
616
|
+
return { ok: false, code: E.RECALL_SETTINGS_WRITE_FAILED, message: '恢复默认失败:' + String(error && error.message ? error.message : error) }
|
|
617
|
+
}
|
|
618
|
+
// 重置后热更运行中的 cfg(与 config-set 同链路的 watch 触发,这里做
|
|
619
|
+
// 双保险:descriptor 已变更,applyResolvedConfig 立即落地)
|
|
620
|
+
applyResolvedConfig(readSettings())
|
|
621
|
+
return { ok: true }
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|