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/CHANGELOG.md +109 -0
- package/README.en.md +124 -118
- package/README.md +125 -119
- package/cordis.patch.yml +19 -11
- package/lib/client.js +780 -456
- package/lib/config.js +36 -0
- package/lib/index.js +541 -164
- package/lib/maintenance.js +120 -107
- package/lib/scripts.posix.js +366 -290
- package/lib/scripts.pwsh.js +432 -334
- package/lib/snapshots.js +214 -192
- package/lib/store.js +342 -249
- package/package.json +52 -57
package/lib/store.js
CHANGED
|
@@ -1,249 +1,342 @@
|
|
|
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
|
-
|
|
17
|
-
// home 不可写时迁移重试的节流间隔:避免每条消息都白试一次注定失败的迁移
|
|
18
|
-
const HOME_RETRY_MS = 300000
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
state.
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
} catch (error) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
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
|
+
|
|
17
|
+
// home 不可写时迁移重试的节流间隔:避免每条消息都白试一次注定失败的迁移
|
|
18
|
+
const HOME_RETRY_MS = 300000
|
|
19
|
+
|
|
20
|
+
// 最近错误环形缓冲容量:设置页排障用,20 条足够回溯一轮快照/gc 的失败
|
|
21
|
+
const ERROR_BUFFER_MAX = 20
|
|
22
|
+
|
|
23
|
+
export function createRuntime(ctx, config) {
|
|
24
|
+
const shell = ctx.shell
|
|
25
|
+
const sessions = ctx.sessions
|
|
26
|
+
|
|
27
|
+
const isWin = process.platform === 'win32'
|
|
28
|
+
const SEP = isWin ? '\\' : '/'
|
|
29
|
+
const scripts = isWin ? pwshScripts : posixScripts
|
|
30
|
+
|
|
31
|
+
const state = {
|
|
32
|
+
roots: new Map(),
|
|
33
|
+
stores: new Map(),
|
|
34
|
+
snapshots: new Map(),
|
|
35
|
+
queue: Promise.resolve(),
|
|
36
|
+
indexLoaded: new Set(),
|
|
37
|
+
gitReady: new Set(),
|
|
38
|
+
cutSeqCache: new Map(),
|
|
39
|
+
homeRetryAt: new Map(),
|
|
40
|
+
gcLastAt: new Map(),
|
|
41
|
+
gcCount: new Map(),
|
|
42
|
+
gitExe: null,
|
|
43
|
+
posixHomeBase: null,
|
|
44
|
+
homeContainer: null,
|
|
45
|
+
errors: []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 最近错误环形缓冲:Host 侧所有失败原本只进 console.error(宿主进程
|
|
49
|
+
// 日志,用户在页面上不可见),这里留最近 20 条经 /api/recall/status
|
|
50
|
+
// 下发给设置页展示。同时转发 console.error 保持原有宿主日志不变。
|
|
51
|
+
function recordError(text) {
|
|
52
|
+
const message = String(text)
|
|
53
|
+
state.errors.push({ time: Date.now(), message })
|
|
54
|
+
if (state.errors.length > ERROR_BUFFER_MAX) state.errors.splice(0, state.errors.length - ERROR_BUFFER_MAX)
|
|
55
|
+
console.error(message)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 两套脚本模板的「命令函数」同名导出是跨平台正确性的硬约束(store.js
|
|
59
|
+
// 按平台单选 rt.scripts,调用方统一 S.*):单侧漏导出只会在另一平台
|
|
60
|
+
// 用户机器上以「不是函数」的怪异方式暴雷。装配时比对一次。豁免项:
|
|
61
|
+
// 平台专属导出(homeDirScript 的 $h 链只在 pwsh 侧需要——POSIX 的 home
|
|
62
|
+
// 基底走 probeHomeScript + Node 侧推导;常量与转义工具不承载命令)。
|
|
63
|
+
;(function checkScriptParity() {
|
|
64
|
+
// fileWriteCmd 仅 pwsh 版存在:POSIX 的文本落盘走 stdin(store.js
|
|
65
|
+
// writeTextViaShell 的 POSIX 分支不经命令行传参),不需要该模板函数
|
|
66
|
+
const SKIP = new Set(['homeDirScript', 'probeHomeScript', 'fileWriteCmd'])
|
|
67
|
+
const pwshKeys = Object.keys(pwshScripts).filter((k) => !SKIP.has(k) && typeof pwshScripts[k] === 'function')
|
|
68
|
+
const posixKeys = Object.keys(posixScripts).filter((k) => !SKIP.has(k) && typeof posixScripts[k] === 'function')
|
|
69
|
+
const missing = pwshKeys.filter((k) => posixKeys.indexOf(k) < 0)
|
|
70
|
+
if (missing.length) recordError('recall script parity: posix 缺少导出 ' + missing.join(', '))
|
|
71
|
+
})()
|
|
72
|
+
|
|
73
|
+
// 所有 shell 调用都以宿主身份(danger-full-access)执行,不借用会话沙箱。
|
|
74
|
+
// 为什么安全:DSH 沙箱约束的是「模型驱动」的文件效果,而本插件的命令全部
|
|
75
|
+
// 是宿主侧固定模板(建仓/快照/索引/回退),命令串里唯一变量是插件自己
|
|
76
|
+
// 推导的路径(会话 cwd、哈希出的 store 路径、消息 ID),模型无法注入任何
|
|
77
|
+
// 内容;快照落盘的也只是会话本就有权读取的工作区文件副本,不扩大能力。
|
|
78
|
+
// 为什么必须如此:若按会话解析策略,workspace-write/read-only 会话写不了
|
|
79
|
+
// home,快照被迫降级进项目目录(污染);read-only 会话连项目都写不了,
|
|
80
|
+
// 回退恢复直接失败。pwsh-sandbox / bash-sandbox 对 danger-full-access
|
|
81
|
+
// 直接不约束(等价本地执行器),无沙箱后端的部署则忽略该字段,两边都成立。
|
|
82
|
+
async function runShell(command, opts) {
|
|
83
|
+
const sp = ctx.get('sandboxPolicy')
|
|
84
|
+
const spec = shell.resolve({
|
|
85
|
+
// 编码前导:pwsh 侧统一 UTF-8 输出(中文机器 GBK 代码页不再乱码);
|
|
86
|
+
// bash 侧 LC_ALL=C 确定序。各模板自带,这里统一前置注入。
|
|
87
|
+
command: scripts.UTF8_PRELUDE + '\n' + command,
|
|
88
|
+
timeoutMs: (opts && opts.timeoutMs) || 300000,
|
|
89
|
+
stdoutMaxBytes: (opts && opts.stdoutMaxBytes) || 4194304,
|
|
90
|
+
// stdin 是官方 ShellExecRequest 契约字段(bash-local/pwsh 均实现),
|
|
91
|
+
// POSIX 侧用它传 index.json 全文,绕开 argv 长度上限
|
|
92
|
+
...((opts && opts.stdin !== undefined) ? { stdin: opts.stdin } : {}),
|
|
93
|
+
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: (sp && sp.workspaceRoot) || process.cwd() }
|
|
94
|
+
})
|
|
95
|
+
const res = await shell.run(spec)
|
|
96
|
+
const out = (res && res.stdout && res.stdout.text) || ''
|
|
97
|
+
if (res && res.exitCode !== 0) {
|
|
98
|
+
const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
|
|
99
|
+
throw new Error(err.slice(0, 1500))
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function resolveRoot(sessionId) {
|
|
105
|
+
const key = sessionId ? String(sessionId) : 'fallback'
|
|
106
|
+
const cached = state.roots.get(key)
|
|
107
|
+
if (cached) return cached
|
|
108
|
+
let root = null
|
|
109
|
+
if (sessionId) {
|
|
110
|
+
const session = sessions.get(sessionId)
|
|
111
|
+
if (session && session.header && session.header.cwd) root = session.header.cwd
|
|
112
|
+
}
|
|
113
|
+
if (!root) {
|
|
114
|
+
const sp = ctx.get('sandboxPolicy')
|
|
115
|
+
if (sp && sp.workspaceRoot) root = sp.workspaceRoot
|
|
116
|
+
}
|
|
117
|
+
if (root) {
|
|
118
|
+
// 尾分隔符归一(win32 保 "D:\" 三字符盘根;POSIX 保 "/" 根):
|
|
119
|
+
// cwd 是否带尾斜杠由上游决定,不归一会让哈希输入不一致(换 store
|
|
120
|
+
// 目录),也会让排除扫描的 ${f#"$root"/} 前缀剥离错一位。
|
|
121
|
+
root = root.replace(/[\\/]+$/, '') || (isWin ? root : '/')
|
|
122
|
+
if (isWin && root.length === 2) root += '\\'
|
|
123
|
+
state.roots.set(key, root)
|
|
124
|
+
}
|
|
125
|
+
return root
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 解析 git 可执行文件路径:求值一次并缓存,脚本里用绝对路径调用,
|
|
129
|
+
// 避免每条命令依赖 PATH(DSH 进程 PATH 可能不含 git)。
|
|
130
|
+
async function resolveGit() {
|
|
131
|
+
if (state.gitExe !== null) return state.gitExe
|
|
132
|
+
try {
|
|
133
|
+
const path = scripts.stripBom(await runShell(scripts.resolveGitScript(), { stdoutMaxBytes: 4096 })).trim()
|
|
134
|
+
state.gitExe = path || ''
|
|
135
|
+
} catch (error) {
|
|
136
|
+
state.gitExe = ''
|
|
137
|
+
}
|
|
138
|
+
return state.gitExe
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// win32:哈希在 PowerShell 里算(SHA256 Create 兼容 PS 5.1),连带
|
|
142
|
+
// $env:DSH_HOME / $env:USERPROFILE 的解析都在 shell 侧完成。
|
|
143
|
+
async function homeDirForWin(root) {
|
|
144
|
+
const envHome = (process.env && process.env.DSH_HOME) || ''
|
|
145
|
+
const text = scripts.stripBom(await runShell(scripts.homeDirScript(root, envHome), { stdoutMaxBytes: 4096 })).trim()
|
|
146
|
+
if (!text) return null
|
|
147
|
+
// 折叠 Join-Path 可能带出的连续反斜杠;开头的双反斜杠是 UNC 前缀
|
|
148
|
+
// (DSH_HOME/主目录指到网络盘),折叠掉会把 \\server\share 变成无效
|
|
149
|
+
// 的 \server\share,必须原样保留。
|
|
150
|
+
if (/^\\\\/.test(text)) return '\\\\' + text.slice(2).replace(/\\{2,}/g, '\\')
|
|
151
|
+
return text.replace(/\\{2,}/g, '\\')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// POSIX:shell 侧只探 bash env 里显式的 $DSH_HOME(DSH 执行器洗刷
|
|
155
|
+
// DSH_* 变量后通常为空);为空时依次回退 Node 主进程的 DSH_HOME
|
|
156
|
+
// (宿主进程 env,用户导出可见)与 os.homedir()。哈希用 Node crypto
|
|
157
|
+
// 统一算,规避 Linux sha256sum / macOS shasum 的二选一移植成本。
|
|
158
|
+
async function posixHomeBaseResolve() {
|
|
159
|
+
if (state.posixHomeBase === null) {
|
|
160
|
+
let probed = ''
|
|
161
|
+
try {
|
|
162
|
+
probed = (await runShell(scripts.probeHomeScript(), { stdoutMaxBytes: 4096 })).trim()
|
|
163
|
+
} catch (error) {
|
|
164
|
+
probed = ''
|
|
165
|
+
}
|
|
166
|
+
state.posixHomeBase = probed || process.env.DSH_HOME || os.homedir()
|
|
167
|
+
}
|
|
168
|
+
return state.posixHomeBase
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function homeDirForPosix(root) {
|
|
172
|
+
const base = await posixHomeBaseResolve()
|
|
173
|
+
const hash = crypto.createHash('sha256').update(root, 'utf8').digest('hex')
|
|
174
|
+
return base.replace(/\/+$/, '') + '/dsh-recall-snapshots/' + hash
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function homeDirFor(root) {
|
|
178
|
+
return isWin ? homeDirForWin(root) : homeDirForPosix(root)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 快照容器目录(<homeBase>/dsh-recall-snapshots,不含哈希子目录):
|
|
182
|
+
// 设置页 exclude-get 的磁盘兜底用——冷启动时会话注册表为空(惰性
|
|
183
|
+
// 载入),但容器目录可能早已存在,此时共享 exclude.txt 仍应可编辑。
|
|
184
|
+
// 目录结构固定 <base>/dsh-recall-snapshots/<hash>,所以容器就是
|
|
185
|
+
// homeDirFor 结果的父目录:JS 侧 slice 推导,不再走第二条 shell 解析链
|
|
186
|
+
// (旧实现里 homeDirScript 与 homeContainerScript 的 $h 链靠注释人工
|
|
187
|
+
// 对齐,存在漂移风险)。失败返回 null 且不缓存,下次调用自然重试。
|
|
188
|
+
async function resolveHomeContainer() {
|
|
189
|
+
if (state.homeContainer) return state.homeContainer
|
|
190
|
+
let container = null
|
|
191
|
+
try {
|
|
192
|
+
const probeRoot = Array.from(state.roots.values())[0] || process.cwd()
|
|
193
|
+
const homeDir = await homeDirFor(probeRoot)
|
|
194
|
+
if (homeDir) container = homeDir.slice(0, homeDir.length - 65)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
container = null
|
|
197
|
+
}
|
|
198
|
+
if (container) state.homeContainer = container
|
|
199
|
+
return container
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// store 形态装配:exclude.txt 是用户自定义排除文件,home 存储时放在
|
|
203
|
+
// dsh-recall-snapshots 根(所有项目共享一份全局配置);降级存储时放
|
|
204
|
+
// store 目录内部——降级目录本身已被排除规则覆盖,不再往项目根塞文件。
|
|
205
|
+
// git init <dir> 会把真实 git-dir 建在 <dir>/.git,所以 repo 是仓库
|
|
206
|
+
// 工作目录、git 是真实 git-dir——冒烟测试踩过的坑。
|
|
207
|
+
// maxFileBytes 从 config 注入 store:脚本模板(snapshot/diff/rollback
|
|
208
|
+
// 的超大文件剔除)按调用时从 store 读取,用户改 config 后下一条命令
|
|
209
|
+
// 即生效,无需重启。
|
|
210
|
+
function makeStore(dir, home) {
|
|
211
|
+
const excludeFile = home
|
|
212
|
+
? dir.slice(0, dir.lastIndexOf(SEP)) + SEP + 'exclude.txt'
|
|
213
|
+
: dir + SEP + 'exclude.txt'
|
|
214
|
+
return { dir, repo: dir + SEP + 'git', git: dir + SEP + 'git' + SEP + '.git', home, excludeFile, maxFileBytes: config.maxFileBytes }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// store 级元数据 root.txt:内容为工作区绝对路径。store 目录名是 root 的
|
|
218
|
+
// 单向 SHA256,反解不了——「快照管理」跨工作区展示时靠它把哈希目录映射
|
|
219
|
+
// 回工作区名。best-effort(失败不阻断主流程),旧 store 在 resolveStore
|
|
220
|
+
// 再次被调用(重启后首个 init/快照/管理列表)时自然补写,存量自愈。
|
|
221
|
+
function persistRootHint(store, root) {
|
|
222
|
+
writeTextViaShell(store.dir + SEP + 'root.txt', root).catch(() => {})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 存储根:优先放 DSH home(保持项目目录干净)。shell 以宿主身份执行,
|
|
226
|
+
// 受限会话(workspace-write/read-only)也能写 home;只有 home 本身不可写
|
|
227
|
+
// (如 DSH_HOME 指向只读/网络盘)才降级到项目内(功能优先于干净)。
|
|
228
|
+
async function resolveStore(root) {
|
|
229
|
+
const cached = state.stores.get(root)
|
|
230
|
+
if (cached) return cached
|
|
231
|
+
let homeDir = null
|
|
232
|
+
try {
|
|
233
|
+
homeDir = await homeDirFor(root)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
homeDir = null
|
|
236
|
+
}
|
|
237
|
+
if (homeDir) {
|
|
238
|
+
try {
|
|
239
|
+
await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
|
|
240
|
+
const store = makeStore(homeDir, true)
|
|
241
|
+
state.stores.set(root, store)
|
|
242
|
+
persistRootHint(store, root)
|
|
243
|
+
return store
|
|
244
|
+
} catch (error) {
|
|
245
|
+
recordError('recall home store unavailable, falling back to workspace: ' + String(error))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const fallback = root + SEP + '.dsh-recall-snapshots'
|
|
249
|
+
await runShell(scripts.mkdirScript(fallback), { stdoutMaxBytes: 4096 })
|
|
250
|
+
const store = makeStore(fallback, false)
|
|
251
|
+
state.stores.set(root, store)
|
|
252
|
+
persistRootHint(store, root)
|
|
253
|
+
return store
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 旧版迁移:宿主身份执行前的版本在受限会话里会把影子仓库降级到项目内,
|
|
257
|
+
// 这里在下一条消息快照前把它整体迁回 home 并删除项目内目录,恢复
|
|
258
|
+
// 「项目目录干净」。失败节流 5 分钟,避免 home 不可写时每条消息白试。
|
|
259
|
+
async function tryUpgradeToHome(root) {
|
|
260
|
+
const store = state.stores.get(root)
|
|
261
|
+
if (!store || store.home) return store
|
|
262
|
+
const now = Date.now()
|
|
263
|
+
const last = state.homeRetryAt.get(root) || 0
|
|
264
|
+
if (now - last < HOME_RETRY_MS) return store
|
|
265
|
+
state.homeRetryAt.set(root, now)
|
|
266
|
+
let homeDir = null
|
|
267
|
+
try {
|
|
268
|
+
homeDir = await homeDirFor(root)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
homeDir = null
|
|
271
|
+
}
|
|
272
|
+
if (!homeDir) return store
|
|
273
|
+
try {
|
|
274
|
+
await runShell(scripts.mkdirScript(homeDir), { stdoutMaxBytes: 4096 })
|
|
275
|
+
await runShell(scripts.migrateScript(store.dir, homeDir), { timeoutMs: 300000, stdoutMaxBytes: 4096 })
|
|
276
|
+
const upgraded = makeStore(homeDir, true)
|
|
277
|
+
state.stores.set(root, upgraded)
|
|
278
|
+
persistRootHint(upgraded, root)
|
|
279
|
+
state.gitReady.delete(store.git)
|
|
280
|
+
// 旧 store 的 gc 节流凭据随之作废,清掉避免新 store 误读
|
|
281
|
+
state.gcLastAt.delete(store.git)
|
|
282
|
+
state.gcCount.delete(store.git)
|
|
283
|
+
console.error('recall store upgraded to home:', root)
|
|
284
|
+
return upgraded
|
|
285
|
+
} catch (error) {
|
|
286
|
+
recordError('recall home upgrade failed: ' + String(error))
|
|
287
|
+
return store
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 任意长度文本落盘(index.json / exclude.txt 共用):win32 走 base64
|
|
292
|
+
// 分块内联(每块 20000 字符,规避 Windows 命令行 32767 上限——DSH 的
|
|
293
|
+
// pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn,快照攒到
|
|
294
|
+
// 几百条就超限),首块覆盖、续块追加;POSIX 用官方 ShellExecRequest
|
|
295
|
+
// 的 stdin 契约字段直写全文,不经命令行传参,天然没有 argv 上限。
|
|
296
|
+
// 空内容也落一次写(清空配置/空索引是合法状态),所以 base64 为空串
|
|
297
|
+
// 时仍发一块空 piece,而不是整段跳过留下旧文件。
|
|
298
|
+
async function writeTextViaShell(file, text) {
|
|
299
|
+
const body = String(text == null ? '' : text)
|
|
300
|
+
if (isWin) {
|
|
301
|
+
const b64 = Buffer.from(body, 'utf8').toString('base64')
|
|
302
|
+
const chunks = b64 ? b64.match(/.{1,20000}/g) : ['']
|
|
303
|
+
let first = true
|
|
304
|
+
for (const chunk of chunks) {
|
|
305
|
+
const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + chunk + "')) | "
|
|
306
|
+
await runShell(scripts.fileWriteCmd(file, piece, first), { stdoutMaxBytes: 4096 })
|
|
307
|
+
first = false
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
await runShell('cat > ' + scripts.psq(file), { stdin: body, stdoutMaxBytes: 4096 })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 建立影子仓库(幂等:gitReady 命中后直接跳过,省掉每条消息一次的
|
|
315
|
+
// config/exclude 重写)。同时回读 gc.stamp 种子化 gc 节流:让「上次 gc
|
|
316
|
+
// 时间」跨重启续存,避免天天重启的机器每开机都来一次全量 gc。
|
|
317
|
+
async function ensureGit(root, store) {
|
|
318
|
+
if (state.gitReady.has(store.git)) return true
|
|
319
|
+
const gitExe = await resolveGit()
|
|
320
|
+
if (!gitExe) return false
|
|
321
|
+
try {
|
|
322
|
+
const out = scripts.stripBom(await runShell(scripts.ensureGitScript(store, gitExe, config.baseExcludes), { stdoutMaxBytes: 4096 }))
|
|
323
|
+
state.gitReady.add(store.git)
|
|
324
|
+
const m = out.match(/GIT_OK\s+(\d+)/)
|
|
325
|
+
state.gcLastAt.set(store.git, m ? parseInt(m[1], 10) * 1000 : Date.now())
|
|
326
|
+
return true
|
|
327
|
+
} catch (error) {
|
|
328
|
+
recordError('recall ensureGit failed: ' + String(error))
|
|
329
|
+
return false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 迁移收尾:删除旧版 blobs 格式的项目内 .dsh-recall-snapshots 目录,
|
|
334
|
+
// 仅在 home 存储可用时执行——降级场景下该目录就是新 store,不能删。
|
|
335
|
+
function cleanupLegacy(root) {
|
|
336
|
+
const store = state.stores.get(root)
|
|
337
|
+
if (!store || !store.home) return
|
|
338
|
+
runShell(scripts.legacyRmScript(root + SEP + '.dsh-recall-snapshots'), { timeoutMs: 120000, stdoutMaxBytes: 4096 }).catch(() => {})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { state, isWin, scripts, recordError, runShell, writeTextViaShell, resolveRoot, resolveGit, homeDirFor, resolveHomeContainer, resolveStore, tryUpgradeToHome, ensureGit, cleanupLegacy }
|
|
342
|
+
}
|