dsh-recall-plugin 2.3.0 → 2.3.2

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/store.js CHANGED
@@ -1,563 +1,364 @@
1
- /**
2
- * dsh-recall-plugin — 执行与存储层(ctx 绑定的工厂,无模块级副作用)
3
- *
4
- * 职责:提供 runShell(宿主身份执行 + 统一编码保证)、会话根目录解析、
5
- * git 可执行文件探测、home/降级存储解析与迁移、影子仓库初始化(ensureGit)。
6
- * process.platform 选择脚本模板(scripts.pwsh.js / scripts.posix.js),
7
- * 两套模板导出同名接口,本文件用 rt.scripts 统一下发。
8
- * 产出共享 state(各 Map 缓存)供 snapshots.js / maintenance.js 复用;
9
- * lib/index.js apply(ctx) 里装配,插件卸载时随 Fiber 一起丢弃。
10
- */
11
-
12
- import os from 'node:os'
13
- import crypto from 'node:crypto'
14
- import * as pwshScripts from './scripts.pwsh.js'
15
- import * as posixScripts from './scripts.posix.js'
16
- import { classifyEnvError } from './diagnostics.js'
17
-
18
- // home 不可写时迁移重试的节流间隔:避免每条消息都白试一次注定失败的迁移
19
- const HOME_RETRY_MS = 300000
20
-
21
- // 最近错误环形缓冲容量:设置页排障用,20 条足够回溯一轮快照/gc 的失败
22
- const ERROR_BUFFER_MAX = 20
23
-
24
- // ---- POSIX home 基底解析(模块级纯逻辑,单测直测;工厂内 posixHomeBaseResolve
25
- // 委托到这里)。放模块级而非工厂闭包:分支行为要在 win32 CI 上可测,而
26
- // posixHomeBaseResolve 只在 POSIX 运行时被触达。----
27
-
28
- // 三档回退选择:bash env $DSH_HOME → Node 主进程 DSH_HOME → os.homedir()。
29
- // 第三档必须补 /.dsh 子目录(I24):win32 版第三档是 Join-Path USERPROFILE .dsh
30
- // (scripts.pwsh.js homeDirScript),POSIX 版曾直接用裸 homedir,快照落
31
- // ~/dsh-recall-snapshots 而非 ~/.dsh/dsh-recall-snapshots(issue #11 实证)。
32
- // 返回 third 标记是否走了第三档——只有第三档才涉及旧容器迁移(存量用户
33
- // 的数据在旧位,改 base 前要先搬)。
34
- export function selectPosixHomeBase({ probed, envHome, homedir }) {
35
- if (probed) return { base: probed, third: false }
36
- if (envHome) return { base: envHome, third: false }
37
- return { base: homedir + '/.dsh', third: true }
38
- }
39
-
40
- // 第三档命中时的一次性旧容器迁移编排(best-effort,数据安全优先):
41
- // legacyHomeMigrateScript 只在「旧容器存在且新容器不存在」时整容器 mv,
42
- // 输出四态由这里裁决——MIGRATE_OK / OLD_ABSENT 落规范位置(~/.dsh/…);
43
- // BOTH_PRESENT(双容器并存)/ MIGRATE_FAIL(mv 失败)沿用旧位并 recordError,
44
- // tryUpgradeToHome 的非致命迁移哲学一致:数据不丢永远优先于路径规范。
45
- // 探测命令自身失败按同策略回落旧位——此刻无法判断旧容器是否存在,选新位
46
- // 会让存量用户「看不到」历史快照,选旧位对新装机只是维持修复前的行为。
47
- export async function resolvePosixHomeBase(deps, { probed, envHome, homedir }) {
48
- const sel = selectPosixHomeBase({ probed, envHome, homedir })
49
- if (!sel.third) return sel.base
50
- try {
51
- const out = String(await deps.runShell(deps.scripts.legacyHomeMigrateScript(homedir), { timeoutMs: 300000, stdoutMaxBytes: 4096 })).trim()
52
- if (out === 'MIGRATE_OK' || out === 'OLD_ABSENT') return sel.base
53
- deps.recordError(
54
- out === 'BOTH_PRESENT'
55
- ? 'recall home store 新旧容器并存(' + homedir + '/dsh-recall-snapshots 与 ' + homedir + '/.dsh/dsh-recall-snapshots),沿用旧位,未做任何改动'
56
- : 'recall 旧快照容器迁移失败(MIGRATE_FAIL),沿用旧位 ' + homedir + '/dsh-recall-snapshots'
57
- )
58
- return homedir
59
- } catch (error) {
60
- deps.recordError('recall 旧快照容器迁移探测失败,沿用旧位: ' + String(error))
61
- return homedir
62
- }
63
- }
64
-
65
- // 失败清扫脚本输出解读(M3 纯逻辑,供单测;cleanupAfterGitFailure 消费)。
66
- // killOrphansScript 的三级出口:CLEANUP_OTHER_INSTANCE <pid> = 检测到另一个
67
- // 活实例正在使用同一快照库(心跳有效且进程存活),清扫已让路;
68
- // CLEANUP_SKIPPED_FRESH_LOCK = 存在 5 分钟内的新锁(疑似 git 操作进行中),
69
- // 清扫未触碰;CLEANUP_DONE = 原有清扫路径执行完毕。解析按标记行匹配,
70
- // 与模板输出逐字对应(改标记必须两侧同步)。
71
- export function parseCleanupResult(out) {
72
- const m = String(out || '').match(/CLEANUP_OTHER_INSTANCE\s+(\d+)/)
73
- if (m) return { otherPid: parseInt(m[1], 10), skippedFresh: false }
74
- if (String(out || '').indexOf('CLEANUP_SKIPPED_FRESH_LOCK') >= 0) return { otherPid: null, skippedFresh: true }
75
- return { otherPid: null, skippedFresh: false }
76
- }
77
-
78
- // rename ENOENT 判定(模块级纯逻辑,单测直接覆盖):POSIX mv 与
79
- // pwsh Move-Item 的「目标不存在」文案集合,且错误必须提到 tmp 文件名
80
- //(basename)——只认 rename 步的错误形态,误吞面最小。
81
- export function isTmpConsumedError(error, basename) {
82
- const s = String(error || '')
83
- if (!basename || s.indexOf(basename) < 0) return false
84
- return /No such file/i.test(s) || /does not exist/i.test(s) || /cannot find path/i.test(s)
85
- }
86
-
87
- export function createRuntime(ctx, config) {
88
- const shell = ctx.shell
89
- const sessions = ctx.sessions
90
-
91
- const isWin = process.platform === 'win32'
92
- const SEP = isWin ? '\\' : '/'
93
- const scripts = isWin ? pwshScripts : posixScripts
94
-
95
- const state = {
96
- roots: new Map(),
97
- stores: new Map(),
98
- snapshots: new Map(),
99
- queue: Promise.resolve(),
100
- indexLoaded: new Set(),
101
- // PF-5 索引终态三/四档标记(rebuildOrphans 守卫的数据源):
102
- // - indexHealthy:磁盘索引解析成功且在场(loadIndex 正常载入分支)——
103
- // rebuildOrphans healthy 且条目非空的 root 整体跳过(省 1+N 条进程)
104
- // - indexTruncated:读截断(F-G3,内存是残缺视图)——rebuildOrphans
105
- // 必须跳过:否则全部 tag 被判孤儿、用残缺孤儿集覆盖完好的大索引
106
- // (feedback 全丢、数万条索引按 win32 分块写下是数百条进程的灾难)
107
- // empty(无索引文件)/quarantined(损坏隔离)不标记 rebuild 照跑,
108
- // 自愈链路完整
109
- indexHealthy: new Set(),
110
- indexTruncated: new Set(),
111
- gitReady: new Set(),
112
- cutSeqCache: new Map(),
113
- homeRetryAt: new Map(),
114
- gcLastAt: new Map(),
115
- gcCount: new Map(),
116
- gitExe: null,
117
- posixHomeBase: null,
118
- homeContainer: null,
119
- errors: [],
120
- // 逐消息的快照反馈(issue #7 失败可见性):失败 {failed,error}
121
- // fail-open 跳过 {skipped:[...]},由 snapshot-info 端点下发给客户端
122
- // 弹 toast。放共享 state 而非 snapshots.js 闭包:端点在 index.js,
123
- // 与索引/根缓存同层取用。
124
- snapFeedback: new Map()
125
- }
126
-
127
- // 最近错误环形缓冲:Host 侧所有失败原本只进 console.error(宿主进程
128
- // 日志,用户在页面上不可见),这里留最近 20 条经 /api/recall/status
129
- // 下发给设置页展示。同时转发 console.error 保持原有宿主日志不变。
130
- // 尾部去重(issue #11):环境性错误随每条消息重复抛出,逐条 push 会把
131
- // 20 条环形缓冲刷成同一条目、console.error 同步刷屏,其他诊断信息全被
132
- // 挤掉。相邻重复只更新 time/count——间隔其他错误的重复仍新建条目,错误
133
- // 时序不丢;kind 随条目富集(classifyEnvError),供 status 端点机器分流。
134
- function recordError(text) {
135
- const message = String(text)
136
- const last = state.errors[state.errors.length - 1]
137
- if (last && last.message === message) {
138
- last.time = Date.now()
139
- last.count += 1
140
- return
141
- }
142
- state.errors.push({ time: Date.now(), message, count: 1, kind: classifyEnvError(message) })
143
- if (state.errors.length > ERROR_BUFFER_MAX) state.errors.splice(0, state.errors.length - ERROR_BUFFER_MAX)
144
- console.error(message)
145
- }
146
-
147
- // 两套脚本模板的「命令函数」同名导出是跨平台正确性的硬约束(store.js
148
- // 按平台单选 rt.scripts,调用方统一 S.*):单侧漏导出只会在另一平台
149
- // 用户机器上以「不是函数」的怪异方式暴雷。装配时比对一次。豁免项:
150
- // 平台专属导出(homeDirScript $h 链只在 pwsh 侧需要——POSIX 的 home
151
- // 基底走 probeHomeScript + Node 侧推导;常量与转义工具不承载命令)。
152
- ;(function checkScriptParity() {
153
- // fileWriteStdinCmd 两平台同名(PF-2 起两平台统一走 stdin 单进程落盘,
154
- // 编码行为由探针钉死);legacyHomeMigrateScript 仅 posix 版存在:
155
- // 旧容器迁移是 POSIX 漂移(I24)专属的存量数据兜底,win32 无此问题
156
- const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'legacyHomeMigrateScript'])
157
- const pwshKeys = Object.keys(pwshScripts).filter((k) => !SKIP.has(k) && typeof pwshScripts[k] === 'function')
158
- const posixKeys = Object.keys(posixScripts).filter((k) => !SKIP.has(k) && typeof posixScripts[k] === 'function')
159
- const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0)
160
- if (missing.length) recordError('recall script parity: posix 缺少导出 ' + missing.join(', '))
161
- })()
162
-
163
- // 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
164
- // 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
165
- // 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
166
- // 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
167
- // 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
168
- // 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
169
- // home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
170
- // 回退恢复直接失败。pwsh-sandbox / bash-sandbox 对 danger-full-access
171
- // 直接不约束(等价本地执行器),无沙箱后端的部署则忽略该字段,两边都成立。
172
- // F-G3:runShell 的元数据变体——stdout 截断可判定(官方 ShellRunResult.stdout
173
- // CollectedOutput{text, truncated, spillPath?},见 dsh-shell 与
174
- // dsh-subprocess 的 lib/types/types.d.ts;截断时 text 只剩流尾部)。需要
175
- // 「解析完整 stdout」的调用方(loadIndex)用它区分「读截断」与「内容损坏」;
176
- // 其余调用方继续用 runShell 拿纯文本,签名不变。
177
- async function runShellMeta(command, opts) {
178
- const sp = ctx.get('sandboxPolicy')
179
- const spec = shell.resolve({
180
- // 编码前导:pwsh 侧统一 UTF-8 输出(中文机器 GBK 代码页不再乱码);
181
- // bash LC_ALL=C 确定序。各模板自带,这里统一前置注入。
182
- command: scripts.UTF8_PRELUDE + '\n' + command,
183
- timeoutMs: (opts && opts.timeoutMs) || 300000,
184
- stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
185
- // stdin 是官方 ShellExecRequest 契约字段(bash-local/pwsh 均实现),
186
- // POSIX 侧用它传 index.json 全文,绕开 argv 长度上限
187
- ...((opts && opts.stdin !== undefined) ? { stdin: opts.stdin } : {}),
188
- sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
189
- })
190
- const res = await shell.run(spec)
191
- const out = (res && res.stdout && res.stdout.text) || ''
192
- if (res && res.exitCode !== 0) {
193
- // 失败兜底(issue #7):超时/失败的 git 命令可能留下孤儿进程与
194
- // stale 锁——subprocess 服务的树级终止有竞态窗口,且 git 被硬杀时
195
- // 不回收 index.lock,残留锁会让后续每条快照持续 fatal。best-effort
196
- // 清扫后再抛原始错误,清扫自身的失败不得掩盖它。
197
- await cleanupAfterGitFailure(command)
198
- const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
199
- throw new Error(err.slice(0, 1500))
200
- }
201
- return {
202
- text: out,
203
- truncated: Boolean(res && res.stdout && res.stdout.truncated),
204
- }
205
- }
206
-
207
- async function runShell(command, opts) {
208
- return (await runShellMeta(command, opts)).text
209
- }
210
-
211
- // 从脚本文本提取影子仓库 git-dir:两套模板的 git 命令脚本都以
212
- // `$g = '<store.git>'` / `g='<store.git>'` 开头(凡带 store 的脚本全遵守
213
- // 此约定),取首个带引号字面量赋值即得。resolveGitScript 等对 $g
214
- // 非字面量赋值天然不匹配;含单引号的罕见路径会让 psq '' 转义截断
215
- // 提取结果——清扫脚本对错误路径只是 no-op(杀不到进程、删不到锁),
216
- // 安全降级为本兜底加入前的行为。
217
- function extractGitDir(command) {
218
- const m = String(command).match(/(?:^|\n)[ \t]*(?:\$g|g)[ \t]*=[ \t]*'([^']+)/)
219
- return m ? m[1] : null
220
- }
221
-
222
- async function cleanupAfterGitFailure(command) {
223
- // 哨兵识别清扫脚本自身:它也定义 $g 且可能失败(如 taskkill 缺失),
224
- // 不拦住会「清扫失败 再清扫」无限递归
225
- if (!command || String(command).indexOf('RECALL_CLEANUP') >= 0) return
226
- const gitDir = extractGitDir(command)
227
- if (!gitDir) return
228
- try {
229
- const out = await runShell(scripts.killOrphansScript(gitDir), { timeoutMs: 60000, stdoutMaxBytes: 4096 })
230
- // M3:清扫让路的两种情形都值得一条记录——前者把 issue #11 的「疑似
231
- // 多实例」升级为「确认」(点名 PID),后者解释了环境为何没有被自动
232
- // 清理。recordError 的尾部去重保证逐消息重复失败不会刷屏。
233
- const r = parseCleanupResult(out)
234
- if (r.otherPid !== null) recordError('recall 检测到另一个 DSH 实例(PID ' + r.otherPid + ')正在使用此快照库,失败清扫已让路:未杀进程、未动锁')
235
- else if (r.skippedFresh) recordError('recall 检测到 5 分钟内的新锁文件,疑似 git 操作正在进行,失败清扫已让路(锁陈旧后会自动清理)')
236
- } catch (error) { /* best-effort:清扫失败不影响原始错误的抛出 */ }
237
- }
238
-
239
- async function resolveRoot(sessionId) {
240
- const key = sessionId ? String(sessionId) : 'fallback'
241
- const cached = state.roots.get(key)
242
- if (cached) return cached
243
- let root = null
244
- // 是否为「真实会话来源」的解析结果(live header / 持久化 header):
245
- // 只有这类结果才允许进缓存。回退到 sandboxPolicy.workspaceRoot 的临时
246
- // 结果不缓存——它通常是 harness 启动目录而非会话真实 cwd,一旦缓存,
247
- // 会话稍后变 live/持久化后仍被旧错误根遮蔽,撤回按钮永不出现。
248
- let authoritative = false
249
- if (sessionId) {
250
- const session = sessions.get(sessionId)
251
- if (session && session.header && session.header.cwd) {
252
- root = session.header.cwd
253
- authoritative = true
254
- }
255
- }
256
- if (!root && sessionId) {
257
- // 冷会话(尚未 live,如页面先于会话注册就绪加载)从持久化 header
258
- // 解析真实 cwd,避免回退 workspaceRoot(harness 启动目录)查错
259
- // store。listSessions 是目录级 header 枚举,不触碰全量日志;解析
260
- // 失败静默走回退,不阻断主流程。
261
- try {
262
- const query = ctx.get('sessionQuery')
263
- if (query && typeof query.listSessions === 'function') {
264
- const records = await query.listSessions()
265
- const rec = (records || []).find((r) => r && r.header && r.header.id === sessionId)
266
- if (rec && rec.header && rec.header.cwd) {
267
- root = rec.header.cwd
268
- authoritative = true
269
- }
270
- }
271
- } catch (error) { /* 冷元数据不可用则走回退 */ }
272
- }
273
- if (!root) {
274
- const sp = ctx.get('sandboxPolicy')
275
- if (sp && sp.workspaceRoot) root = sp.workspaceRoot
276
- }
277
- if (root) {
278
- // 尾分隔符归一(win32 保 "D:\" 三字符盘根;POSIX 保 "/" 根):
279
- // cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store
280
- // 目录),也会让排除扫描的 ${f#"$root"/} 前缀剥离错一位。
281
- root = root.replace(/[\\/]+$/, '') || (isWin ? root : '/')
282
- if (isWin && root.length === 2) root += '\\'
283
- if (authoritative) state.roots.set(key, root)
284
- }
285
- return root
286
- }
287
-
288
- // 解析 git 可执行文件路径:求值一次并缓存,脚本里用绝对路径调用,
289
- // 避免每条命令依赖 PATH(DSH 进程 PATH 可能不含 git)。
290
- async function resolveGit() {
291
- if (state.gitExe !== null) return state.gitExe
292
- try {
293
- const path = scripts.stripBom(await runShell(scripts.resolveGitScript(), { stdoutMaxBytes: 4096 })).trim()
294
- state.gitExe = path || ''
295
- } catch (error) {
296
- state.gitExe = ''
297
- }
298
- return state.gitExe
299
- }
300
-
301
- // win32:哈希在 PowerShell 里算(SHA256 Create 兼容 PS 5.1),连带
302
- // $env:DSH_HOME / $env:USERPROFILE 的解析都在 shell 侧完成。
303
- async function homeDirForWin(root) {
304
- const envHome = (process.env && process.env.DSH_HOME) || ''
305
- const text = scripts.stripBom(await runShell(scripts.homeDirScript(root, envHome), { stdoutMaxBytes: 4096 })).trim()
306
- if (!text) return null
307
- // 折叠 Join-Path 可能带出的连续反斜杠;开头的双反斜杠是 UNC 前缀
308
- // (DSH_HOME/主目录指到网络盘),折叠掉会把 \\server\share 变成无效
309
- // 的 \server\share,必须原样保留。
310
- if (/^\\\\/.test(text)) return '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\')
311
- return text.replace(/\\{2,}/g, '\\')
312
- }
313
-
314
- // POSIX:shell 侧只探 bash env 里显式的 $DSH_HOME(DSH 执行器洗刷
315
- // DSH_* 变量后通常为空);为空时依次回退 Node 主进程的 DSH_HOME
316
- // (宿主进程 env,用户导出可见)与 os.homedir()(补 /.dsh 层,见
317
- // selectPosixHomeBase 的 I24 注释)。哈希用 Node crypto 统一算,规避
318
- // Linux sha256sum / macOS shasum 的二选一移植成本。三档选择与旧容器
319
- // 迁移编排都委托模块级纯函数(resolvePosixHomeBase),本方法只负责探测
320
- // 输入与结果缓存(迁移随缓存每进程至多跑一次)。
321
- async function posixHomeBaseResolve() {
322
- if (state.posixHomeBase === null) {
323
- let probed = ''
324
- try {
325
- probed = (await runShell(scripts.probeHomeScript(), { stdoutMaxBytes: 4096 })).trim()
326
- } catch (error) {
327
- probed = ''
328
- }
329
- state.posixHomeBase = await resolvePosixHomeBase(
330
- { runShell, scripts, recordError },
331
- { probed, envHome: (process.env && process.env.DSH_HOME) || '', homedir: os.homedir() }
332
- )
333
- }
334
- return state.posixHomeBase
335
- }
336
-
337
- async function homeDirForPosix(root) {
338
- const base = await posixHomeBaseResolve()
339
- const hash = crypto.createHash('sha256').update(root, 'utf8').digest('hex')
340
- return base.replace(/\/+$/, '') + '/dsh-recall-snapshots/' + hash
341
- }
342
-
343
- async function homeDirFor(root) {
344
- return isWin ? homeDirForWin(root) : homeDirForPosix(root)
345
- }
346
-
347
- // 快照容器目录(<homeBase>/dsh-recall-snapshots,不含哈希子目录):
348
- // 设置页 exclude-get 的磁盘兜底用——冷启动时会话注册表为空(惰性
349
- // 载入),但容器目录可能早已存在,此时共享 exclude.txt 仍应可编辑。
350
- // 目录结构固定 <base>/dsh-recall-snapshots/<hash>,所以容器就是
351
- // homeDirFor 结果的父目录:JS 侧 slice 推导,不再走第二条 shell 解析链
352
- // (旧实现里 homeDirScript 与 homeContainerScript 的 $h 链靠注释人工
353
- // 对齐,存在漂移风险)。失败返回 null 且不缓存,下次调用自然重试。
354
- async function resolveHomeContainer() {
355
- if (state.homeContainer) return state.homeContainer
356
- let container = null
357
- try {
358
- const probeRoot = Array.from(state.roots.values())[0] || process.cwd()
359
- const homeDir = await homeDirFor(probeRoot)
360
- if (homeDir) container = homeDir.slice(0, homeDir.length - 65)
361
- } catch (error) {
362
- container = null
363
- }
364
- if (container) state.homeContainer = container
365
- return container
366
- }
367
-
368
- // store 形态装配:exclude.txt 是用户自定义排除文件,home 存储时放在
369
- // dsh-recall-snapshots 根(所有项目共享一份全局配置);降级存储时放
370
- // store 目录内部——降级目录本身已被排除规则覆盖,不再往项目根塞文件。
371
- // git init <dir> 会把真实 git-dir 建在 <dir>/.git,所以 repo 是仓库
372
- // 工作目录、git 是真实 git-dir——冒烟测试踩过的坑。
373
- // maxFileBytes 从 config 注入 store:脚本模板(snapshot/diff/rollback
374
- // 的超大文件剔除)按调用时从 store 读取,用户改 config 后下一条命令
375
- // 即生效,无需重启——因此用 getter 跟随 config 热更新,而不是创建时
376
- // 快照(settings 卡片改 maxFileBytes 后 store 缓存不重建)。
377
- function makeStore(dir, home) {
378
- const excludeFile = home
379
- ? dir.slice(0, dir.lastIndexOf(SEP)) + SEP + 'exclude.txt'
380
- : dir + SEP + 'exclude.txt'
381
- return {
382
- dir,
383
- repo: dir + SEP + 'git',
384
- git: dir + SEP + 'git' + SEP + '.git',
385
- home,
386
- excludeFile,
387
- get maxFileBytes() { return config.maxFileBytes },
388
- }
389
- }
390
-
391
- // 将磁盘枚举出的 store 目录临时包装成 store 对象。全部删除必须覆盖
392
- // `root.txt`/`index.json` 已失步的历史仓库:这时无法安全地用 root 调
393
- // `resolveStore`(它可能新建另一个目录),所以直接以已枚举的 dir 为准。
394
- // home 参数只影响 excludeFile;删除 tag/index 不依赖它,因而未知时用
395
- // false 也安全。
396
- function storeFromDir(dir, home) {
397
- return makeStore(dir, Boolean(home))
398
- }
399
-
400
- // store 级元数据 root.txt:内容为工作区绝对路径。store 目录名是 root 的
401
- // 单向 SHA256,反解不了——「快照管理」跨工作区展示时靠它把哈希目录映射
402
- // 回工作区名。best-effort(失败不阻断主流程),旧 store 在 resolveStore
403
- // 再次被调用(重启后首个 init/快照/管理列表)时自然补写,存量自愈。
404
- function persistRootHint(store, root) {
405
- writeTextViaShell(store.dir + SEP + 'root.txt', root).catch(() => {})
406
- }
407
-
408
- // 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
409
- // 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
410
- // (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
411
- async function resolveStore(root) {
412
- const cached = state.stores.get(root)
413
- if (cached) return cached
414
- let homeDir = null
415
- try {
416
- homeDir = await homeDirFor(root)
417
- } catch (error) {
418
- homeDir = null
419
- }
420
- if (homeDir) {
421
- try {
422
- await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
423
- const store = makeStore(homeDir, true)
424
- state.stores.set(root, store)
425
- persistRootHint(store, root)
426
- return store
427
- } catch (error) {
428
- recordError('recall home store unavailable, falling back to workspace: ' + String(error))
429
- }
430
- }
431
- const fallback = root + SEP + '.dsh-recall-snapshots'
432
- await runShell(scripts.mkdirScript(fallback), { stdoutMaxBytes: 4096 })
433
- const store = makeStore(fallback, false)
434
- state.stores.set(root, store)
435
- persistRootHint(store, root)
436
- return store
437
- }
438
-
439
- // 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
440
- // 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
441
- // 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
442
- async function tryUpgradeToHome(root) {
443
- const store = state.stores.get(root)
444
- if (!store || store.home) return store
445
- const now = Date.now()
446
- const last = state.homeRetryAt.get(root) || 0
447
- if (now - last < HOME_RETRY_MS) return store
448
- state.homeRetryAt.set(root, now)
449
- let homeDir = null
450
- try {
451
- homeDir = await homeDirFor(root)
452
- } catch (error) {
453
- homeDir = null
454
- }
455
- if (!homeDir) return store
456
- try {
457
- await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
458
- await runShell(scripts.migrateScript(store.dir, homeDir), { timeoutMs: 300000, stdoutMaxBytes: 4096 })
459
- const upgraded = makeStore(homeDir, true)
460
- state.stores.set(root, upgraded)
461
- persistRootHint(upgraded, root)
462
- state.gitReady.delete(store.git)
463
- // 旧 store 的 gc 节流凭据随之作废,清掉避免新 store 误读
464
- state.gcLastAt.delete(store.git)
465
- state.gcCount.delete(store.git)
466
- console.error('recall store upgraded to home:', root)
467
- return upgraded
468
- } catch (error) {
469
- recordError('recall home upgrade failed: ' + String(error))
470
- return store
471
- }
472
- }
473
-
474
- // 任意长度文本落盘(index.json / exclude.txt / lineage.json / root.txt
475
- // 共用),原子写(H2):先写 <file>.tmp 再 rename 替换目标——写中途崩溃
476
- // 最多留一个无害的 .tmp 残留(下次写覆盖),绝不会留下截断 JSON。
477
- // PF-2:两平台统一「stdin 传全文 + 单进程落盘」(fileWriteStdinCmd,同名
478
- // 导出纪律)。win32 曾按 base64 20000 字符分块——每块一条 PowerShell 进程,
479
- // 索引几百条时 saveIndex = 6+ 条进程,而它在每条消息快照后、每次删除、
480
- // 每次 init 都全量重写;POSIX 的 cat > tmp 内联命令一并迁进模板。stdin
481
- // 不经命令行,argv 32767 上限与编码坑天然消失;pwsh 侧读取手法由探针
482
- // 钉死(OpenStandardInput 字节流——Console.In 在 PS 5.1 按 GBK 解码必挂,
483
- // 见 tests/probe/stdin-write.test.js 与 plan-performance.md 实施记录)。
484
- // 空内容也落一次写(清空配置/空索引是合法状态),stdin 空串照常发送。
485
- // rename 是同卷 O(1) 元数据操作,索引写频率为每消息一次,额外开销可忽略。
486
- // rename 步的 ENOENT 容忍(WSL 双实例实弹发现):每实例写同一个
487
- // <file>.tmp 路径,并发时一方 rename 把 tmp 消费掉,另一方 rename 报
488
- // 「No such file / does not exist」。容忍是安全的,因为能走到 rename
489
- // 的前提是写侧(stdin 写)已完整成功——任一写步失败都在写侧直接抛
490
- // (POSIX set -e 对 cat 失败终止 / pwsh EAP=Stop 对 WriteAllText 抛),
491
- // 进不到这里;所以此刻 tmp 消失只可能是同伴先把完整内容 rename 到了
492
- // 目标——本侧写语义已被达成。Windows 侧「偶发一次 Move-Item:
493
- // index.json.tmp does not exist」即同根。不进 recordError(用户错误列表
494
- // 刷屏正是要消除的症状),console.error 留诊断痕迹;其余错误原样抛出。
495
- async function renameTmpQuietly(tmp, file) {
496
- try {
497
- await runShell(scripts.renameFileCmd(tmp, file), { stdoutMaxBytes: 4096 })
498
- } catch (error) {
499
- const basename = tmp.slice(tmp.lastIndexOf(SEP) + 1)
500
- if (isTmpConsumedError(error, basename)) {
501
- console.error('recall writeTextViaShell: ' + basename + ' 已被并发写者 rename 消费,视同成功')
502
- return
503
- }
504
- throw error
505
- }
506
- }
507
-
508
- async function writeTextViaShell(file, text) {
509
- const body = String(text == null ? '' : text)
510
- const tmp = file + '.tmp'
511
- await runShell(scripts.fileWriteStdinCmd(tmp), { stdin: body, stdoutMaxBytes: 4096 })
512
- await renameTmpQuietly(tmp, file)
513
- }
514
-
515
- // 建立影子仓库(幂等:gitReady 命中后直接跳过,省掉每条消息一次的
516
- // config/exclude 重写)。同时回读 gc.stamp 种子化 gc 节流:让「上次 gc
517
- // 时间」跨重启续存,避免天天重启的机器每开机都来一次全量 gc。
518
- // 返回 {ok, error}(M1-D2):失败原因必须传出——captureSnapshot 要把它
519
- // 分类成 snapFeedback 的可行动提示(此前吞成布尔,客户端空轮询 20 次、
520
- // 用户零感知,issue #11 主线缺口);init/预热调用方忽略返回值,不受形状
521
- // 变化影响。
522
- async function ensureGit(root, store) {
523
- if (state.gitReady.has(store.git)) return { ok: true }
524
- const gitExe = await resolveGit()
525
- if (!gitExe) {
526
- // git 缺失分支:原先静默 return false,连 recordError 都没有(用户
527
- // 重启也查不到原因的盲区)。进错误环靠上方尾部去重天然免刷屏。
528
- const error = '未检测到 git CLI,快照不可用'
529
- recordError('recall ensureGit: ' + error + ':请安装 git 或检查其是否在 PATH 中')
530
- return { ok: false, error }
531
- }
532
- try {
533
- const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe, config.baseExcludes), { stdoutMaxBytes: 4096 }))
534
- state.gitReady.add(store.git)
535
- const m = out.match(/GIT_OK\s+(\d+)/)
536
- state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1000 : Date.now())
537
- return { ok: true }
538
- } catch (error) {
539
- recordError('recall ensureGit failed: ' + String(error))
540
- return { ok: false, error: String(error) }
541
- }
542
- }
543
-
544
- // 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
545
- // 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
546
- // PF-5 顺带:legacyCleaned 内存标记——该目录只存在于极早期版本用户的
547
- // 降级工作区,探测成功一次后不可能再出现,同 root 多次 init 只付一条
548
- // 进程;失败(环境性)也标记不重试:残留只占磁盘无功能影响,重置场景
549
- // (DSH 重启)每进程一次可接受(文档 PF-5 顺带节的取舍)。
550
- // pwsh 侧 legacyRmScript 已加 -ErrorAction SilentlyContinue:「目录本就
551
- // 不存在」(常态)不再抛错中断,而是以成功返回被标记——否则常态下每次
552
- // init 都白跑一条进程,标记就失去了意义。
553
- const legacyCleaned = new Set()
554
- function cleanupLegacy(root) {
555
- const store = state.stores.get(root)
556
- if (!store || !store.home) return
557
- if (legacyCleaned.has(root)) return
558
- legacyCleaned.add(root)
559
- runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
560
- }
561
-
562
- return { state, isWin, scripts, recordError, runShell, runShellMeta, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, storeFromDir, tryUpgradeToHome, ensureGit, cleanupLegacy, cleanupAfterGitFailure }
563
- }
1
+ import os from "node:os";
2
+ import crypto from "node:crypto";
3
+ import * as pwshScripts from "./scripts.pwsh.js";
4
+ import * as posixScripts from "./scripts.posix.js";
5
+ import { classifyEnvError } from "./diagnostics.js";
6
+ const HOME_RETRY_MS = 3e5;
7
+ const ERROR_BUFFER_MAX = 20;
8
+ function selectPosixHomeBase({ probed, envHome, homedir }) {
9
+ if (probed) return { base: probed, third: false };
10
+ if (envHome) return { base: envHome, third: false };
11
+ return { base: homedir + "/.dsh", third: true };
12
+ }
13
+ async function resolvePosixHomeBase(deps, inputs) {
14
+ const { probed, envHome, homedir } = inputs;
15
+ const sel = selectPosixHomeBase({ probed, envHome, homedir });
16
+ if (!sel.third) return sel.base;
17
+ try {
18
+ const out = String(await deps.runShell(deps.scripts.legacyHomeMigrateScript(homedir), { timeoutMs: 3e5, stdoutMaxBytes: 4096 })).trim();
19
+ if (out === "MIGRATE_OK" || out === "OLD_ABSENT") return sel.base;
20
+ deps.recordError(
21
+ out === "BOTH_PRESENT" ? "recall home store \u65B0\u65E7\u5BB9\u5668\u5E76\u5B58\uFF08" + homedir + "/dsh-recall-snapshots \u4E0E " + homedir + "/.dsh/dsh-recall-snapshots\uFF09\uFF0C\u6CBF\u7528\u65E7\u4F4D\uFF0C\u672A\u505A\u4EFB\u4F55\u6539\u52A8" : "recall \u65E7\u5FEB\u7167\u5BB9\u5668\u8FC1\u79FB\u5931\u8D25\uFF08MIGRATE_FAIL\uFF09\uFF0C\u6CBF\u7528\u65E7\u4F4D " + homedir + "/dsh-recall-snapshots"
22
+ );
23
+ return homedir;
24
+ } catch (error) {
25
+ deps.recordError("recall \u65E7\u5FEB\u7167\u5BB9\u5668\u8FC1\u79FB\u63A2\u6D4B\u5931\u8D25\uFF0C\u6CBF\u7528\u65E7\u4F4D: " + String(error));
26
+ return homedir;
27
+ }
28
+ }
29
+ function parseCleanupResult(out) {
30
+ const m = String(out || "").match(/CLEANUP_OTHER_INSTANCE\s+(\d+)/);
31
+ if (m) return { otherPid: parseInt(m[1], 10), skippedFresh: false };
32
+ if (String(out || "").indexOf("CLEANUP_SKIPPED_FRESH_LOCK") >= 0) return { otherPid: null, skippedFresh: true };
33
+ return { otherPid: null, skippedFresh: false };
34
+ }
35
+ function isTmpConsumedError(error, basename) {
36
+ const s = String(error || "");
37
+ if (!basename || s.indexOf(basename) < 0) return false;
38
+ return /No such file/i.test(s) || /does not exist/i.test(s) || /cannot find path/i.test(s);
39
+ }
40
+ function createRuntime(ctx, config) {
41
+ const shell = ctx.shell;
42
+ const sessions = ctx.sessions;
43
+ const isWin = process.platform === "win32";
44
+ const SEP = isWin ? "\\" : "/";
45
+ const scripts = isWin ? pwshScripts : posixScripts;
46
+ const state = {
47
+ roots: /* @__PURE__ */ new Map(),
48
+ stores: /* @__PURE__ */ new Map(),
49
+ snapshots: /* @__PURE__ */ new Map(),
50
+ queue: Promise.resolve(),
51
+ indexLoaded: /* @__PURE__ */ new Set(),
52
+ // PF-5 索引终态三/四档标记(rebuildOrphans 守卫的数据源):
53
+ // - indexHealthy:磁盘索引解析成功且在场(loadIndex 正常载入分支)——
54
+ // rebuildOrphans healthy 且条目非空的 root 整体跳过(省 1+N 条进程)
55
+ // - indexTruncated:读截断(F-G3,内存是残缺视图)——rebuildOrphans
56
+ // 必须跳过:否则全部 tag 被判孤儿、用残缺孤儿集覆盖完好的大索引
57
+ // (feedback 全丢、数万条索引按 win32 分块写下是数百条进程的灾难)
58
+ // empty(无索引文件)/quarantined(损坏隔离)不标记 → rebuild 照跑,
59
+ // 自愈链路完整
60
+ indexHealthy: /* @__PURE__ */ new Set(),
61
+ indexTruncated: /* @__PURE__ */ new Set(),
62
+ gitReady: /* @__PURE__ */ new Set(),
63
+ cutSeqCache: /* @__PURE__ */ new Map(),
64
+ homeRetryAt: /* @__PURE__ */ new Map(),
65
+ gcLastAt: /* @__PURE__ */ new Map(),
66
+ gcCount: /* @__PURE__ */ new Map(),
67
+ gitExe: null,
68
+ posixHomeBase: null,
69
+ homeContainer: null,
70
+ errors: [],
71
+ // 逐消息的快照反馈(issue #7 失败可见性):失败 {failed,error} 或
72
+ // fail-open 跳过 {skipped:[...]},由 snapshot-info 端点下发给客户端
73
+ // toast。放共享 state 而非 snapshots.js 闭包:端点在 index.js,
74
+ // 与索引/根缓存同层取用。
75
+ snapFeedback: /* @__PURE__ */ new Map()
76
+ };
77
+ function recordError(text) {
78
+ const message = String(text);
79
+ const last = state.errors[state.errors.length - 1];
80
+ if (last && last.message === message) {
81
+ last.time = Date.now();
82
+ last.count += 1;
83
+ return;
84
+ }
85
+ const rec = { time: Date.now(), message, count: 1, kind: classifyEnvError(message) };
86
+ state.errors.push(rec);
87
+ if (state.errors.length > ERROR_BUFFER_MAX) state.errors.splice(0, state.errors.length - ERROR_BUFFER_MAX);
88
+ console.error(message);
89
+ }
90
+ ;
91
+ (function checkScriptParity() {
92
+ const SKIP = /* @__PURE__ */ new Set(["homeDirScript", "probeHomeScript", "legacyHomeMigrateScript"]);
93
+ const pwshKeys = Object.keys(pwshScripts).filter((k) => !SKIP.has(k) && typeof pwshScripts[k] === "function");
94
+ const posixKeys = Object.keys(posixScripts).filter((k) => !SKIP.has(k) && typeof posixScripts[k] === "function");
95
+ const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0);
96
+ if (missing.length) recordError("recall script parity: posix \u7F3A\u5C11\u5BFC\u51FA " + missing.join(", "));
97
+ })();
98
+ async function runShellMeta(command, opts) {
99
+ const sp = ctx.get("sandboxPolicy");
100
+ const spec = shell.resolve({
101
+ // 编码前导:pwsh 侧统一 UTF-8 输出(中文机器 GBK 代码页不再乱码);
102
+ // bash LC_ALL=C 确定序。各模板自带,这里统一前置注入。
103
+ command: scripts.UTF8_PRELUDE + "\n" + command,
104
+ timeoutMs: opts && opts.timeoutMs || 3e5,
105
+ stdoutMaxBytes: opts && opts.stdoutMaxBytes || 4194304,
106
+ // stdin 是官方 ShellExecRequest 契约字段(bash-local/pwsh 均实现),
107
+ // POSIX 侧用它传 index.json 全文,绕开 argv 长度上限
108
+ ...opts && opts.stdin !== void 0 ? { stdin: opts.stdin } : {},
109
+ sandboxPolicy: { mode: "danger-full-access", workspaceRoot: sp && sp.workspaceRoot || process.cwd() }
110
+ });
111
+ const res = await shell.run(spec);
112
+ const out = res && res.stdout && res.stdout.text || "";
113
+ if (res && res.exitCode !== 0) {
114
+ await cleanupAfterGitFailure(command);
115
+ const err = (res && res.stderr && res.stderr.text || "").trim() || "exit " + String(res.exitCode);
116
+ throw new Error(err.slice(0, 1500));
117
+ }
118
+ return {
119
+ text: out,
120
+ truncated: Boolean(res && res.stdout && res.stdout.truncated)
121
+ };
122
+ }
123
+ async function runShell(command, opts) {
124
+ return (await runShellMeta(command, opts)).text;
125
+ }
126
+ function extractGitDir(command) {
127
+ const m = String(command).match(/(?:^|\n)[ \t]*(?:\$g|g)[ \t]*=[ \t]*'([^']+)/);
128
+ return m ? m[1] : null;
129
+ }
130
+ async function cleanupAfterGitFailure(command) {
131
+ if (!command || String(command).indexOf("RECALL_CLEANUP") >= 0) return;
132
+ const gitDir = extractGitDir(command);
133
+ if (!gitDir) return;
134
+ try {
135
+ const out = await runShell(scripts.killOrphansScript(gitDir), { timeoutMs: 6e4, stdoutMaxBytes: 4096 });
136
+ const r = parseCleanupResult(out);
137
+ if (r.otherPid !== null) recordError("recall \u68C0\u6D4B\u5230\u53E6\u4E00\u4E2A DSH \u5B9E\u4F8B\uFF08PID " + r.otherPid + "\uFF09\u6B63\u5728\u4F7F\u7528\u6B64\u5FEB\u7167\u5E93\uFF0C\u5931\u8D25\u6E05\u626B\u5DF2\u8BA9\u8DEF\uFF1A\u672A\u6740\u8FDB\u7A0B\u3001\u672A\u52A8\u9501");
138
+ else if (r.skippedFresh) recordError("recall \u68C0\u6D4B\u5230 5 \u5206\u949F\u5185\u7684\u65B0\u9501\u6587\u4EF6\uFF0C\u7591\u4F3C git \u64CD\u4F5C\u6B63\u5728\u8FDB\u884C\uFF0C\u5931\u8D25\u6E05\u626B\u5DF2\u8BA9\u8DEF\uFF08\u9501\u9648\u65E7\u540E\u4F1A\u81EA\u52A8\u6E05\u7406\uFF09");
139
+ } catch (error) {
140
+ }
141
+ }
142
+ async function resolveRoot(sessionId) {
143
+ const key = sessionId ? String(sessionId) : "fallback";
144
+ const cached = state.roots.get(key);
145
+ if (cached) return cached;
146
+ let root = null;
147
+ let authoritative = false;
148
+ if (sessionId) {
149
+ const session = sessions.get(sessionId);
150
+ if (session && session.header && session.header.cwd) {
151
+ root = session.header.cwd;
152
+ authoritative = true;
153
+ }
154
+ }
155
+ if (!root && sessionId) {
156
+ try {
157
+ const query = ctx.get("sessionQuery");
158
+ if (query && typeof query.listSessions === "function") {
159
+ const records = await query.listSessions();
160
+ const rec = (records || []).find((r) => r && r.header && r.header.id === sessionId);
161
+ if (rec && rec.header && rec.header.cwd) {
162
+ root = rec.header.cwd;
163
+ authoritative = true;
164
+ }
165
+ }
166
+ } catch (error) {
167
+ }
168
+ }
169
+ if (!root) {
170
+ const sp = ctx.get("sandboxPolicy");
171
+ if (sp && sp.workspaceRoot) root = sp.workspaceRoot;
172
+ }
173
+ if (root) {
174
+ root = root.replace(/[\\/]+$/, "") || (isWin ? root : "/");
175
+ if (isWin && root.length === 2) root += "\\";
176
+ if (authoritative) state.roots.set(key, root);
177
+ }
178
+ return root;
179
+ }
180
+ async function resolveGit() {
181
+ if (state.gitExe !== null) return state.gitExe;
182
+ try {
183
+ const path = scripts.stripBom(await runShell(scripts.resolveGitScript(), { stdoutMaxBytes: 4096 })).trim();
184
+ state.gitExe = path || "";
185
+ } catch (error) {
186
+ state.gitExe = "";
187
+ }
188
+ return state.gitExe;
189
+ }
190
+ async function homeDirForWin(root) {
191
+ const envHome = process.env && process.env.DSH_HOME || "";
192
+ const text = scripts.stripBom(await runShell(scripts.homeDirScript(root, envHome), { stdoutMaxBytes: 4096 })).trim();
193
+ if (!text) return null;
194
+ if (/^\\\\/.test(text)) return "\\\\" + text.slice(2).replace(/\\{2,}/g, "\\");
195
+ return text.replace(/\\{2,}/g, "\\");
196
+ }
197
+ async function posixHomeBaseResolve() {
198
+ if (state.posixHomeBase === null) {
199
+ let probed = "";
200
+ try {
201
+ probed = (await runShell(scripts.probeHomeScript(), { stdoutMaxBytes: 4096 })).trim();
202
+ } catch (error) {
203
+ probed = "";
204
+ }
205
+ state.posixHomeBase = await resolvePosixHomeBase(
206
+ { runShell, scripts, recordError },
207
+ { probed, envHome: process.env && process.env.DSH_HOME || "", homedir: os.homedir() }
208
+ );
209
+ }
210
+ return state.posixHomeBase;
211
+ }
212
+ async function homeDirForPosix(root) {
213
+ const base = await posixHomeBaseResolve();
214
+ const hash = crypto.createHash("sha256").update(root, "utf8").digest("hex");
215
+ return base.replace(/\/+$/, "") + "/dsh-recall-snapshots/" + hash;
216
+ }
217
+ async function homeDirFor(root) {
218
+ return isWin ? homeDirForWin(root) : homeDirForPosix(root);
219
+ }
220
+ async function resolveHomeContainer() {
221
+ if (state.homeContainer) return state.homeContainer;
222
+ let container = null;
223
+ try {
224
+ const probeRoot = Array.from(state.roots.values())[0] || process.cwd();
225
+ const homeDir = await homeDirFor(probeRoot);
226
+ if (homeDir) container = homeDir.slice(0, homeDir.length - 65);
227
+ } catch (error) {
228
+ container = null;
229
+ }
230
+ if (container) state.homeContainer = container;
231
+ return container;
232
+ }
233
+ function makeStore(dir, home) {
234
+ const excludeFile = home ? dir.slice(0, dir.lastIndexOf(SEP)) + SEP + "exclude.txt" : dir + SEP + "exclude.txt";
235
+ return {
236
+ dir,
237
+ repo: dir + SEP + "git",
238
+ git: dir + SEP + "git" + SEP + ".git",
239
+ home,
240
+ excludeFile,
241
+ get maxFileBytes() {
242
+ return config.maxFileBytes;
243
+ }
244
+ };
245
+ }
246
+ function storeFromDir(dir, home) {
247
+ return makeStore(dir, Boolean(home));
248
+ }
249
+ function persistRootHint(store, root) {
250
+ writeTextViaShell(store.dir + SEP + "root.txt", root).catch(() => {
251
+ });
252
+ }
253
+ async function resolveStore(root) {
254
+ const cached = state.stores.get(root);
255
+ if (cached) return cached;
256
+ let homeDir = null;
257
+ try {
258
+ homeDir = await homeDirFor(root);
259
+ } catch (error) {
260
+ homeDir = null;
261
+ }
262
+ if (homeDir) {
263
+ try {
264
+ await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 });
265
+ const store2 = makeStore(homeDir, true);
266
+ state.stores.set(root, store2);
267
+ persistRootHint(store2, root);
268
+ return store2;
269
+ } catch (error) {
270
+ recordError("recall home store unavailable, falling back to workspace: " + String(error));
271
+ }
272
+ }
273
+ const fallback = root + SEP + ".dsh-recall-snapshots";
274
+ await runShell(scripts.mkdirScript(fallback), { stdoutMaxBytes: 4096 });
275
+ const store = makeStore(fallback, false);
276
+ state.stores.set(root, store);
277
+ persistRootHint(store, root);
278
+ return store;
279
+ }
280
+ async function tryUpgradeToHome(root) {
281
+ const store = state.stores.get(root);
282
+ if (!store || store.home) return store || null;
283
+ const now = Date.now();
284
+ const last = state.homeRetryAt.get(root) || 0;
285
+ if (now - last < HOME_RETRY_MS) return store;
286
+ state.homeRetryAt.set(root, now);
287
+ let homeDir = null;
288
+ try {
289
+ homeDir = await homeDirFor(root);
290
+ } catch (error) {
291
+ homeDir = null;
292
+ }
293
+ if (!homeDir) return store;
294
+ try {
295
+ await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 });
296
+ await runShell(scripts.migrateScript(store.dir, homeDir), { timeoutMs: 3e5, stdoutMaxBytes: 4096 });
297
+ const upgraded = makeStore(homeDir, true);
298
+ state.stores.set(root, upgraded);
299
+ persistRootHint(upgraded, root);
300
+ state.gitReady.delete(store.git);
301
+ state.gcLastAt.delete(store.git);
302
+ state.gcCount.delete(store.git);
303
+ console.error("recall store upgraded to home:", root);
304
+ return upgraded;
305
+ } catch (error) {
306
+ recordError("recall home upgrade failed: " + String(error));
307
+ return store;
308
+ }
309
+ }
310
+ async function renameTmpQuietly(tmp, file) {
311
+ try {
312
+ await runShell(scripts.renameFileCmd(tmp, file), { stdoutMaxBytes: 4096 });
313
+ } catch (error) {
314
+ const basename = tmp.slice(tmp.lastIndexOf(SEP) + 1);
315
+ if (isTmpConsumedError(error, basename)) {
316
+ console.error("recall writeTextViaShell: " + basename + " \u5DF2\u88AB\u5E76\u53D1\u5199\u8005 rename \u6D88\u8D39\uFF0C\u89C6\u540C\u6210\u529F");
317
+ return;
318
+ }
319
+ throw error;
320
+ }
321
+ }
322
+ async function writeTextViaShell(file, text) {
323
+ const body = String(text == null ? "" : text);
324
+ const tmp = file + ".tmp";
325
+ await runShell(scripts.fileWriteStdinCmd(tmp), { stdin: body, stdoutMaxBytes: 4096 });
326
+ await renameTmpQuietly(tmp, file);
327
+ }
328
+ async function ensureGit(root, store) {
329
+ if (state.gitReady.has(store.git)) return { ok: true };
330
+ const gitExe = await resolveGit();
331
+ if (!gitExe) {
332
+ const error = "\u672A\u68C0\u6D4B\u5230 git CLI\uFF0C\u5FEB\u7167\u4E0D\u53EF\u7528";
333
+ recordError("recall ensureGit: " + error + "\uFF1A\u8BF7\u5B89\u88C5 git \u6216\u68C0\u67E5\u5176\u662F\u5426\u5728 PATH \u4E2D");
334
+ return { ok: false, error };
335
+ }
336
+ try {
337
+ const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe, config.baseExcludes), { stdoutMaxBytes: 4096 }));
338
+ state.gitReady.add(store.git);
339
+ const m = out.match(/GIT_OK\s+(\d+)/);
340
+ state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1e3 : Date.now());
341
+ return { ok: true };
342
+ } catch (error) {
343
+ recordError("recall ensureGit failed: " + String(error));
344
+ return { ok: false, error: String(error) };
345
+ }
346
+ }
347
+ const legacyCleaned = /* @__PURE__ */ new Set();
348
+ function cleanupLegacy(root) {
349
+ const store = state.stores.get(root);
350
+ if (!store || !store.home) return;
351
+ if (legacyCleaned.has(root)) return;
352
+ legacyCleaned.add(root);
353
+ runShell(scripts.legacyRmScript(root + SEP + ".dsh-recall-snapshots"), { timeoutMs: 12e4, stdoutMaxBytes: 4096 }).catch(() => {
354
+ });
355
+ }
356
+ return { state, isWin, scripts, recordError, runShell, runShellMeta, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, storeFromDir, tryUpgradeToHome, ensureGit, cleanupLegacy, cleanupAfterGitFailure };
357
+ }
358
+ export {
359
+ createRuntime,
360
+ isTmpConsumedError,
361
+ parseCleanupResult,
362
+ resolvePosixHomeBase,
363
+ selectPosixHomeBase
364
+ };