dsh-recall-plugin 1.2.2 → 1.4.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/lib/index.js CHANGED
@@ -1,164 +1,541 @@
1
- /**
2
- * dsh-recall-plugin — Host 半入口(持久插件形态,bundle 行挂载)
3
- *
4
- * 职责:装配各域模块(store 执行存储层 / snapshots 快照域 /
5
- * maintenance 维护域),通过 webServer 注册 /api/recall/* HTTP API
6
- * 供 Client 半调用(init / snapshot-info / preview / execute),
7
- * 并接线 session/event 快照触发与启动预热。
8
- *
9
- * 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml 的
10
- * insert 行挂载进 profile composition,DSH 重启后自动生效。
11
- * 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
12
- */
13
-
14
- import { createRuntime } from './store.js'
15
- import { createSnapshots } from './snapshots.js'
16
- import { createMaintenance } from './maintenance.js'
17
-
18
- export const name = 'dsh-recall-plugin'
19
-
20
- // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
21
- // webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
22
- export const inject = ['shell', 'sessions', 'webServer']
23
-
24
- export function apply(ctx) {
25
- const webServer = ctx.webServer
26
-
27
- const rt = createRuntime(ctx)
28
- const snaps = createSnapshots(ctx, rt)
29
- const maint = createMaintenance(ctx, rt, snaps)
30
- const state = rt.state
31
-
32
- // 平台门控:win32 PowerShell 模板,linux/darwin 走 bash 模板
33
- // (ctx.shell DSH 平台层单选挂载 pwsh/bash 执行器,见 dsh-shell README)。
34
- // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示;
35
- // 其余端点因无快照自然返回「没有可用快照」,全程零文件副作用。
36
- const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
37
-
38
- // ---- HTTP APIClient 半经由 fetch 调用;动态插件的 harness RPC 在此换成 webServer 路由)----
39
-
40
- async function readJsonBody(req) {
41
- const chunks = []
42
- for await (const chunk of req) chunks.push(chunk)
43
- const text = Buffer.concat(chunks).toString('utf8')
44
- if (!text.trim()) return {}
45
- return JSON.parse(text)
46
- }
47
-
48
- function sendJson(res, status, body) {
49
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
50
- res.end(JSON.stringify(body))
51
- }
52
-
53
- ctx.effect(() => webServer.register({
54
- kind: 'prefix',
55
- path: '/api/recall',
56
- handler: async (req, res) => {
57
- const path = (req.url || '').split('?')[0]
58
- const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
59
- try {
60
- const args = await readJsonBody(req)
61
- if (name === 'init') {
62
- if (!supported) {
63
- sendJson(res, 200, { ok: false, root: null, notice: { unsupported: true } })
64
- return
65
- }
66
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
67
- const root = await rt.resolveRoot(sessionId)
68
- let notice = null
69
- if (root) {
70
- let store = await rt.resolveStore(root)
71
- store = await rt.tryUpgradeToHome(root)
72
- await rt.ensureGit(root, store)
73
- await snaps.loadIndex(root, sessionId)
74
- await snaps.rebuildOrphans(root, sessionId)
75
- rt.cleanupLegacy(root)
76
- // 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
77
- // gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
78
- // 不可写,快照降级存进项目内 .dsh-recall-snapshots。
79
- notice = {
80
- gitMissing: state.gitExe === '',
81
- homeFallback: store ? !store.home : false
82
- }
83
- }
84
- sendJson(res, 200, { ok: Boolean(root), root: root || null, notice })
85
- return
86
- }
87
- if (name === 'snapshot-info') {
88
- await state.queue
89
- const id = args && args.messageId ? String(args.messageId) : ''
90
- const snap = state.snapshots.get(id)
91
- sendJson(res, 200, { has: Boolean(snap), time: snap ? snap.time : null, id })
92
- return
93
- }
94
- if (name === 'preview') {
95
- const id = args && args.messageId ? String(args.messageId) : ''
96
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
97
- try {
98
- const changes = await snaps.diffFor(id)
99
- if (changes === null) { sendJson(res, 200, { ok: false, error: '该消息没有可用的项目快照' }); return }
100
- const snap = state.snapshots.get(id)
101
- const cutSeq = await snaps.resolveCutSeq(sessionId, id)
102
- sendJson(res, 200, { ok: true, changes, time: snap ? snap.time : null, root: snap ? snap.root : null, cutSeq })
103
- } catch (error) {
104
- sendJson(res, 200, { ok: false, error: String(error) })
105
- }
106
- return
107
- }
108
- if (name === 'execute') {
109
- const id = args && args.messageId ? String(args.messageId) : ''
110
- const sessionId = args && args.sessionId ? String(args.sessionId) : null
111
- try {
112
- const result = await snaps.rollbackFor(id)
113
- if (!result.ok) { sendJson(res, 200, result); return }
114
- // 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
115
- const cutSeq = await snaps.resolveCutSeq(sessionId, id)
116
- sendJson(res, 200, { ok: true, count: result.count, cutSeq })
117
- } catch (error) {
118
- sendJson(res, 200, { ok: false, error: String(error) })
119
- }
120
- return
121
- }
122
- sendJson(res, 404, { ok: false, error: 'unknown endpoint: ' + name })
123
- } catch (error) {
124
- sendJson(res, 500, { ok: false, error: String(error) })
125
- }
126
- }
127
- }))
128
-
129
- // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
130
- if (!supported) return
131
-
132
- // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
133
- // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
134
- ctx.on('session/event', (session, event) => {
135
- if (!event || event.type !== 'user/message') return
136
- const data = event.data
137
- if (!data || typeof data.id !== 'string' || !data.id) return
138
- const source = data.source
139
- if (!source || source.kind !== 'user') return
140
- if (session && session.header && session.header.origin === 'subagent') return
141
- const messageId = data.id
142
- const time = event.time
143
- state.queue = state.queue
144
- .then(() => snaps.captureSnapshot(session.id, messageId, time))
145
- .then(() => maint.maybeMaintain(session.id))
146
- .catch((error) => console.error('recall snapshot error:', String(error)))
147
- })
148
-
149
- // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
150
- // 并清理旧版项目内 blobs 目录(home 可用时)。
151
- // 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
152
- for (const session of ctx.sessions.list()) {
153
- const cwd = session && session.header && session.header.cwd
154
- if (!cwd) continue
155
- const sessionId = session.id
156
- Promise.resolve(rt.resolveStore(cwd))
157
- .then(() => rt.tryUpgradeToHome(cwd))
158
- .then((store) => rt.ensureGit(cwd, store))
159
- .then(() => snaps.loadIndex(cwd, sessionId))
160
- .then(() => snaps.rebuildOrphans(cwd, sessionId))
161
- .then(() => rt.cleanupLegacy(cwd))
162
- .catch(() => {})
163
- }
164
- }
1
+ /**
2
+ * dsh-recall-plugin — Host 半入口(持久插件形态,bundle 行挂载)
3
+ *
4
+ * 职责:装配各域模块(config 配置域 / store 执行存储层 / snapshots 快照域 /
5
+ * maintenance 维护域),通过 webServer 注册 /api/recall/* HTTP API
6
+ * 供 Client 半调用(init / snapshot-info / preview / execute /
7
+ * exclude-get / exclude-set / manage / status),并接线 session/event
8
+ * 快照触发与启动预热。
9
+ *
10
+ * 这是持久 npm 插件包的主入口(exports["."]),由 cordis.patch.yml
11
+ * insert 行挂载进 profile composition,DSH 重启后自动生效。
12
+ * 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
13
+ */
14
+
15
+ import { createConfig } from './config.js'
16
+ import { createRuntime } from './store.js'
17
+ import { createSnapshots } from './snapshots.js'
18
+ import { createMaintenance } from './maintenance.js'
19
+
20
+ export const name = 'dsh-recall-plugin'
21
+
22
+ // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
23
+ // webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
24
+ export const inject = ['shell', 'sessions', 'webServer']
25
+
26
+ // config 由 cordis.patch.yml 的 insert 行 config 键下发,用户在 profile 层
27
+ // id: recall 重述该行即可覆盖默认值(见 config.js 头注释)
28
+ export function apply(ctx, config) {
29
+ const webServer = ctx.webServer
30
+
31
+ const cfg = createConfig(config)
32
+ const rt = createRuntime(ctx, cfg)
33
+ const snaps = createSnapshots(ctx, rt, cfg)
34
+ const maint = createMaintenance(ctx, rt, snaps, cfg)
35
+ const state = rt.state
36
+
37
+ // 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板
38
+ // (ctx.shell DSH 平台层单选挂载 pwsh/bash 执行器,见 dsh-shell README)。
39
+ // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示;
40
+ // 其余端点因无快照自然返回「没有可用快照」,全程零文件副作用。
41
+ const supported = process.platform === 'win32' || process.platform === 'linux' || process.platform === 'darwin'
42
+
43
+ // ---- HTTP API(Client 半经由 fetch 调用;动态插件的 harness RPC 在此换成 webServer 路由)----
44
+
45
+ // 请求体上限:端点里 exclude-set 接受用户任意文本,无上限时可被无限
46
+ // POST 撑爆内存。1MB 远超正常配置体量,超限干净报错而不是悄悄截断
47
+ // (半截 JSON 会在 parse 处抛更晦涩的错)。
48
+ const MAX_BODY_BYTES = 1048576
49
+
50
+ // 快照管理列表的结果缓存:磁盘 dump + 冷会话标题即便已批量/并行化,
51
+ // 也不是零成本(1 条 shell + 若干日志解压)。设置页打开、删除后刷新
52
+ // 都会重拉,30s 缓存让二次打开即时;delete 与新快照落地时失效。
53
+ let listCache = { at: 0, payload: null }
54
+
55
+ // 会话标题缓存(apply 级跨请求共享):冷会话标题要 readSession 整日志
56
+ // 解压 + 重放校验(大日志 10 秒级),绝不能挡列表首屏——list 只查
57
+ // live/缓存(同步、瞬时),冷标题由 Client 拿到列表后异步调 titles 补。
58
+ // 值为 null 表示「查过、确实没有」(已删除会话),同样命中缓存。
59
+ const sessionTitles = new Map()
60
+ function titleFromEvents(events) {
61
+ if (!Array.isArray(events)) return null
62
+ for (let i = events.length - 1; i >= 0; i--) {
63
+ const e = events[i]
64
+ if (e && e.type === 'session/title' && e.data && typeof e.data.title === 'string' && e.data.title) return e.data.title
65
+ }
66
+ return null
67
+ }
68
+ function liveTitleFast(sessionId) {
69
+ if (!sessionId) return null
70
+ if (sessionTitles.has(sessionId)) return sessionTitles.get(sessionId)
71
+ let t = null
72
+ try {
73
+ const live = ctx.sessions.get(sessionId)
74
+ if (live) t = titleFromEvents(live.events)
75
+ } catch (error) { t = null }
76
+ if (t !== null) sessionTitles.set(sessionId, t)
77
+ return t
78
+ }
79
+
80
+ async function readJsonBody(req) {
81
+ const chunks = []
82
+ let size = 0
83
+ for await (const chunk of req) {
84
+ size += chunk.length
85
+ if (size > MAX_BODY_BYTES) throw new Error('BODY_TOO_LARGE')
86
+ chunks.push(chunk)
87
+ }
88
+ const text = Buffer.concat(chunks).toString('utf8')
89
+ if (!text.trim()) return {}
90
+ return JSON.parse(text)
91
+ }
92
+
93
+ function sendJson(res, status, body) {
94
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
95
+ res.end(JSON.stringify(body))
96
+ }
97
+
98
+ // 枚举当前全部已知 exclude 文件并按路径去重:home 存储全局共享一份
99
+ // (所有工作区通常合并成一条),降级存储各自独立。根来源取并集——
100
+ // 会话注册表(当前活跃的工作区)+ state.stores 缓存(历史会话预热过、
101
+ // 可能已关闭的工作区),让设置页也能编辑非活跃项目的排除配置。
102
+ // exclude-get 直接消费结果;exclude-set 用它做路径白名单校验,客户端
103
+ // 只能回传 get 下发过的路径,堵死「借 API 写任意文件」的通道。
104
+ async function listExcludeFiles() {
105
+ const roots = new Set(state.stores.keys())
106
+ for (const session of ctx.sessions.list()) {
107
+ const cwd = session && session.header && session.header.cwd
108
+ if (cwd) roots.add(cwd)
109
+ }
110
+ const byFile = new Map()
111
+ for (const root of roots) {
112
+ try {
113
+ const store = await rt.resolveStore(root)
114
+ if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
115
+ byFile.get(store.excludeFile).roots.push(root)
116
+ } catch (error) {
117
+ /* 单个根解析失败只影响它自己,不拖垮整个列表 */
118
+ }
119
+ }
120
+ // 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
121
+ // 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑(哪怕从未
122
+ // 写过、内容为空);容器不在 全新安装,让设置页显示引导文案。
123
+ // 注册表扫描命中的同路径条目优先(roots 信息更全),这里只补缺。
124
+ try {
125
+ const container = await rt.resolveHomeContainer()
126
+ if (container) {
127
+ const probe = rt.scripts.stripBom(await rt.runShell(rt.scripts.dirExistsScript(container), { stdoutMaxBytes: 4096 })).trim()
128
+ if (probe === 'YES') {
129
+ const excludeFile = container + (rt.isWin ? '\\' : '/') + 'exclude.txt'
130
+ if (!byFile.has(excludeFile)) {
131
+ // 伪 store:仅承载 readExclude/writeExclude 用到的 excludeFile
132
+ // 与 home 两个字段;不进 state.stores(无对应 root,不污染缓存)
133
+ byFile.set(excludeFile, { store: { dir: container, home: true, excludeFile }, roots: [] })
134
+ }
135
+ }
136
+ }
137
+ } catch (error) {
138
+ /* 兜底失败退回注册表结果 */
139
+ }
140
+ return byFile
141
+ }
142
+
143
+ // 工作区 cwd 全集:live 注册表只是子集(ctx.sessions 是纯内存 Map,web
144
+ // 侧栏拉会话列表走 persistence 只读路径、不 resume,注册表可以一直空
145
+ // 着),sessionQuery.listSessions 是「live + 磁盘冷元数据」的完整语料
146
+ // ——每个会话 header 都带创建时的 cwd。manage list 与 delete 兜底共用。
147
+ async function collectCwds() {
148
+ const cwds = new Set()
149
+ for (const session of ctx.sessions.list()) {
150
+ const cwd = session && session.header && session.header.cwd
151
+ if (cwd) cwds.add(cwd)
152
+ }
153
+ try {
154
+ const querySvc = ctx.get('sessionQuery')
155
+ if (querySvc && typeof querySvc.listSessions === 'function') {
156
+ for (const record of await querySvc.listSessions()) {
157
+ const cwd = record && record.header && record.header.cwd
158
+ if (cwd) cwds.add(cwd)
159
+ }
160
+ }
161
+ } catch (error) { /* 冷元数据不可用时退回 live 注册表 */ }
162
+ return cwds
163
+ }
164
+
165
+ // 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
166
+ // (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store 的 JSON 损坏只丢它自己。
167
+ function parseStoresDump(text) {
168
+ const map = new Map()
169
+ let cur = null
170
+ let inIndex = false
171
+ let indexLines = []
172
+ function flush() {
173
+ if (!cur) return
174
+ const raw = indexLines.join('\n').trim()
175
+ if (raw) {
176
+ try {
177
+ const arr = JSON.parse(raw)
178
+ if (Array.isArray(arr)) cur.entries = arr
179
+ } catch (error) { /* index 损坏按无索引处理 */ }
180
+ }
181
+ map.set(cur.dir, cur)
182
+ cur = null
183
+ }
184
+ for (const line of String(text).split(/\r?\n/)) {
185
+ if (line.indexOf('==DIR ') === 0) { flush(); cur = { dir: line.slice(6).trim(), root: null, entries: null }; inIndex = false; indexLines = []; continue }
186
+ if (!cur) continue
187
+ if (line.indexOf('ROOT ') === 0) { const v = line.slice(5).trim(); cur.root = v || null; continue }
188
+ if (line === 'INDEXBEGIN') { inIndex = true; indexLines = []; continue }
189
+ if (line === 'INDEXEND') { inIndex = false; continue }
190
+ if (inIndex) indexLines.push(line)
191
+ }
192
+ flush()
193
+ return map
194
+ }
195
+
196
+ // 一条 shell dump 全部 store 元数据(容器子目录 + 降级候选目录的
197
+ // root.txt 与 index.json),manage list 与 delete 兜底共用。
198
+ async function dumpStores() {
199
+ const container = await rt.resolveHomeContainer()
200
+ const extras = Array.from(await collectCwds()).map((cwd) => cwd + (rt.isWin ? '\\' : '/') + '.dsh-recall-snapshots')
201
+ try {
202
+ const text = rt.scripts.stripBom(await rt.runShell(rt.scripts.storesDumpScript(container || '', extras), { timeoutMs: 120000, stdoutMaxBytes: 8388608 }))
203
+ return parseStoresDump(text)
204
+ } catch (error) {
205
+ return new Map()
206
+ }
207
+ }
208
+
209
+ // 磁盘反查某快照归属的 store:dump 全部 index 后按 id 查找,root 取
210
+ // 条目自带字段 → root.txt → 内存映射。delete 的兜底路径用它消灭
211
+ // 「列表可见但内存缺失 ⇒ 误报不存在」。
212
+ async function locateSnapshotOnDisk(id) {
213
+ if (!id) return null
214
+ const dump = await dumpStores()
215
+ const hints = new Map()
216
+ for (const [root, st] of state.stores.entries()) {
217
+ if (st && st.dir) hints.set(st.dir, root)
218
+ }
219
+ for (const [dir, info] of dump) {
220
+ const hit = (info.entries || []).find((e) => e && e.id === id)
221
+ if (!hit) continue
222
+ const root = (typeof hit.root === 'string' && hit.root) || info.root || hints.get(dir) || null
223
+ if (!root) continue
224
+ try {
225
+ const store = await rt.resolveStore(root)
226
+ if (store) return { store, root }
227
+ } catch (error) { /* 单个 root 解析失败继续找 */ }
228
+ }
229
+ return null
230
+ }
231
+
232
+ // 统一错误映射:业务失败与系统异常分离,文案与诊断解耦。code 给
233
+ // Client 做分支判断(BODY_TOO_LARGE 等),message 直接展示。
234
+ function errBody(error) {
235
+ const text = String(error && error.message ? error.message : error)
236
+ if (text === 'BODY_TOO_LARGE') return { ok: false, code: 'BODY_TOO_LARGE', message: '请求体超过 1MB 上限' }
237
+ return { ok: false, code: 'ERROR', message: text }
238
+ }
239
+
240
+ // ---- 端点表:name → handler(args) → 回包体。统一 try/catch 与入队
241
+ // 策略写在这里,端点主体只写业务。queued 标记的端点与快照/gc 共用同
242
+ // 一条串行队列——preview/execute 内部都跑 git add -A,不入队会与
243
+ // 进行中的快照争 index.lock(曾只在 snapshot-info 入队,是并发隐患)。
244
+ // 队列入队即占住后续快照,队列失败不堵队(catch 就地消化)。
245
+ function enqueue(task) {
246
+ const run = state.queue.then(task)
247
+ state.queue = run.catch(() => {})
248
+ return run
249
+ }
250
+
251
+ const endpoints = {
252
+ 'init': async (args) => {
253
+ if (!supported) {
254
+ return { ok: false, root: null, notice: { unsupported: true } }
255
+ }
256
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
257
+ const root = await rt.resolveRoot(sessionId)
258
+ let notice = null
259
+ if (root) {
260
+ let store = await rt.resolveStore(root)
261
+ store = await rt.tryUpgradeToHome(root)
262
+ await rt.ensureGit(root, store)
263
+ await snaps.loadIndex(root, sessionId)
264
+ await snaps.rebuildOrphans(root, sessionId)
265
+ rt.cleanupLegacy(root)
266
+ // 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
267
+ // gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
268
+ // 不可写,快照降级存进项目内 .dsh-recall-snapshots。
269
+ notice = {
270
+ gitMissing: state.gitExe === '',
271
+ homeFallback: store ? !store.home : false
272
+ }
273
+ }
274
+ return { ok: Boolean(root), root: root || null, notice }
275
+ },
276
+
277
+ 'snapshot-info': async (args) => {
278
+ const id = args && args.messageId ? String(args.messageId) : ''
279
+ const snap = state.snapshots.get(id)
280
+ return { has: Boolean(snap), time: snap ? snap.time : null, id }
281
+ },
282
+
283
+ 'preview': async (args) => {
284
+ const id = args && args.messageId ? String(args.messageId) : ''
285
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
286
+ const result = await enqueue(() => snaps.diffFor(id))
287
+ if (result === null) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
288
+ const snap = state.snapshots.get(id)
289
+ const cutSeq = await snaps.resolveCutSeq(sessionId, id)
290
+ return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap ? snap.time : null, root: snap ? snap.root : null, cutSeq }
291
+ },
292
+
293
+ 'execute': async (args) => {
294
+ const id = args && args.messageId ? String(args.messageId) : ''
295
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
296
+ const result = await enqueue(async () => {
297
+ // 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
298
+ // 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
299
+ // 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
300
+ // (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
301
+ // 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
302
+ const snap = state.snapshots.get(id)
303
+ if (!snap) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
304
+ const store = state.stores.get(snap.root)
305
+ if (!store) return { ok: false, code: 'NO_STORE', message: '快照存储不可用' }
306
+ const safetyId = 'pre-rollback-' + Date.now()
307
+ try {
308
+ await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
309
+ } catch (error) {
310
+ // 安全快照失败不阻断回退本身:用户已确认覆盖,记录后照原计划执行
311
+ rt.recordError('recall safety snapshot failed: ' + String(error))
312
+ }
313
+ return snaps.rollbackFor(id)
314
+ })
315
+ if (!result.ok) return result
316
+ // 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
317
+ const cutSeq = await snaps.resolveCutSeq(sessionId, id)
318
+ return { ok: true, count: result.count, cutSeq }
319
+ },
320
+
321
+ 'exclude-get': async () => {
322
+ // 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
323
+ // 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
324
+ if (!supported) return { ok: false, unsupported: true }
325
+ const byFile = await listExcludeFiles()
326
+ const files = []
327
+ for (const [path, info] of byFile) {
328
+ let content = ''
329
+ try { content = await snaps.readExclude(info.store) } catch (error) { content = '' }
330
+ files.push({ path, home: Boolean(info.store.home), roots: info.roots, content })
331
+ }
332
+ return { ok: true, files }
333
+ },
334
+
335
+ 'exclude-set': async (args) => {
336
+ if (!supported) return { ok: false, unsupported: true }
337
+ const path = args && args.path ? String(args.path) : ''
338
+ const content = args && typeof args.content === 'string' ? args.content : ''
339
+ // 路径白名单:重新枚举当前已知 exclude 文件并要求精确命中,
340
+ // 客户端伪造的任意路径在这里被拒(见 listExcludeFiles 注释)
341
+ const byFile = await listExcludeFiles()
342
+ const info = byFile.get(path)
343
+ if (!info) return { ok: false, code: 'UNKNOWN_PATH', message: '未知的排除文件路径' }
344
+ await snaps.writeExclude(info.store, content)
345
+ return { ok: true }
346
+ },
347
+
348
+ // 设置页「快照管理」卡片:列表 / 磁盘占用 / 单条删除 / 手动 gc。
349
+ // 全部走串行队列——删除 tag 与 gc 与快照争的是同一个 git 仓库。
350
+ 'manage': async (args) => {
351
+ if (!supported) return { ok: false, unsupported: true }
352
+ const op = args && args.op ? String(args.op) : 'list'
353
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
354
+ if (op === 'list') {
355
+ // 结果缓存(30s + 删除/新快照失效):设置页反复打开、删除后刷新
356
+ // 都会重拉列表,缓存让二次打开零 shell。
357
+ if (listCache.payload && Date.now() - listCache.at < 30000) return listCache.payload
358
+ const seen = new Set()
359
+ const allItems = []
360
+
361
+ // 磁盘全量:一条 shell dump(dumpStores 见其注释——旧实现每目录
362
+ // 2-3 条 shell 串行跑,20 秒级慢的根因)。root 解析链:条目自带
363
+ // root(新数据)→ root.txt → 内存 store 映射(store 目录名是
364
+ // root 的单向 SHA256,磁盘上只有持久化记录能反查)。
365
+ // 标题只查 live/缓存(liveTitleFast,同步瞬时)——冷会话标题由
366
+ // Client 拿到列表后异步调 titles 补齐,列表首屏不等日志解压。
367
+ const dump = await dumpStores()
368
+ const hints = new Map()
369
+ for (const [root, st] of state.stores.entries()) {
370
+ if (st && st.dir) hints.set(st.dir, root)
371
+ }
372
+ // 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照
373
+ // 因「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
374
+ function push(id, time, root, sessionId) {
375
+ if (!id || typeof id !== 'string' || seen.has(id)) return
376
+ seen.add(id)
377
+ allItems.push({
378
+ id,
379
+ time: typeof time === 'number' ? time : 0,
380
+ root: root || null,
381
+ workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
382
+ sessionId: sessionId || null,
383
+ sessionTitle: liveTitleFast(sessionId)
384
+ })
385
+ }
386
+ for (const [dir, info] of dump) {
387
+ const baseRoot = info.root || hints.get(dir) || null
388
+ for (const e of info.entries || []) {
389
+ if (!e || typeof e.id !== 'string') continue
390
+ push(e.id, e.time, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId)
391
+ }
392
+ }
393
+ // 内存兜底(刚拍未落盘的保险,正常已被磁盘 dump 覆盖)
394
+ for (const [id, s] of state.snapshots.entries()) {
395
+ push(id, s.time, s.root, s.sessionId)
396
+ }
397
+
398
+ allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
399
+ const payload = { ok: true, items: allItems.slice(0, 200), total: allItems.length }
400
+ listCache = { at: Date.now(), payload }
401
+ return payload
402
+ }
403
+ if (op === 'titles') {
404
+ // 冷会话标题补齐(Client 异步二次请求):readSession 整日志解压 +
405
+ // 重放校验,大日志 10 秒级——独立于列表让首屏即时。并发交给
406
+ // sessionQuery 自带的 inspect 并发闸,这里全量并行发车。
407
+ if (!supported) return { ok: false, unsupported: true }
408
+ const ids = Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String).slice(0, 100) : []
409
+ const out = {}
410
+ await Promise.all(ids.map(async (sid) => {
411
+ if (!sid || out[sid] !== undefined) return
412
+ let title = liveTitleFast(sid)
413
+ if (title === null) {
414
+ const query = ctx.get('sessionQuery')
415
+ if (query && typeof query.readSession === 'function') {
416
+ try {
417
+ const log = await query.readSession(sid)
418
+ title = titleFromEvents(log && log.events)
419
+ } catch (error) { title = null }
420
+ }
421
+ }
422
+ sessionTitles.set(sid, title)
423
+ out[sid] = title
424
+ }))
425
+ return { ok: true, titles: out }
426
+ }
427
+ if (op === 'usage') {
428
+ const root = await rt.resolveRoot(sessionId)
429
+ if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
430
+ const store = state.stores.get(root)
431
+ if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
432
+ const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
433
+ const bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
434
+ return { ok: true, bytes }
435
+ }
436
+ if (op === 'delete') {
437
+ const id = args && args.messageId ? String(args.messageId) : ''
438
+ // 管理列表来自磁盘(跨工作区全量),而内存 state.snapshots 只含
439
+ // 当前工作区 + 预热过的——冷启动或别的会话先点开列表时,列表里有、
440
+ // 内存里没有,只查内存会把可删的快照误报「不存在」。解析链:
441
+ // 内存命中 → Client 透传的条目 root → 磁盘 index 反查归属 store。
442
+ let snap = state.snapshots.get(id) || null
443
+ let root = snap ? snap.root : (args && args.root ? String(args.root) : null)
444
+ let store = null
445
+ if (root) {
446
+ try { store = await rt.resolveStore(root) } catch (error) { store = null }
447
+ }
448
+ if (!store) {
449
+ // 兜底:扫 home 容器与降级目录的 index.json,找到含该 id 的 store
450
+ const found = await locateSnapshotOnDisk(id)
451
+ if (found) { store = found.store; root = found.root }
452
+ }
453
+ if (!store) return { ok: false, code: 'NO_SNAPSHOT', message: '该快照不存在' }
454
+ const finalStore = store
455
+ const finalRoot = root
456
+ await enqueue(async () => {
457
+ if (state.gitExe) {
458
+ await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ['snap-' + id]), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
459
+ }
460
+ // 兜底路径到这里时内存可能还没载入过该 root 的索引——直接
461
+ // saveIndex 会用「只有内存条目」的列表覆盖 index.json,把同
462
+ // store 其余磁盘快照一并抹掉。先 loadIndex 补齐内存视图(幂等,
463
+ // indexLoaded 命中则零成本),再删目标条目后重写。
464
+ if (!state.indexLoaded.has(finalRoot)) {
465
+ try { await snaps.loadIndex(finalRoot, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
466
+ }
467
+ state.snapshots.delete(id)
468
+ await snaps.saveIndex(finalRoot, sessionId)
469
+ // 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
470
+ listCache.payload = null
471
+ })
472
+ return { ok: true }
473
+ }
474
+ if (op === 'gc') {
475
+ const done = await enqueue(() => maint.runGc(sessionId, true))
476
+ return { ok: true, gc: Boolean(done) }
477
+ }
478
+ return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
479
+ },
480
+
481
+ // 设置页排障:最近错误(Host 侧 console.error 的页面可见副本)
482
+ 'status': async () => ({ ok: true, errors: state.errors.slice(-20).reverse() })
483
+ }
484
+
485
+ ctx.effect(() => webServer.register({
486
+ kind: 'prefix',
487
+ path: '/api/recall',
488
+ handler: async (req, res) => {
489
+ const path = (req.url || '').split('?')[0]
490
+ const name = path.replace(/^\/api\/recall\/?/, '').split('/')[0]
491
+ const endpoint = endpoints[name]
492
+ if (!endpoint) {
493
+ sendJson(res, 404, { ok: false, code: 'UNKNOWN_ENDPOINT', message: 'unknown endpoint: ' + name })
494
+ return
495
+ }
496
+ try {
497
+ const args = await readJsonBody(req)
498
+ sendJson(res, 200, await endpoint(args))
499
+ } catch (error) {
500
+ sendJson(res, 200, errBody(error))
501
+ }
502
+ }
503
+ }))
504
+
505
+ // 快照事件与启动预热仅在受支持平台注册(见上方 supported 短路说明)
506
+ if (!supported) return
507
+
508
+ // 每条用户消息触发快照(子代理会话跳过);快照完成后串行接一次维护
509
+ // (定期 gc / 会话清理)——排在同一条队列里,与快照天然互斥,无 git 锁竞态
510
+ ctx.on('session/event', (session, event) => {
511
+ if (!event || event.type !== 'user/message') return
512
+ const data = event.data
513
+ if (!data || typeof data.id !== 'string' || !data.id) return
514
+ const source = data.source
515
+ if (!source || source.kind !== 'user') return
516
+ if (session && session.header && session.header.origin === 'subagent') return
517
+ const messageId = data.id
518
+ const time = event.time
519
+ state.queue = state.queue
520
+ .then(() => snaps.captureSnapshot(session.id, messageId, time))
521
+ .then(() => maint.maybeMaintain(session.id))
522
+ .then(() => { listCache.payload = null })
523
+ .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
524
+ })
525
+
526
+ // 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
527
+ // 并清理旧版项目内 blobs 目录(home 可用时)。
528
+ // 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
529
+ for (const session of ctx.sessions.list()) {
530
+ const cwd = session && session.header && session.header.cwd
531
+ if (!cwd) continue
532
+ const sessionId = session.id
533
+ Promise.resolve(rt.resolveStore(cwd))
534
+ .then(() => rt.tryUpgradeToHome(cwd))
535
+ .then((store) => rt.ensureGit(cwd, store))
536
+ .then(() => snaps.loadIndex(cwd, sessionId))
537
+ .then(() => snaps.rebuildOrphans(cwd, sessionId))
538
+ .then(() => rt.cleanupLegacy(cwd))
539
+ .catch(() => {})
540
+ }
541
+ }