dsh-recall-plugin 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.en.md +21 -18
- package/README.md +28 -19
- package/lib/client.js +284 -108
- package/lib/config.js +43 -2
- package/lib/index.js +153 -24
- package/lib/maintenance.js +263 -145
- package/lib/snapshots.js +73 -50
- package/package.json +15 -8
package/lib/config.js
CHANGED
|
@@ -19,18 +19,36 @@ export const Config = Schema.object({
|
|
|
19
19
|
gcSnaps: Schema.number().default(50).description('每积累多少条快照触发一次 git gc'),
|
|
20
20
|
gcHours: Schema.number().default(24).description('距上次 gc 超过多少小时触发(与条数先到先触发)'),
|
|
21
21
|
maxFileBytes: Schema.number().default(104857600).description('超过该字节数的文件不进快照、不被回退触碰'),
|
|
22
|
+
maxSnapshotsPerWorkspace: Schema.number().default(500).description('每个工作区保留的最大快照数,超限删除最旧的'),
|
|
22
23
|
// 排除表必须同时覆盖两种存储目录名:降级存储是项目内 .dsh-recall-snapshots/,
|
|
23
24
|
// 而 home 存储目录名是 dsh-recall-snapshots/(无点)——工作区 root 恰为
|
|
24
25
|
// HOME 时(容器 root=/root 等)它落在工作区内,漏排除会让 git add -A
|
|
25
26
|
// 把影子仓库自己吞进去、快照全部失败(issue #6)
|
|
26
27
|
baseExcludes: Schema.array(Schema.string()).default(['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).description('基础排除表(gitignore 语法,优先级低于 exclude.txt)'),
|
|
27
28
|
refillDraft: Schema.boolean().default(true).description('撤回后把被撤回的消息文本回填到输入框'),
|
|
29
|
+
snapshotEnabled: Schema.boolean().default(true).description('启用消息快照(关闭后不再新建,已有快照仍可撤回)'),
|
|
30
|
+
archiveOriginal: Schema.boolean().default(true).description('撤回后归档原会话(关闭后原会话保留在列表中)'),
|
|
31
|
+
retentionDays: Schema.number().default(0).description('按天数保留快照,超期自动删除;0 表示不启用'),
|
|
28
32
|
})
|
|
29
33
|
|
|
30
34
|
// schema 默认值的运行时镜像:settings 服务未组装时 createConfig 直接以
|
|
31
|
-
// 入口 config 解析,这组兜底与 Config
|
|
35
|
+
// 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)。
|
|
36
|
+
// DEFAULTS 同时供 config-reset 降级路径(settings.replace 不可用时的兜底,
|
|
37
|
+
// 见 index.js config-reset 端点)——默认值只此一份,避免重置与 schema 漂移。
|
|
32
38
|
const BASE_EXCLUDES = ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
|
|
33
39
|
|
|
40
|
+
export const DEFAULTS = {
|
|
41
|
+
gcSnaps: 50,
|
|
42
|
+
gcHours: 24,
|
|
43
|
+
maxFileBytes: 104857600,
|
|
44
|
+
maxSnapshotsPerWorkspace: 500,
|
|
45
|
+
baseExcludes: BASE_EXCLUDES,
|
|
46
|
+
refillDraft: true,
|
|
47
|
+
snapshotEnabled: true,
|
|
48
|
+
archiveOriginal: true,
|
|
49
|
+
retentionDays: 0,
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
export function createConfig(raw) {
|
|
35
53
|
const cfg = raw && typeof raw === 'object' ? raw : {}
|
|
36
54
|
|
|
@@ -44,6 +62,13 @@ export function createConfig(raw) {
|
|
|
44
62
|
const gcSnaps = pickNumber(process.env.DSH_RECALL_GC_SNAPS, pickNumber(cfg.gcSnaps, 50, 1), 1)
|
|
45
63
|
const gcHours = pickNumber(process.env.DSH_RECALL_GC_HOURS, pickNumber(cfg.gcHours, 24, 1), 1)
|
|
46
64
|
const maxFileBytes = pickNumber(cfg.maxFileBytes, 104857600, 1024)
|
|
65
|
+
// 每工作区快照上限:0 或负值语义 = 不限制(给想全保留的用户出口);
|
|
66
|
+
// 非数值回退默认 500。默认 500 ≈ 重度使用一周量级,太小会静默丢历史
|
|
67
|
+
// 撤回点,太大失去防膨胀意义。
|
|
68
|
+
const rawMax = typeof cfg.maxSnapshotsPerWorkspace === 'number'
|
|
69
|
+
? cfg.maxSnapshotsPerWorkspace
|
|
70
|
+
: parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? '' : cfg.maxSnapshotsPerWorkspace), 10)
|
|
71
|
+
const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500
|
|
47
72
|
|
|
48
73
|
const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length
|
|
49
74
|
? cfg.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
|
|
@@ -51,5 +76,21 @@ export function createConfig(raw) {
|
|
|
51
76
|
|
|
52
77
|
const refillDraft = typeof cfg.refillDraft === 'boolean' ? cfg.refillDraft : true
|
|
53
78
|
|
|
54
|
-
|
|
79
|
+
// 快照总开关:false 冻结「新建」(session/event 短路,见 index.js),
|
|
80
|
+
// 已有快照的撤回链路不受影响——关闭只停增量,不销毁存量。
|
|
81
|
+
const snapshotEnabled = typeof cfg.snapshotEnabled === 'boolean' ? cfg.snapshotEnabled : true
|
|
82
|
+
|
|
83
|
+
// 撤回后是否归档原会话:关闭时原会话保留在侧栏(fork 新会话仍打开),
|
|
84
|
+
// 供用户对照回退前后上下文;默认开(归档只是隐藏、可恢复)。
|
|
85
|
+
const archiveOriginal = typeof cfg.archiveOriginal === 'boolean' ? cfg.archiveOriginal : true
|
|
86
|
+
|
|
87
|
+
// 按时间保留(S2-3):0 或负值 = 不启用(静默删历史撤回点必须显式
|
|
88
|
+
// opt-in);非数值回退 0。与 maxSnapshotsPerWorkspace(条数维度)并存,
|
|
89
|
+
// 各自独立触发——见 maintenance.enforceRetention。
|
|
90
|
+
const rawDays = typeof cfg.retentionDays === 'number'
|
|
91
|
+
? cfg.retentionDays
|
|
92
|
+
: parseInt(String(cfg.retentionDays == null ? '' : cfg.retentionDays), 10)
|
|
93
|
+
const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0
|
|
94
|
+
|
|
95
|
+
return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays }
|
|
55
96
|
}
|
package/lib/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createConfig, Config } from './config.js'
|
|
15
|
+
import { createConfig, Config, DEFAULTS } from './config.js'
|
|
16
16
|
import { createRuntime } from './store.js'
|
|
17
17
|
import { createSnapshots } from './snapshots.js'
|
|
18
18
|
import { createMaintenance } from './maintenance.js'
|
|
@@ -21,8 +21,12 @@ import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
|
|
21
21
|
export const name = 'dsh-recall-plugin'
|
|
22
22
|
|
|
23
23
|
// 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
|
|
24
|
-
// webServer(Client 半的 HTTP API
|
|
25
|
-
|
|
24
|
+
// webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
|
|
25
|
+
// agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
|
|
26
|
+
// 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
|
|
27
|
+
// "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
|
|
28
|
+
// 其余服务按需 ctx.get。
|
|
29
|
+
export const inject = ['shell', 'sessions', 'webServer', 'agents']
|
|
26
30
|
|
|
27
31
|
// 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
|
|
28
32
|
// 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
|
|
@@ -77,7 +81,7 @@ export function apply(ctx, config) {
|
|
|
77
81
|
// 快照管理列表的结果缓存:磁盘 dump + 冷会话标题即便已批量/并行化,
|
|
78
82
|
// 也不是零成本(1 条 shell + 若干日志解压)。设置页打开、删除后刷新
|
|
79
83
|
// 都会重拉,30s 缓存让二次打开即时;delete 与新快照落地时失效。
|
|
80
|
-
let listCache = { at: 0,
|
|
84
|
+
let listCache = { at: 0, items: null }
|
|
81
85
|
// 排除配置枚举缓存(30s):exclude-get 首次要遍历工作区、逐文件 shell 读,
|
|
82
86
|
// 设置页反复打开时不该每次重算;exclude-set 成功写入后立即失效。
|
|
83
87
|
let excludeCache = { at: 0, payload: null }
|
|
@@ -224,6 +228,44 @@ export function apply(ctx, config) {
|
|
|
224
228
|
return cwds
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
// 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
|
|
232
|
+
// 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
|
|
233
|
+
function normalizeWorkdir(path) {
|
|
234
|
+
if (!path) return ''
|
|
235
|
+
let p = String(path)
|
|
236
|
+
return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
|
|
240
|
+
// 保守策略——不做自动取消(不替用户做决定),仅拦下操作并提示先停止。
|
|
241
|
+
// API 已在 dsh-agent .d.ts 公开面核验:AgentRegistry.get(id) / list() 均公开,
|
|
242
|
+
// Agent.status ∈ 'idle' | 'running',Agent.session.header.cwd 是活动会话的工作区。
|
|
243
|
+
// 守卫式访问只为防御「未来版本改名 / agent 服务未装配」,失败视为「不忙」
|
|
244
|
+
// (fail-open,不阻断主流程,只损失保护)。
|
|
245
|
+
function agentBusy(sessionId, root) {
|
|
246
|
+
let reg = null
|
|
247
|
+
try { reg = ctx.agents } catch (error) { return false }
|
|
248
|
+
if (!reg) return false
|
|
249
|
+
try {
|
|
250
|
+
if (typeof reg.list === 'function') {
|
|
251
|
+
for (const agent of reg.list()) {
|
|
252
|
+
if (!agent || agent.status !== 'running') continue
|
|
253
|
+
// 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
|
|
254
|
+
if (sessionId && String(agent.id) === String(sessionId)) return true
|
|
255
|
+
// 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
|
|
256
|
+
const cwd = agent.session && agent.session.header && agent.session.header.cwd
|
|
257
|
+
if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
|
|
258
|
+
}
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
if (sessionId && typeof reg.get === 'function') {
|
|
262
|
+
const agent = reg.get(sessionId)
|
|
263
|
+
return Boolean(agent && agent.status === 'running')
|
|
264
|
+
}
|
|
265
|
+
} catch (error) { /* fail-open */ }
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
|
|
227
269
|
// 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
|
|
228
270
|
// (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store 的 JSON 损坏只丢它自己。
|
|
229
271
|
function parseStoresDump(text) {
|
|
@@ -411,7 +453,7 @@ export function apply(ctx, config) {
|
|
|
411
453
|
rt.recordError('recall batch delete failed for ' + root + ': ' + String(error))
|
|
412
454
|
}
|
|
413
455
|
}
|
|
414
|
-
listCache.
|
|
456
|
+
listCache.items = null
|
|
415
457
|
})
|
|
416
458
|
return deleted
|
|
417
459
|
}
|
|
@@ -488,7 +530,7 @@ export function apply(ctx, config) {
|
|
|
488
530
|
}
|
|
489
531
|
// list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
|
|
490
532
|
// 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
|
|
491
|
-
listCache.
|
|
533
|
+
listCache.items = null
|
|
492
534
|
return { deleted, stores: clearedStores, failed }
|
|
493
535
|
})
|
|
494
536
|
}
|
|
@@ -518,7 +560,7 @@ export function apply(ctx, config) {
|
|
|
518
560
|
}
|
|
519
561
|
// 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
|
|
520
562
|
// init 是每会话必经的预热通道
|
|
521
|
-
return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft } }
|
|
563
|
+
return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } }
|
|
522
564
|
},
|
|
523
565
|
|
|
524
566
|
'snapshot-info': async (args) => {
|
|
@@ -534,26 +576,44 @@ export function apply(ctx, config) {
|
|
|
534
576
|
'preview': async (args) => {
|
|
535
577
|
const id = args && args.messageId ? String(args.messageId) : ''
|
|
536
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 正在运行中,请先停止后再撤回' }
|
|
537
584
|
const result = await enqueue(() => snaps.diffFor(id))
|
|
538
585
|
if (result === null) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
|
|
539
|
-
const
|
|
586
|
+
const snap2 = state.snapshots.get(id)
|
|
540
587
|
const cutSeq = await snaps.resolveCutSeq(sessionId, id)
|
|
541
|
-
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time:
|
|
588
|
+
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
|
|
542
589
|
},
|
|
543
590
|
|
|
544
591
|
'execute': async (args) => {
|
|
545
592
|
const id = args && args.messageId ? String(args.messageId) : ''
|
|
546
593
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
547
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
|
+
}
|
|
548
612
|
// 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
|
|
549
613
|
// 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
|
|
550
614
|
// 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
|
|
551
615
|
// (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
|
|
552
616
|
// 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
|
|
553
|
-
const snap = state.snapshots.get(id)
|
|
554
|
-
if (!snap) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
|
|
555
|
-
const store = state.stores.get(snap.root)
|
|
556
|
-
if (!store) return { ok: false, code: 'NO_STORE', message: '快照存储不可用' }
|
|
557
617
|
const safetyId = 'pre-rollback-' + Date.now()
|
|
558
618
|
try {
|
|
559
619
|
await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
@@ -628,8 +688,12 @@ export function apply(ctx, config) {
|
|
|
628
688
|
gcSnaps: cfg.gcSnaps,
|
|
629
689
|
gcHours: cfg.gcHours,
|
|
630
690
|
maxFileBytes: cfg.maxFileBytes,
|
|
691
|
+
maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
|
|
631
692
|
baseExcludes: cfg.baseExcludes.slice(),
|
|
632
693
|
refillDraft: cfg.refillDraft,
|
|
694
|
+
snapshotEnabled: cfg.snapshotEnabled,
|
|
695
|
+
archiveOriginal: cfg.archiveOriginal,
|
|
696
|
+
retentionDays: cfg.retentionDays,
|
|
633
697
|
},
|
|
634
698
|
overridden,
|
|
635
699
|
envLocks,
|
|
@@ -646,7 +710,21 @@ export function apply(ctx, config) {
|
|
|
646
710
|
if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
|
|
647
711
|
if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
|
|
648
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
|
+
}
|
|
649
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
|
+
}
|
|
650
728
|
if (patch.baseExcludes !== undefined) {
|
|
651
729
|
if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
|
|
652
730
|
clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
|
|
@@ -673,8 +751,13 @@ export function apply(ctx, config) {
|
|
|
673
751
|
const sessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
674
752
|
if (op === 'list') {
|
|
675
753
|
// 结果缓存(30s + 删除/新快照失效):设置页反复打开、删除后刷新
|
|
676
|
-
// 都会重拉列表,缓存让二次打开零 shell
|
|
677
|
-
|
|
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
|
+
}
|
|
678
761
|
const allItems = []
|
|
679
762
|
|
|
680
763
|
// 磁盘全量:一条 shell dump(dumpStores 见其注释——旧实现每目录
|
|
@@ -732,9 +815,8 @@ export function apply(ctx, config) {
|
|
|
732
815
|
}
|
|
733
816
|
|
|
734
817
|
allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
return payload
|
|
818
|
+
listCache = { at: Date.now(), items: allItems }
|
|
819
|
+
return { ok: true, items: allItems.slice(0, safeLimit), total: allItems.length }
|
|
738
820
|
}
|
|
739
821
|
if (op === 'titles') {
|
|
740
822
|
// 冷会话标题补齐(Client 异步二次请求):readSession 整日志解压 +
|
|
@@ -812,12 +894,18 @@ export function apply(ctx, config) {
|
|
|
812
894
|
}
|
|
813
895
|
if (op === 'usage') {
|
|
814
896
|
let bytes = 0
|
|
897
|
+
// 存储健康统计(S2-4):仅对内存已知 store 计数——与汇总同源,
|
|
898
|
+
// 冷启动预热未完成时不完整,属已知限制(见 plan-settings-ux S2-4)
|
|
899
|
+
let homeStores = 0
|
|
900
|
+
let fallbackStores = 0
|
|
815
901
|
if (sessionId) {
|
|
816
902
|
// 旧调用方(带会话上下文):单工作区占用
|
|
817
903
|
const root = await rt.resolveRoot(sessionId)
|
|
818
904
|
if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
|
|
819
905
|
const store = state.stores.get(root)
|
|
820
906
|
if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
|
|
907
|
+
if (store.home) homeStores++
|
|
908
|
+
else fallbackStores++
|
|
821
909
|
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
822
910
|
bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
823
911
|
} else {
|
|
@@ -826,13 +914,15 @@ export function apply(ctx, config) {
|
|
|
826
914
|
// 不影响汇总,best-effort。
|
|
827
915
|
for (const store of state.stores.values()) {
|
|
828
916
|
if (!store || !store.dir) continue
|
|
917
|
+
if (store.home) homeStores++
|
|
918
|
+
else fallbackStores++
|
|
829
919
|
try {
|
|
830
920
|
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
831
921
|
bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
832
922
|
} catch (error) { /* 单 store 失败跳过 */ }
|
|
833
923
|
}
|
|
834
924
|
}
|
|
835
|
-
return { ok: true, bytes }
|
|
925
|
+
return { ok: true, bytes, gitAvailable: state.gitExe !== '', homeStores, fallbackStores }
|
|
836
926
|
}
|
|
837
927
|
if (op === 'delete') {
|
|
838
928
|
// 统一删除入口:scope=workspace 删除整个工作区全部快照;
|
|
@@ -889,7 +979,7 @@ export function apply(ctx, config) {
|
|
|
889
979
|
state.snapshots.delete(id)
|
|
890
980
|
await snaps.saveIndex(finalRoot, sessionId)
|
|
891
981
|
// 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
|
|
892
|
-
listCache.
|
|
982
|
+
listCache.items = null
|
|
893
983
|
})
|
|
894
984
|
return { ok: true }
|
|
895
985
|
}
|
|
@@ -916,8 +1006,44 @@ export function apply(ctx, config) {
|
|
|
916
1006
|
return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
|
|
917
1007
|
},
|
|
918
1008
|
|
|
919
|
-
//
|
|
920
|
-
|
|
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
|
+
}
|
|
921
1047
|
}
|
|
922
1048
|
|
|
923
1049
|
ctx.effect(() => webServer.register({
|
|
@@ -955,9 +1081,12 @@ export function apply(ctx, config) {
|
|
|
955
1081
|
const messageId = data.id
|
|
956
1082
|
const time = event.time
|
|
957
1083
|
state.queue = state.queue
|
|
958
|
-
|
|
1084
|
+
// 快照总开关(S2-1):cfg 按调用时读取,设置页热更即时生效。
|
|
1085
|
+
// 关闭时只冻结新建,maybeMaintain 照常跑——已停增的存储仍需被
|
|
1086
|
+
// gc/清理治理。
|
|
1087
|
+
.then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
|
|
959
1088
|
.then(() => maint.maybeMaintain(session.id))
|
|
960
|
-
.then(() => { listCache.
|
|
1089
|
+
.then(() => { listCache.items = null })
|
|
961
1090
|
.catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
|
|
962
1091
|
})
|
|
963
1092
|
|