dsh-recall-plugin 2.1.1 → 2.2.1
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 +31 -0
- package/README.en.md +57 -31
- package/README.md +46 -21
- package/lib/client.js +14 -1
- package/lib/dump-parse.js +78 -0
- package/lib/index.js +19 -34
- package/lib/maintenance.js +30 -18
- package/lib/routes-core.js +25 -7
- package/lib/routes-manage.js +170 -90
- package/lib/scripts.posix.js +57 -9
- package/lib/scripts.pwsh.js +146 -36
- package/lib/snapshots.js +65 -4
- package/lib/store.js +44 -35
- package/package.json +1 -1
package/lib/maintenance.js
CHANGED
|
@@ -95,12 +95,21 @@ export function createMaintenance(ctx, rt, snaps, config) {
|
|
|
95
95
|
return purged
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// -
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
98
|
+
// 扫描索引里出现过的全部会话:不在 sessions 注册表、也不在磁盘会话目录
|
|
99
|
+
// 里的,才认定「已删除」。
|
|
100
|
+
// PF-7:一次 listSessions 建 id 集合替代逐会话 readSession 冷读——后者
|
|
101
|
+
// 对每个非 live 会话解压全量日志且跑在串行队列里,会话多的老工作区 gc
|
|
102
|
+
// 一到就把后续快照/撤回全堵在队尾;listSessions 是「目录级 header 枚举、
|
|
103
|
+
// 不触碰全量日志」(I8:记录 id 在 header.id),一次调用即得全部磁盘
|
|
104
|
+
// 会话 id 集。判定语义与旧「readSession 成败」等价且更保守:归档会话
|
|
105
|
+
// 日志仍在磁盘(集合中保留,不被误清——旧路径同样靠这一点)、日志损坏
|
|
106
|
+
// 但文件在的也保留(purge 不可逆,宁可少清)。
|
|
107
|
+
// 保守闸门保持:sessionQuery 服务(或 listSessions)不存在、枚举抛异常
|
|
108
|
+
// 时整体跳过——无法枚举就无法区分「已删除」和「只是冷着」,误删快照
|
|
109
|
+
// 不可逆,宁可不清理。
|
|
110
|
+
// titles 半项(PF-7 原案):探针(tests/probe/api-surface.test.js)确认
|
|
111
|
+
// SessionHeader 无 title 字段(标题住在事件日志里)→ 冷标题无法走
|
|
112
|
+
// listSessions,titles 冷读维持 readSession 现状。
|
|
104
113
|
async function sweepDeletedSessions() {
|
|
105
114
|
const ids = new Set()
|
|
106
115
|
for (const s of state.snapshots.values()) {
|
|
@@ -108,17 +117,19 @@ export function createMaintenance(ctx, rt, snaps, config) {
|
|
|
108
117
|
}
|
|
109
118
|
if (!ids.size) return
|
|
110
119
|
const query = ctx.get('sessionQuery')
|
|
111
|
-
if (!query || typeof query.
|
|
120
|
+
if (!query || typeof query.listSessions !== 'function') return
|
|
121
|
+
let diskIds
|
|
122
|
+
try {
|
|
123
|
+
diskIds = new Set(((await query.listSessions()) || [])
|
|
124
|
+
.map((r) => r && r.header && r.header.id)
|
|
125
|
+
.filter(Boolean))
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
112
129
|
for (const id of ids) {
|
|
113
130
|
if (sessions.get(id)) continue
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const log = await query.readSession(id)
|
|
117
|
-
alive = Boolean(log)
|
|
118
|
-
} catch (error) {
|
|
119
|
-
alive = false
|
|
120
|
-
}
|
|
121
|
-
if (!alive) await purgeSession(id)
|
|
131
|
+
if (diskIds.has(id)) continue
|
|
132
|
+
await purgeSession(id)
|
|
122
133
|
}
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -257,7 +268,8 @@ export function createMaintenance(ctx, rt, snaps, config) {
|
|
|
257
268
|
await runGc(sessionId, false)
|
|
258
269
|
}
|
|
259
270
|
|
|
260
|
-
// 模块收敛:runGc/runGcAll 之外的内部步骤不对外暴露面;enforceLimits
|
|
261
|
-
// 保留导出供单测以工厂形态驱动(注入假 rt/ctx
|
|
262
|
-
|
|
271
|
+
// 模块收敛:runGc/runGcAll 之外的内部步骤不对外暴露面;enforceLimits /
|
|
272
|
+
// sweepDeletedSessions 保留导出供单测以工厂形态驱动(注入假 rt/ctx 钉
|
|
273
|
+
// 执行链路;PF-7 sweep 判定矩阵依赖导出)。
|
|
274
|
+
return { maybeMaintain, runGc, runGcAll, enforceLimits, enforceRetention, sweepDeletedSessions }
|
|
263
275
|
}
|
package/lib/routes-core.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ENV_HINTS } from './diagnostics.js'
|
|
11
|
+
import { parseTreeId } from './snapshots.js'
|
|
11
12
|
|
|
12
13
|
export function createRoutesCore(deps) {
|
|
13
14
|
// ctx 不解构(A4):本域所有服务访问都已由 rt/snaps 封装,直接摸 ctx 会
|
|
@@ -64,7 +65,11 @@ export function createRoutesCore(deps) {
|
|
|
64
65
|
if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: '该消息没有可用的项目快照' }
|
|
65
66
|
const snap2 = state.snapshots.get(id)
|
|
66
67
|
const cutSeq = await snaps.resolveCutSeq(sessionId, id)
|
|
67
|
-
|
|
68
|
+
// PF-1:treeId 是 preview 时 add -A 后的 index 树指纹,Client 确认时
|
|
69
|
+
// 透传回 execute——Host 与安全快照指纹比对即可判定「预览后文件是否
|
|
70
|
+
// 变化」,省掉 execute 侧整条重复 diff。旧版 Client 不认识该字段,
|
|
71
|
+
// 无值时 execute 退回 previewTotal 校验(向后兼容)。
|
|
72
|
+
return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, treeId: result.treeId || null, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
|
|
68
73
|
},
|
|
69
74
|
|
|
70
75
|
'execute': async (args) => {
|
|
@@ -78,11 +83,15 @@ export function createRoutesCore(deps) {
|
|
|
78
83
|
// P0-1:队列内第一步——执行前再查一次 agent 状态。检查放在互斥
|
|
79
84
|
// 队列内,检查后紧接执行,中间不可能插进别的操作,窗口为零。
|
|
80
85
|
if (agentBusy(sessionId, snap.root)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: 'Agent 正在运行中,请先停止后再撤回' }
|
|
81
|
-
// P0-3:preview→execute
|
|
82
|
-
// Client
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
+
// P0-3 / PF-1:preview→execute 失效校验,两代并存——
|
|
87
|
+
// - 新版 Client 透传 previewTreeId(preview 时 add -A 后的 index 树
|
|
88
|
+
// 指纹):与下方安全快照输出的指纹比对,内容级一致判定,且免掉
|
|
89
|
+
// 一整条重复 diff 进程(一次撤回 4 条重进程 → 3 条)。
|
|
90
|
+
// - 旧版 Client 只带 previewTotal:退回条目总数校验(多付一次 diff,
|
|
91
|
+
// 同数不同文件的边缘情形由安全快照兜底)。
|
|
92
|
+
// - 都不带(直调 API):不校验,与 P0-3 同款可选语义。
|
|
93
|
+
const previewTreeId = args && typeof args.previewTreeId === 'string' && args.previewTreeId ? args.previewTreeId : null
|
|
94
|
+
if (!previewTreeId && args && typeof args.previewTotal === 'number') {
|
|
86
95
|
const fresh = await snaps.diffFor(id)
|
|
87
96
|
if (!fresh || fresh.total !== args.previewTotal) {
|
|
88
97
|
return { ok: false, code: E.RECALL_STALE, message: '预览后项目文件发生了变化,请重新预览确认' }
|
|
@@ -95,15 +104,24 @@ export function createRoutesCore(deps) {
|
|
|
95
104
|
// 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
|
|
96
105
|
// 失败时 safetyOk 置 false:后续回退若也失败将无救援点(H1),
|
|
97
106
|
// 行为退化为现状(fail-loud),不更差。
|
|
107
|
+
// PF-1:安全快照输出的树指纹就是「执行时刻的工作区状态」——与
|
|
108
|
+
// previewTreeId 不一致 → STALE(此时安全快照已打下,isSafetySnapshotId
|
|
109
|
+
// 让它不进索引,反而是额外的救援点)。安全快照失败时无指纹可比对,
|
|
110
|
+
// 跳过指纹校验继续回退(不阻断主流程的既有语义)。
|
|
98
111
|
const safetyId = 'pre-rollback-' + Date.now()
|
|
99
112
|
let safetyOk = false
|
|
113
|
+
let safetyTreeId = null
|
|
100
114
|
try {
|
|
101
|
-
await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
115
|
+
const out = await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
102
116
|
safetyOk = true
|
|
117
|
+
safetyTreeId = parseTreeId(out)
|
|
103
118
|
} catch (error) {
|
|
104
119
|
// 安全快照失败不阻断回退本身:用户已确认覆盖,记录后照原计划执行
|
|
105
120
|
rt.recordError('recall safety snapshot failed: ' + String(error))
|
|
106
121
|
}
|
|
122
|
+
if (previewTreeId && safetyTreeId && safetyTreeId !== previewTreeId) {
|
|
123
|
+
return { ok: false, code: E.RECALL_STALE, message: '预览后项目文件发生了变化,请重新预览确认' }
|
|
124
|
+
}
|
|
107
125
|
const rolled = await snaps.rollbackFor(id)
|
|
108
126
|
if (rolled.ok) return rolled
|
|
109
127
|
// 回退失败(rollbackFor 返回 partial,工作区可能半回退):用安全快照
|
package/lib/routes-manage.js
CHANGED
|
@@ -8,22 +8,115 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { isSafetySnapshotId } from './snapshots.js'
|
|
11
|
+
import { parseExcludeDump } from './dump-parse.js'
|
|
11
12
|
|
|
12
13
|
export function createRoutesManage(deps) {
|
|
13
14
|
const {
|
|
14
15
|
ctx, rt, snaps, maint, state, cfg, supported, enqueue, runLimited,
|
|
15
16
|
listExcludeFiles, dumpStores, locateSnapshotOnDisk, collectAllSnapshotRecords,
|
|
16
|
-
listCache, excludeCache, sessionInfo, titleFromEvents, messageTextFromEvents,
|
|
17
|
+
listCache, excludeCache, usageCache, sessionInfo, titleFromEvents, messageTextFromEvents,
|
|
17
18
|
applyResolvedConfig, readSettings, DEFAULTS, E,
|
|
18
19
|
} = deps
|
|
19
20
|
const { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast } = sessionInfo
|
|
20
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
|
+
push(e.id, e.time, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 内存兜底(刚拍未落盘的保险,正常已被磁盘 dump 覆盖)
|
|
77
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
78
|
+
push(id, s.time, s.root, s.sessionId)
|
|
79
|
+
}
|
|
80
|
+
allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
|
|
81
|
+
return allItems
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// PF-6:stale 时的后台缓存刷新(in-flight 去重)——stale 期间重复 list
|
|
85
|
+
// 复用同一进行中的 dump,不重复起进程(否则进程数反而放大)。完成后清
|
|
86
|
+
// stale 标记;失败静默(下次 stale 触发自然重试)。
|
|
87
|
+
function refreshListCacheInBackground() {
|
|
88
|
+
if (listCache.refreshing) return listCache.refreshing
|
|
89
|
+
listCache.refreshing = buildListItems()
|
|
90
|
+
.then((allItems) => {
|
|
91
|
+
listCache.items = allItems
|
|
92
|
+
listCache.at = Date.now()
|
|
93
|
+
listCache.stale = false
|
|
94
|
+
})
|
|
95
|
+
// 冒烟实证:这里的静默吞错曾让 stale 卡死近半小时无任何观测点——
|
|
96
|
+
// 失败必须留痕(console),否则只能靠行为异常反推
|
|
97
|
+
.catch((error) => { console.error('recall list refresh failed:', String(error && error.stack || error)) })
|
|
98
|
+
.finally(() => { listCache.refreshing = null })
|
|
99
|
+
return listCache.refreshing
|
|
100
|
+
}
|
|
101
|
+
|
|
21
102
|
// 按过滤条件批量删除快照(工作区/会话两个树节点共用):先收集匹配
|
|
22
103
|
// id 并按 root 分组,再整体进串行队列——与快照/gc 互斥,避免 git 锁
|
|
23
104
|
// 竞态。每个 root 先 purge tag 再补载索引后重写 index.json,防止冷启动
|
|
24
105
|
// 时用残缺内存覆盖同 store 其余磁盘快照。
|
|
106
|
+
// PF-6:缓存非空(含 stale)时直接由缓存 items 构造 records,省一次
|
|
107
|
+
// 全量 dumpStores——删除以「用户当前所见」为准(stale 说明有新快照未
|
|
108
|
+
// 入列表,用户没看到的也不在删除预期内);缓存为空才全量收集。
|
|
25
109
|
async function deleteSnapshotsByFilter(match, sessionId) {
|
|
26
|
-
|
|
110
|
+
let records
|
|
111
|
+
if (Array.isArray(listCache.items) && listCache.items.length) {
|
|
112
|
+
records = new Map()
|
|
113
|
+
for (const it of listCache.items) {
|
|
114
|
+
if (!it || typeof it.id !== 'string') continue
|
|
115
|
+
records.set(it.id, { id: it.id, root: it.root || null, sessionId: it.sessionId || null, time: typeof it.time === 'number' ? it.time : 0 })
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
records = await collectAllSnapshotRecords()
|
|
119
|
+
}
|
|
27
120
|
const byRoot = new Map()
|
|
28
121
|
for (const rec of records.values()) {
|
|
29
122
|
if (!match(rec) || !rec.root) continue
|
|
@@ -60,6 +153,7 @@ export function createRoutesManage(deps) {
|
|
|
60
153
|
}
|
|
61
154
|
}
|
|
62
155
|
listCache.items = null
|
|
156
|
+
usageCache.payload = null
|
|
63
157
|
})
|
|
64
158
|
return deleted
|
|
65
159
|
}
|
|
@@ -136,6 +230,7 @@ export function createRoutesManage(deps) {
|
|
|
136
230
|
// list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
|
|
137
231
|
// 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
|
|
138
232
|
listCache.items = null
|
|
233
|
+
usageCache.payload = null
|
|
139
234
|
return { deleted, stores: clearedStores, failed }
|
|
140
235
|
})
|
|
141
236
|
}
|
|
@@ -145,17 +240,28 @@ export function createRoutesManage(deps) {
|
|
|
145
240
|
// 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
|
|
146
241
|
// 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
|
|
147
242
|
if (!supported) return { ok: false, unsupported: true }
|
|
148
|
-
// 30s
|
|
149
|
-
//
|
|
243
|
+
// 30s 结果缓存:首次进入要 resolveStore 链 + 读取,二次打开/切标签
|
|
244
|
+
// 不应重复付出这份代价;exclude-set 写入后失效。
|
|
150
245
|
if (excludeCache.payload && Date.now() - excludeCache.at < 30000) return excludeCache.payload
|
|
151
246
|
const byFile = await listExcludeFiles()
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
247
|
+
// PF-8:一条脚本 base64 读全部 exclude 文件——原每文件一条 Get-Content/
|
|
248
|
+
// cat 进程是首开 4-6 条链路里的大头;内容走 base64 对任意用户文本
|
|
249
|
+
// 免疫(定界不会被内容行打乱),parse 失败/文件缺失按空内容处理
|
|
250
|
+
// (与原 readExclude 对不存在文件输出空串的语义一致)。
|
|
251
|
+
let contents = new Map()
|
|
252
|
+
try {
|
|
253
|
+
const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.excludeDumpScript(Array.from(byFile.keys())), { stdoutMaxBytes: 1048576 }))
|
|
254
|
+
contents = parseExcludeDump(text)
|
|
255
|
+
} catch (error) { /* dump 失败退回空内容列表(读失败的原语义) */ }
|
|
256
|
+
const payload = {
|
|
257
|
+
ok: true,
|
|
258
|
+
files: Array.from(byFile.entries()).map(([path, info]) => ({
|
|
259
|
+
path,
|
|
260
|
+
home: Boolean(info.store.home),
|
|
261
|
+
roots: info.roots,
|
|
262
|
+
content: contents.get(path) || ''
|
|
263
|
+
}))
|
|
264
|
+
}
|
|
159
265
|
excludeCache.at = Date.now()
|
|
160
266
|
excludeCache.payload = payload
|
|
161
267
|
return payload
|
|
@@ -263,66 +369,19 @@ export function createRoutesManage(deps) {
|
|
|
263
369
|
if (op === 'list') {
|
|
264
370
|
const limitRaw = args && args.limit !== undefined ? Number(args.limit) : 200
|
|
265
371
|
const safeLimit = Math.min(Math.max(Number.isFinite(limitRaw) ? Math.trunc(limitRaw) : 200, 1), 2000)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const hints = new Map()
|
|
275
|
-
for (const [root, st] of state.stores.entries()) {
|
|
276
|
-
if (st && st.dir) hints.set(st.dir, root)
|
|
277
|
-
}
|
|
278
|
-
// 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照因
|
|
279
|
-
// 「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
|
|
280
|
-
const byId = new Map()
|
|
281
|
-
function push(id, time, root, sessionId) {
|
|
282
|
-
if (!id || typeof id !== 'string') return
|
|
283
|
-
// F-G1 防御性展示过滤:修复前 rebuildOrphans 曾把 safety tag
|
|
284
|
-
// (pre-rollback-<ts>)strip 前缀后写进 index.json——存量污染条目
|
|
285
|
-
// 不做迁移清理(一次性数据,代价收益不划算),这里挡住可见性:
|
|
286
|
-
// 安全快照不是消息快照,本就不该出现在管理列表/树里。
|
|
287
|
-
if (isSafetySnapshotId(id)) return
|
|
288
|
-
const old = byId.get(id)
|
|
289
|
-
if (!old) {
|
|
290
|
-
const rec = {
|
|
291
|
-
id,
|
|
292
|
-
time: typeof time === 'number' ? time : 0,
|
|
293
|
-
root: root || null,
|
|
294
|
-
workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
|
|
295
|
-
sessionId: sessionId || null,
|
|
296
|
-
sessionTitle: liveTitleFast(sessionId)
|
|
297
|
-
}
|
|
298
|
-
// 消息文本只放已确认值:live 命中字符串则带,否则不设字段。
|
|
299
|
-
const liveText = liveMessageTextFast(sessionId, id)
|
|
300
|
-
if (liveText) rec.messageText = liveText
|
|
301
|
-
byId.set(id, rec)
|
|
302
|
-
allItems.push(rec)
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
// 与 collectAllSnapshotRecords 同款补全:磁盘先占位、内存后补全 root
|
|
306
|
-
if (!old.root && root) { old.root = root; old.workspace = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || null }
|
|
307
|
-
if (!old.sessionId && sessionId) { old.sessionId = sessionId; old.sessionTitle = liveTitleFast(sessionId) }
|
|
308
|
-
if (!old.messageText && id) { old.messageText = liveMessageTextFast(sessionId, id) }
|
|
309
|
-
if (!old.time && time) old.time = time
|
|
372
|
+
// PF-6:缓存非空且(fresh 或 stale)→ 立即用旧 items 应答,对话中
|
|
373
|
+
// 打开快照管理不再等全量 dump(30s TTL 曾被每条消息的清空形同虚设)。
|
|
374
|
+
// stale 时后台刷新(in-flight 去重),Client 凭 stale 标记静默再拉
|
|
375
|
+
// 一次渐进补新。缓存为空 → 同步 dump(首开现状)。
|
|
376
|
+
if (listCache.items && (Date.now() - listCache.at < 30000 || listCache.stale)) {
|
|
377
|
+
const stale = Boolean(listCache.stale)
|
|
378
|
+
if (stale) refreshListCacheInBackground()
|
|
379
|
+
return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length, stale }
|
|
310
380
|
}
|
|
311
|
-
|
|
312
|
-
const baseRoot = info.root || hints.get(dir) || null
|
|
313
|
-
for (const e of info.entries || []) {
|
|
314
|
-
if (!e || typeof e.id !== 'string') continue
|
|
315
|
-
push(e.id, e.time, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId)
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
// 内存兜底(刚拍未落盘的保险,正常已被磁盘 dump 覆盖)
|
|
319
|
-
for (const [id, s] of state.snapshots.entries()) {
|
|
320
|
-
push(id, s.time, s.root, s.sessionId)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
|
|
381
|
+
const allItems = await buildListItems()
|
|
324
382
|
listCache.at = Date.now()
|
|
325
383
|
listCache.items = allItems
|
|
384
|
+
listCache.stale = false
|
|
326
385
|
return { ok: true, items: allItems.slice(0, safeLimit), total: allItems.length }
|
|
327
386
|
}
|
|
328
387
|
if (op === 'titles') {
|
|
@@ -391,6 +450,13 @@ export function createRoutesManage(deps) {
|
|
|
391
450
|
return { ok: true, messageTexts: texts }
|
|
392
451
|
}
|
|
393
452
|
if (op === 'usage') {
|
|
453
|
+
// PF-3 顺带:全量 usage 结果 30s TTL(与 listCache 同款,删除/gc 后
|
|
454
|
+
// 由调用点失效)——ManageCard 每次 refresh 都重算的话,枚举再快
|
|
455
|
+
// 也是白付。仅缓存无 sessionId 的全量分支(client 唯一调用形态;
|
|
456
|
+
// 单工作区分支无调用方,不值得引入 key 维度)。
|
|
457
|
+
if (!sessionId && usageCache.payload && Date.now() - usageCache.at < 30000) {
|
|
458
|
+
return usageCache.payload
|
|
459
|
+
}
|
|
394
460
|
let bytes = 0
|
|
395
461
|
let homeStores = 0
|
|
396
462
|
let fallbackStores = 0
|
|
@@ -404,17 +470,29 @@ export function createRoutesManage(deps) {
|
|
|
404
470
|
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
405
471
|
bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
406
472
|
} else {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
473
|
+
// PF-3 顺带:多 store 并行——读操作不碰 index.lock,但为防极端
|
|
474
|
+
// 磁盘争抢仍走 runLimited(并发 4)而不是裸 Promise.all;单 store
|
|
475
|
+
// 失败跳过的既有语义不变。
|
|
476
|
+
const knownStores = Array.from(state.stores.values()).filter((s) => s && s.dir)
|
|
477
|
+
const perStore = new Map()
|
|
478
|
+
await runLimited(knownStores.map((store) => async () => {
|
|
411
479
|
try {
|
|
412
480
|
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
413
|
-
|
|
481
|
+
perStore.set(store.dir, parseInt(rt.scripts.stripBom(out).trim(), 10) || 0)
|
|
414
482
|
} catch (error) { /* 单 store 失败跳过 */ }
|
|
483
|
+
}), 4)
|
|
484
|
+
for (const store of knownStores) {
|
|
485
|
+
if (store.home) homeStores++
|
|
486
|
+
else fallbackStores++
|
|
487
|
+
bytes += perStore.get(store.dir) || 0
|
|
415
488
|
}
|
|
416
489
|
}
|
|
417
|
-
|
|
490
|
+
const payload = { ok: true, bytes, gitAvailable: state.gitExe !== '', homeStores, fallbackStores }
|
|
491
|
+
if (!sessionId) {
|
|
492
|
+
usageCache.at = Date.now()
|
|
493
|
+
usageCache.payload = payload
|
|
494
|
+
}
|
|
495
|
+
return payload
|
|
418
496
|
}
|
|
419
497
|
if (op === 'delete') {
|
|
420
498
|
const scope = args && args.scope ? String(args.scope) : 'snapshot'
|
|
@@ -467,6 +545,7 @@ export function createRoutesManage(deps) {
|
|
|
467
545
|
await snaps.saveIndex(finalRoot, sessionId)
|
|
468
546
|
// 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
|
|
469
547
|
listCache.items = null
|
|
548
|
+
usageCache.payload = null
|
|
470
549
|
})
|
|
471
550
|
return { ok: true }
|
|
472
551
|
}
|
|
@@ -488,26 +567,27 @@ export function createRoutesManage(deps) {
|
|
|
488
567
|
const done = sessionId
|
|
489
568
|
? await enqueue(() => maint.runGc(sessionId, true))
|
|
490
569
|
: await enqueue(() => maint.runGcAll())
|
|
570
|
+
// gc 后占用显著下降:立即失效占用缓存,设置页 refresh 必须看到新值
|
|
571
|
+
usageCache.payload = null
|
|
491
572
|
return { ok: true, gc: Boolean(done) }
|
|
492
573
|
}
|
|
493
574
|
if (op === 'lineage') {
|
|
494
|
-
// F1:返回全部已知工作区的 fork lineage(childId ↔ parentId
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
575
|
+
// F1 / PF-4:返回全部已知工作区的 fork lineage(childId ↔ parentId
|
|
576
|
+
// 撤回链),供快照管理树聚族。原实现对每个 root 串行 loadLineage
|
|
577
|
+
// (每 root 一条进程,20 工作区 ≈ 10s,版本家族标记最后才亮)——
|
|
578
|
+
// LINEAGE 段并入 storesDump 后一次 dump 全拿,零新增进程。dump 的
|
|
579
|
+
// ==DIR 就是磁盘 store 目录(比 roots 全集更全,还免去对未知 root
|
|
580
|
+
// resolveStore 建目录的副作用);无 LINEAGE 段的旧输出按空 lineage
|
|
581
|
+
// 处理(parseStoresDump 容错)。
|
|
582
|
+
const hints = new Map()
|
|
583
|
+
for (const [root, st] of state.stores.entries()) {
|
|
584
|
+
if (st && st.dir) hints.set(st.dir, root)
|
|
585
|
+
}
|
|
586
|
+
let dump
|
|
587
|
+
try { dump = await dumpStores() } catch (error) { dump = new Map() }
|
|
503
588
|
const out = []
|
|
504
|
-
for (const
|
|
505
|
-
|
|
506
|
-
if (!store) {
|
|
507
|
-
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
508
|
-
}
|
|
509
|
-
if (!store) continue
|
|
510
|
-
for (const e of await snaps.loadLineage(root)) out.push(e)
|
|
589
|
+
for (const info of dump.values()) {
|
|
590
|
+
for (const e of info.lineage || []) out.push(e)
|
|
511
591
|
}
|
|
512
592
|
return { ok: true, lineage: out }
|
|
513
593
|
}
|
package/lib/scripts.posix.js
CHANGED
|
@@ -75,12 +75,15 @@ function dropGitlinksBlock() {
|
|
|
75
75
|
// 漏看个别文件是 fail-open,可接受——与 pwsh 版同策略。
|
|
76
76
|
// 阈值按调用注入(store.maxFileBytes,config 可调),不读模块常量。
|
|
77
77
|
// 依赖外层已定义的 $git/$g/$root。
|
|
78
|
+
// PF-9 合批:find 命中经管道剥前缀后 xargs -0 多路径合参——xargs 自适应
|
|
79
|
+
// 批次(规避 ARG_MAX)等价 win32 侧显式 100 条/批;-0 保证路径不分裂;
|
|
80
|
+
// xargs 失败/空输入 || true 兜住(fail-open 语义与逐条版一致,残留条目
|
|
81
|
+
// 不进 index 的代价由下次快照幂等重试)。
|
|
78
82
|
function oversizeBlock(maxBytes) {
|
|
79
83
|
return [
|
|
80
84
|
'find "$root" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + 'c -print0 2>/dev/null | while IFS= read -r -d \'\' f; do',
|
|
81
|
-
'
|
|
82
|
-
|
|
83
|
-
'done'
|
|
85
|
+
' printf \'%s\\0\' "${f#"$root"/}"',
|
|
86
|
+
"done | xargs -0 \"$git\" --literal-pathspecs --git-dir=\"$g\" update-index --force-remove -- 2>/dev/null || true",
|
|
84
87
|
].join('\n')
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -91,6 +94,14 @@ function oversizeBlock(maxBytes) {
|
|
|
91
94
|
// 兼容 Windows 上编辑带 CRLF 的 exclude.txt。
|
|
92
95
|
// base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
|
|
93
96
|
// 依赖外层已定义的 $git/$g。
|
|
97
|
+
// - PF-9 条件化:新旧内容比对(命令替换对两侧同样剥尾随换行,比对稳定)
|
|
98
|
+
// 相同则跳过重写**并跳过**清理循环(每条消息常态省 1 次 git 子进程 +
|
|
99
|
+
// 1 次盘写);语义安全论证见 pwsh 版同注释(exclude 未变时 index 已净,
|
|
100
|
+
// add -A 因排除先生效不会加回;「改排除即时生效」承诺不变)。
|
|
101
|
+
// - PF-9 合批:ls-files -z 命中经 xargs -0 多路径合参——xargs 自适应批次
|
|
102
|
+
// 本就是为规避 ARG_MAX 设计(等价 win32 侧显式 100 条/批的分块纪律),
|
|
103
|
+
// -0 保证空格/中文路径不分裂;空输入时 GNU xargs 空跑一次 update-index
|
|
104
|
+
// (usage 退出,2>/dev/null + || true 兜住,BSD xargs 空输入不执行)。
|
|
94
105
|
function excludeSyncBlock(excludeFile, base) {
|
|
95
106
|
// 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
|
|
96
107
|
// dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
|
|
@@ -109,10 +120,12 @@ function excludeSyncBlock(excludeFile, base) {
|
|
|
109
120
|
' user_pats="$user_pats$t\\n"',
|
|
110
121
|
' done < "$ex_file"',
|
|
111
122
|
'fi',
|
|
112
|
-
"printf '\\n" + baseLines.replace(/\\/g, '\\\\').replace(/%/g, '%%') + "%b' \"$user_pats\"
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
123
|
+
"new_exc=$(printf '\\n" + baseLines.replace(/\\/g, '\\\\').replace(/%/g, '%%') + "%b' \"$user_pats\")",
|
|
124
|
+
'old_exc=$(cat "$exc" 2>/dev/null || true)',
|
|
125
|
+
'if [ "$new_exc" != "$old_exc" ]; then',
|
|
126
|
+
" printf '%s\\n' \"$new_exc\" > \"$exc\"",
|
|
127
|
+
' "$git" -c core.quotePath=false --literal-pathspecs --git-dir="$g" ls-files -i -c --exclude-from="$exc" -z 2>/dev/null | xargs -0 "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- 2>/dev/null || true',
|
|
128
|
+
'fi',
|
|
116
129
|
].join('\n')
|
|
117
130
|
}
|
|
118
131
|
|
|
@@ -267,6 +280,9 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
267
280
|
'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
|
|
268
281
|
'commit=$("$git" --git-dir="$g" -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree "$tree" -m ' + psq('snapshot ' + messageId) + ')',
|
|
269
282
|
'"$git" --git-dir="$g" tag -f ' + psq('snap-' + messageId) + ' "$commit" >/dev/null',
|
|
283
|
+
// PF-1:TREE 行随 SNAP_OK 回传 add -A 之后的 index 树指纹(语义见 pwsh 版
|
|
284
|
+
// 同名注释)——execute 与 preview 指纹比对判 STALE,免整条重复 diff
|
|
285
|
+
'echo "TREE $tree"',
|
|
270
286
|
'echo SNAP_OK'
|
|
271
287
|
].join('\n')
|
|
272
288
|
}
|
|
@@ -304,12 +320,15 @@ function collectListsBlock(store, gitExe, root, tag, base) {
|
|
|
304
320
|
// target 侧 "mode type sha<TAB>path" 取 a[3]),输出 TSV「kind<TAB>path」
|
|
305
321
|
// 逐行打印,Node 侧解析(不在 bash 里拼 JSON——没有 jq 依赖、
|
|
306
322
|
// 转义路径的坑也一并消失)。sort -k2 按 path 确定序,与 pwsh 版对齐。
|
|
323
|
+
// PF-1:末尾追加 write-tree + TREE 行(add 后的 index 树指纹,语义见 pwsh
|
|
324
|
+
// 版同注释)。POSIX 侧 TSV 文本轻、无 ConvertTo-Json 序列化开销,不做
|
|
325
|
+
// TOTAL/截断——全量输出,截断仍由 JS 侧 slice(与既有语义一致)。
|
|
307
326
|
export function diffScript(root, store, gitExe, tag, base) {
|
|
308
327
|
return [
|
|
309
328
|
'set -e -o pipefail',
|
|
310
329
|
collectListsBlock(store, gitExe, root, tag, base),
|
|
311
330
|
"trap 'rm -f \"$tmpc\" \"$tmpt\"' EXIT",
|
|
312
|
-
'awk -F\'\\t\' -v OFS=\'\\t\' \'',
|
|
331
|
+
'awk -F\'\\t\' -v OFS=\'\\t\' \'.',
|
|
313
332
|
' FNR==1 { fidx++ }',
|
|
314
333
|
' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
|
|
315
334
|
' { split($1, a, " "); tgt[$2]=a[3] }',
|
|
@@ -321,6 +340,8 @@ export function diffScript(root, store, gitExe, tag, base) {
|
|
|
321
340
|
' for (p in tgt) if (!(p in cur)) print "restored", p',
|
|
322
341
|
' }',
|
|
323
342
|
"' \"$tmpc\" \"$tmpt\" | sort -t$'\\t' -k2,2",
|
|
343
|
+
'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
|
|
344
|
+
'echo "TREE $tree"',
|
|
324
345
|
'exit 0'
|
|
325
346
|
].join('\n')
|
|
326
347
|
}
|
|
@@ -503,6 +524,13 @@ export function renameFileCmd(src, dst) {
|
|
|
503
524
|
return 'mv -f -- ' + psq(src) + ' ' + psq(dst)
|
|
504
525
|
}
|
|
505
526
|
|
|
527
|
+
// 任意长度文本写入(PF-2,语义见 pwsh 版同名注释):stdin 传全文 + 单进程
|
|
528
|
+
// 落盘。POSIX 原本就直写 stdin(cat > tmp 此前内联在 store.js,PF-2 起迁进
|
|
529
|
+
// 模板统一走同名导出),bash 无编码/长度问题,模板本体就是这条 cat。
|
|
530
|
+
export function fileWriteStdinCmd(file) {
|
|
531
|
+
return 'cat > ' + psq(file)
|
|
532
|
+
}
|
|
533
|
+
|
|
506
534
|
// 索引读取(写入走 stdin:见 snapshots.js saveIndex 的 POSIX 分支,
|
|
507
535
|
// 不经命令行传参,天然没有 32767/128KB argv 上限问题)
|
|
508
536
|
export function indexReadCmd(dir) {
|
|
@@ -527,6 +555,22 @@ export function excludeReadCmd(file) {
|
|
|
527
555
|
return 'cat ' + psq(file) + ' 2>/dev/null || true'
|
|
528
556
|
}
|
|
529
557
|
|
|
558
|
+
// 批量读全部 exclude 文件(PF-8,语义见 pwsh 版同注释):内容 base64 单行
|
|
559
|
+
// 输出(任意文本免疫定界混淆)。GNU base64 默认 76 字符折行、BSD(macOS)
|
|
560
|
+
// 不折行且无 -w——统一 base64 | tr -d '\n' 兼容两侧;读失败输出空段。
|
|
561
|
+
export function excludeDumpScript(files) {
|
|
562
|
+
const lines = []
|
|
563
|
+
for (const f of files || []) {
|
|
564
|
+
const q = psq(f)
|
|
565
|
+
lines.push(
|
|
566
|
+
"printf 'EXCLBEGIN %s\\n' " + q,
|
|
567
|
+
'if [ -f ' + q + ' ]; then base64 ' + q + " 2>/dev/null | tr -d '\\n'; fi",
|
|
568
|
+
"echo 'EXCLEND'"
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
return lines.join('\n')
|
|
572
|
+
}
|
|
573
|
+
|
|
530
574
|
// 目录存在探测:YES/NO 定长标记与 pwsh 版逐字同语义(容器路径本身在
|
|
531
575
|
// JS 侧解析,POSIX 不需要 homeContainerScript 的 shell 版)。
|
|
532
576
|
export function dirExistsScript(dir) {
|
|
@@ -556,7 +600,8 @@ export function listSubdirsScript(dir) {
|
|
|
556
600
|
}
|
|
557
601
|
|
|
558
602
|
// 批量 dump 全部 store 元数据(与 pwsh 版 storesDumpScript 同格式、
|
|
559
|
-
// 同语义,见其注释):一条 shell 拿全部目录的 root.txt + index.json
|
|
603
|
+
// 同语义,见其注释):一条 shell 拿全部目录的 root.txt + index.json +
|
|
604
|
+
// lineage.json(PF-4)。
|
|
560
605
|
// bash 3.2 兼容:数组 + += 均可用,glob 无匹配时字面量经 [ -d ] 过滤。
|
|
561
606
|
// root.txt 经 tr 去掉可能的 CRLF 再拼单行,防标记结构被打乱。
|
|
562
607
|
export function storesDumpScript(container, extraDirs) {
|
|
@@ -583,6 +628,9 @@ export function storesDumpScript(container, extraDirs) {
|
|
|
583
628
|
' echo INDEXBEGIN',
|
|
584
629
|
' cat "$d/index.json" 2>/dev/null',
|
|
585
630
|
' echo INDEXEND',
|
|
631
|
+
' echo LINEAGEBEGIN',
|
|
632
|
+
' cat "$d/lineage.json" 2>/dev/null',
|
|
633
|
+
' echo LINEAGEEND',
|
|
586
634
|
'done',
|
|
587
635
|
'exit 0'
|
|
588
636
|
)
|