dsh-recall-plugin 1.0.3 → 1.2.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/README.md +25 -7
- package/lib/client.js +11 -1
- package/lib/index.js +50 -588
- package/lib/maintenance.js +107 -0
- package/lib/scripts.posix.js +290 -0
- package/lib/scripts.pwsh.js +334 -0
- package/lib/snapshots.js +192 -0
- package/lib/store.js +249 -0
- package/package.json +2 -3
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — PowerShell 脚本模板(纯函数,无 ctx 依赖,win32 专用)
|
|
3
|
+
*
|
|
4
|
+
* 职责:集中拼装所有发给 shell 的 PowerShell 脚本文本。只做字符串构造,
|
|
5
|
+
* 不执行、无状态;执行侧(runShell)见 store.js,调用侧见
|
|
6
|
+
* snapshots.js / maintenance.js。集中在这里是为了:
|
|
7
|
+
* 1) PS 5.1 / pwsh 7 双版本兼容的坑只在一处处理;
|
|
8
|
+
* 2) 脚本片段(gitlink 清理、超大文件排除、用户排除同步)在多个
|
|
9
|
+
* 流程里逐字复用,散落各处必然改漏。
|
|
10
|
+
* POSIX(Linux/macOS)对应模板见 scripts.posix.js,两者导出同名接口,
|
|
11
|
+
* 由 store.js 按 process.platform 选择。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// 单引号字面量转义:PS 单引号串里只有 '' 表示一个单引号,且不展开变量,
|
|
15
|
+
// 是把 JS 值安全嵌进命令串的唯一可靠方式(杜绝 $、反引号注入)。
|
|
16
|
+
export function psq(value) {
|
|
17
|
+
return "'" + String(value).replace(/'/g, "''") + "'"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 统一 UTF-8 输出前导:中文等非 ASCII 机器的默认代码页(如 GBK)下,
|
|
21
|
+
// PowerShell 重定向 stdout 按 [Console]::OutputEncoding 编码,而 DSH 按
|
|
22
|
+
// UTF-8 解码——不强制时含中文的用户名/路径会变乱码。PS 5.1 / 7 均支持。
|
|
23
|
+
export const UTF8_PRELUDE = '$OutputEncoding = [Text.UTF8Encoding]::new($false)\ntry { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch {}'
|
|
24
|
+
|
|
25
|
+
// 与 TraeWork 同级的超大文件跳过阈值:git 对象库对大文件极不友好,
|
|
26
|
+
// 回退语义也不该被一个 200MB 的构建产物拖垮。
|
|
27
|
+
export const MAX_FILE_BYTES = 104857600
|
|
28
|
+
|
|
29
|
+
// 去除 PS 5.1 Set-Content -Encoding utf8 写出的 BOM:JSON 解析前必须剥掉,
|
|
30
|
+
// 否则 JSON.parse 把 BOM 当正文首字符直接抛错。
|
|
31
|
+
export function stripBom(text) {
|
|
32
|
+
return text.replace(/^\uFEFF/, '')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 嵌套 git 仓库(工作区里的子项目自带 .git)会被 add -A 记成 gitlink(160000);
|
|
36
|
+
// gitlink 残留在 index 时 add -A 会 fatal "in unpopulated submodule",
|
|
37
|
+
// 且 gitlink 对文件回退毫无意义——所以 add 前后各清一次,子仓库内容不进快照。
|
|
38
|
+
// 依赖外层脚本已定义的 $git/$g;被 snapshot/diff/rollback 三处复用。
|
|
39
|
+
function dropGitlinksBlock() {
|
|
40
|
+
return [
|
|
41
|
+
"& $git --git-dir=$g ls-files --stage | Where-Object { $_ -like '160000*' } | ForEach-Object {",
|
|
42
|
+
" $p = ($_ -split \"`t\")[1]",
|
|
43
|
+
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $p',
|
|
44
|
+
'}'
|
|
45
|
+
].join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 剔除超大文件:扫描加 SilentlyContinue 是因为 EAP=Stop 下个别不可访问
|
|
49
|
+
// 子目录(杀软锁定、异常 ACL、损坏 junction)的非致命错误会被升级为终止,
|
|
50
|
+
// 整条快照作废;本扫描只用于排除超大文件,漏看个别文件是 fail-open,可接受。
|
|
51
|
+
// 依赖外层已定义的 $git/$g/$root。
|
|
52
|
+
function oversizeBlock() {
|
|
53
|
+
return [
|
|
54
|
+
'Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Length -gt ' + MAX_FILE_BYTES + ' } | ForEach-Object {',
|
|
55
|
+
" $rel = $_.FullName.Substring($root.Length + 1).Replace('\\','/')",
|
|
56
|
+
' & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $rel',
|
|
57
|
+
'}'
|
|
58
|
+
].join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 用户自定义排除同步:把基础排除表与用户 exclude.txt 合并重写进 info/exclude,
|
|
62
|
+
// 再用 ls-files -i -c 找出「已被跟踪但命中排除」的条目从 index 清掉。
|
|
63
|
+
// - 只用 --exclude-from 指 info/exclude,不用 --exclude-standard:后者会
|
|
64
|
+
// 连带项目自己的 .gitignore 语义(后加 ignore 的已跟踪文件会被悄悄移出
|
|
65
|
+
// 快照),行为超出用户配置的本意。
|
|
66
|
+
// - 放在 add -A 之前:排除表先生效,新增的排除路径根本不会被暂存,
|
|
67
|
+
// 已跟踪的旧条目由 ls-files -i -c 补刀,两条路径一次覆盖。
|
|
68
|
+
// - 首行留空元素吸收 PS 5.1 utf8 BOM(BOM 粘在首行会废掉第一条模式),
|
|
69
|
+
// 与下方 ensureGitScript 的老技巧一致。
|
|
70
|
+
// - 依赖外层已定义的 $git/$g;被 ensureGit/snapshot/diff/rollback 复用,
|
|
71
|
+
// 因此 exclude.txt 的改动在下一次快照/diff/回退时即时生效,无需重启。
|
|
72
|
+
function excludeSyncBlock(excludeFile) {
|
|
73
|
+
return [
|
|
74
|
+
"$exFile = " + psq(excludeFile),
|
|
75
|
+
'$userPats = @()',
|
|
76
|
+
"if (Test-Path -LiteralPath $exFile) { $userPats = @(Get-Content -LiteralPath $exFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $t = $_.Trim(); $t -and -not $t.StartsWith('#') }) }",
|
|
77
|
+
"$lines = @('') + @('.git','node_modules/','.dsh-recall-snapshots/') + $userPats",
|
|
78
|
+
"$exc = Join-Path $g 'info\\exclude'",
|
|
79
|
+
'Set-Content -LiteralPath $exc -Value $lines -Encoding utf8',
|
|
80
|
+
'& $git -c core.quotePath=false --literal-pathspecs --git-dir=$g ls-files -i -c --exclude-from=$exc | ForEach-Object {',
|
|
81
|
+
' if ($_) { & $git --literal-pathspecs --git-dir=$g update-index --force-remove -- $_ }',
|
|
82
|
+
'}'
|
|
83
|
+
].join('\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,脚本里用绝对路径调用。
|
|
87
|
+
// 逐项判空再 Join-Path:个别 env 在特殊环境(32 位系统无 ProgramFiles(x86))
|
|
88
|
+
// 取到 null,EAP=Stop 下 Join-Path 抛错会让整个探测失败、误报 gitMissing。
|
|
89
|
+
export function resolveGitScript() {
|
|
90
|
+
return [
|
|
91
|
+
'$candidates = @()',
|
|
92
|
+
'$g = (Get-Command git -ErrorAction SilentlyContinue).Source',
|
|
93
|
+
'if ($g) { $candidates += $g }',
|
|
94
|
+
"if (${env:ProgramFiles}) { $candidates += (Join-Path ${env:ProgramFiles} 'Git\\cmd\\git.exe') }",
|
|
95
|
+
"if (${env:ProgramFiles(x86)}) { $candidates += (Join-Path ${env:ProgramFiles(x86)} 'Git\\cmd\\git.exe') }",
|
|
96
|
+
"if (${env:LocalAppData}) { $candidates += (Join-Path ${env:LocalAppData} 'Programs\\Git\\cmd\\git.exe') }",
|
|
97
|
+
"$g = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -First 1",
|
|
98
|
+
'if ($g) { Write-Output $g }'
|
|
99
|
+
].join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 计算项目对应的 home 存储目录(DSH_HOME 优先,否则 ~/.dsh)。
|
|
103
|
+
// 哈希用 Create()+ComputeHash+BitConverter 而不是 HashData+ToHexString:
|
|
104
|
+
// 后两者是 .NET 5+(仅 PS 7)API,别人机器的 shell 若是 Windows PowerShell
|
|
105
|
+
// 5.1 会抛错,导致 home 存储永远降级到项目内;前者两个版本都可用。
|
|
106
|
+
export function homeDirScript(root, envHome) {
|
|
107
|
+
return [
|
|
108
|
+
'$r = ' + psq(root),
|
|
109
|
+
"$h = if ($env:DSH_HOME) { $env:DSH_HOME } elseif (" + psq(envHome) + ") { " + psq(envHome) + " } else { Join-Path $env:USERPROFILE \".dsh\" }",
|
|
110
|
+
'$sha = [Security.Cryptography.SHA256]::Create()',
|
|
111
|
+
"$hex = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($r))) -replace '-','').ToLower()",
|
|
112
|
+
"Write-Output (Join-Path $h ('dsh-recall-snapshots\\' + $hex))"
|
|
113
|
+
].join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function mkdirScript(dir) {
|
|
117
|
+
return 'New-Item -ItemType Directory -Force -Path ' + psq(dir) + ' | Out-Null'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 旧版迁移:把降级时代落在项目内的影子仓库整体搬回 home 并删源目录
|
|
121
|
+
export function migrateScript(src, dst) {
|
|
122
|
+
return [
|
|
123
|
+
"$ErrorActionPreference = 'Stop'",
|
|
124
|
+
'$src = ' + psq(src),
|
|
125
|
+
'$dst = ' + psq(dst),
|
|
126
|
+
"if (Test-Path -LiteralPath (Join-Path $src 'git')) { Move-Item -LiteralPath (Join-Path $src 'git') -Destination (Join-Path $dst 'git') -Force }",
|
|
127
|
+
"if (Test-Path -LiteralPath (Join-Path $src 'index.json')) { Move-Item -LiteralPath (Join-Path $src 'index.json') -Destination (Join-Path $dst 'index.json') -Force }",
|
|
128
|
+
'Remove-Item -Recurse -Force -LiteralPath $src -ErrorAction SilentlyContinue',
|
|
129
|
+
"Write-Output 'MIGRATE_OK'"
|
|
130
|
+
].join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 建立影子仓库:普通 init(index 留在仓库内跨快照复用,git add 的 stat 缓存
|
|
134
|
+
// 让未变文件近乎零成本),core.longpaths 放开 Windows 深路径。
|
|
135
|
+
// autocrlf=false:按原始字节入快照(回退时逐字节还原),也避免用户全局
|
|
136
|
+
// autocrlf=true 时的 LF/CRLF stderr 警告;addEmbeddedRepo=false:嵌套仓库
|
|
137
|
+
// hint/warning 走 stderr,在 DSH shell(EAP=Stop)下会让整条脚本非零退出,
|
|
138
|
+
// 必须在仓库级配置里静默掉。
|
|
139
|
+
// 结尾回读 gc.stamp(maintenance.js 上次 gc 时间戳):让重启后的 gc 节流
|
|
140
|
+
// 不归零——没有它,天天重启 DSH 的用户每次开机第一条消息都会触发一次
|
|
141
|
+
// 全量 gc,纯浪费。
|
|
142
|
+
export function ensureGitScript(store, gitExe) {
|
|
143
|
+
return [
|
|
144
|
+
"$ErrorActionPreference = 'Stop'",
|
|
145
|
+
'$git = ' + psq(gitExe),
|
|
146
|
+
'$repo = ' + psq(store.repo),
|
|
147
|
+
'$g = ' + psq(store.git),
|
|
148
|
+
'if (-not (Test-Path -LiteralPath $g)) {',
|
|
149
|
+
' & $git init $repo | Out-Null',
|
|
150
|
+
'}',
|
|
151
|
+
'& $git --git-dir=$g config core.longpaths true',
|
|
152
|
+
'& $git --git-dir=$g config core.autocrlf false',
|
|
153
|
+
'& $git --git-dir=$g config advice.addEmbeddedRepo false',
|
|
154
|
+
excludeSyncBlock(store.excludeFile),
|
|
155
|
+
"$stamp = Join-Path $g 'gc.stamp'",
|
|
156
|
+
"if (Test-Path -LiteralPath $stamp) { Write-Output ('GIT_OK ' + [String](Get-Content -LiteralPath $stamp -TotalCount 1 -ErrorAction SilentlyContinue)) } else { Write-Output 'GIT_OK' }"
|
|
157
|
+
].join('\n')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 快照:git add -A 增量同步 index(.gitignore/exclude 语义由 git 统一处理),
|
|
161
|
+
// write-tree 生成树、commit-tree 生成无父孤儿提交、tag 保对象可达。
|
|
162
|
+
// 不做 parent 链、不修剪:像 TraeWork 一样保留全量历史,tag 永远可查。
|
|
163
|
+
export function snapshotScript(root, store, gitExe, messageId) {
|
|
164
|
+
return [
|
|
165
|
+
"$ErrorActionPreference = 'Stop'",
|
|
166
|
+
'$git = ' + psq(gitExe),
|
|
167
|
+
'$g = ' + psq(store.git),
|
|
168
|
+
'$root = ' + psq(root),
|
|
169
|
+
dropGitlinksBlock(),
|
|
170
|
+
excludeSyncBlock(store.excludeFile),
|
|
171
|
+
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
172
|
+
dropGitlinksBlock(),
|
|
173
|
+
oversizeBlock(),
|
|
174
|
+
'$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
|
|
175
|
+
"$commit = (& $git --git-dir=$g -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree $tree -m ('snapshot ' + " + psq(messageId) + ")).Trim()",
|
|
176
|
+
'& $git --git-dir=$g tag ' + psq('snap-' + messageId) + ' $commit | Out-Null',
|
|
177
|
+
"Write-Output 'SNAP_OK'"
|
|
178
|
+
].join('\n')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// diff:把当前状态 add 进 index 后用 ls-files --stage 取当前清单,
|
|
182
|
+
// 与目标 tag 的 ls-tree 对比——ignore/exclude 语义两侧一致,不会把
|
|
183
|
+
// node_modules 等误报为“新增”。
|
|
184
|
+
// 不用 -z:PowerShell 捕获原生命令输出会丢弃含 NUL 的行(实测整段变 null),
|
|
185
|
+
// 改用 core.quotePath=false 让非 ASCII 路径原样输出,逐行按 TAB 解析。
|
|
186
|
+
// 代价是文件名含换行的极端情况会解析错乱——概率可忽略,记录为已知限制。
|
|
187
|
+
// (UTF-8 输出编码由 runShell 注入的 UTF8_PRELUDE 统一保证,此处不再重复设置。)
|
|
188
|
+
export function diffScript(root, store, gitExe, tag) {
|
|
189
|
+
return [
|
|
190
|
+
"$ErrorActionPreference = 'Stop'",
|
|
191
|
+
'$git = ' + psq(gitExe),
|
|
192
|
+
'$g = ' + psq(store.git),
|
|
193
|
+
'$root = ' + psq(root),
|
|
194
|
+
dropGitlinksBlock(),
|
|
195
|
+
excludeSyncBlock(store.excludeFile),
|
|
196
|
+
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
197
|
+
dropGitlinksBlock(),
|
|
198
|
+
oversizeBlock(),
|
|
199
|
+
'$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
|
|
200
|
+
// 旧 tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
|
|
201
|
+
// 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
|
|
202
|
+
"$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
|
|
203
|
+
'$curMap = @{}',
|
|
204
|
+
'foreach ($r in @($curOut)) {',
|
|
205
|
+
' if (-not $r) { continue }',
|
|
206
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
207
|
+
' $sha = ($r.Substring(0, $tab) -split " ")[1]',
|
|
208
|
+
' $curMap[$path] = $sha',
|
|
209
|
+
'}',
|
|
210
|
+
'$targetMap = @{}',
|
|
211
|
+
'foreach ($r in @($targetOut)) {',
|
|
212
|
+
' if (-not $r) { continue }',
|
|
213
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
214
|
+
' $sha = ($r.Substring(0, $tab) -split " ")[2]',
|
|
215
|
+
' $targetMap[$path] = $sha',
|
|
216
|
+
'}',
|
|
217
|
+
'$result = @()',
|
|
218
|
+
'foreach ($k in $curMap.Keys) {',
|
|
219
|
+
' if (-not $targetMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "added" } }',
|
|
220
|
+
' elseif ($targetMap[$k] -ne $curMap[$k]) { $result += [pscustomobject]@{ rel = $k; kind = "modified" } }',
|
|
221
|
+
'}',
|
|
222
|
+
'foreach ($k in $targetMap.Keys) {',
|
|
223
|
+
' if (-not $curMap.ContainsKey($k)) { $result += [pscustomobject]@{ rel = $k; kind = "restored" } }',
|
|
224
|
+
'}',
|
|
225
|
+
'$sorted = @($result | Sort-Object rel)',
|
|
226
|
+
'Write-Output (ConvertTo-Json -InputObject $sorted -Depth 3 -Compress)'
|
|
227
|
+
].join('\n')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 回退:archive 生成 zip 直接落盘(二进制不经 shell 文本管道),
|
|
231
|
+
// Expand-Archive 覆盖回工作区;再删除“当前有、目标无”的文件。
|
|
232
|
+
// 空树跳过 archive(空 zip 会让 Expand-Archive 报错),只执行删除。
|
|
233
|
+
// 回退后保留快照 tag 与索引:git delta 空间便宜,保留历史可再次
|
|
234
|
+
// 用该快照恢复(幂等),也避免误回退后无法找回。
|
|
235
|
+
export function rollbackScript(root, store, gitExe, tag) {
|
|
236
|
+
return [
|
|
237
|
+
"$ErrorActionPreference = 'Stop'",
|
|
238
|
+
'$git = ' + psq(gitExe),
|
|
239
|
+
'$g = ' + psq(store.git),
|
|
240
|
+
'$root = ' + psq(root),
|
|
241
|
+
dropGitlinksBlock(),
|
|
242
|
+
excludeSyncBlock(store.excludeFile),
|
|
243
|
+
'& $git --git-dir=$g --work-tree=$root add -A',
|
|
244
|
+
dropGitlinksBlock(),
|
|
245
|
+
oversizeBlock(),
|
|
246
|
+
// 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
|
|
247
|
+
'$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
|
|
248
|
+
"$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
|
|
249
|
+
'$targetMap = @{}',
|
|
250
|
+
'foreach ($r in @($targetOut)) {',
|
|
251
|
+
' if (-not $r) { continue }',
|
|
252
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
253
|
+
' $targetMap[$path] = $true',
|
|
254
|
+
'}',
|
|
255
|
+
'$restored = $targetMap.Count',
|
|
256
|
+
'if ($restored -gt 0) {',
|
|
257
|
+
' $zip = ' + psq(store.dir + '\\restore-tmp.zip'),
|
|
258
|
+
' & $git --git-dir=$g archive --format=zip --output=$zip ' + psq(tag),
|
|
259
|
+
' Expand-Archive -LiteralPath $zip -DestinationPath $root -Force',
|
|
260
|
+
' Remove-Item -LiteralPath $zip -Force',
|
|
261
|
+
'}',
|
|
262
|
+
'$deleted = 0',
|
|
263
|
+
'foreach ($r in @($curOut)) {',
|
|
264
|
+
' if (-not $r) { continue }',
|
|
265
|
+
' $tab = $r.IndexOf("`t"); $path = $r.Substring($tab + 1)',
|
|
266
|
+
' if (-not $targetMap.ContainsKey($path)) {',
|
|
267
|
+
" $full = Join-Path $root ($path.Replace('/','\\'))",
|
|
268
|
+
' if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Force; $deleted++ }',
|
|
269
|
+
' }',
|
|
270
|
+
'}',
|
|
271
|
+
"Write-Output ('ROLLBACK_OK ' + $deleted + ' ' + $restored)"
|
|
272
|
+
].join('\n')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function listTagsScript(store, gitExe) {
|
|
276
|
+
return [
|
|
277
|
+
"$ErrorActionPreference = 'Stop'",
|
|
278
|
+
'$git = ' + psq(gitExe),
|
|
279
|
+
'$g = ' + psq(store.git),
|
|
280
|
+
'& $git --git-dir=$g tag -l "snap-*"'
|
|
281
|
+
].join('\n')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 定期 gc:全量保留策略下对象只增不减,且默认 loose 存储(每对象一个
|
|
285
|
+
// 小文件,NTFS 最小簇 4KB)非常浪费;gc 压 pack + 跨版本 delta 通常省一半
|
|
286
|
+
// 以上。--prune=now 让「会话删除联动清理」删掉的 tag 立即真正释放空间
|
|
287
|
+
// (默认 2 周宽限期内对象仍占盘)——安全前提是 gc 与快照在同一条串行
|
|
288
|
+
// 队列里执行(见 maintenance.js),不存在并发竞态。
|
|
289
|
+
// 结尾写 gc.stamp:跨重启的节流凭据(ensureGit 回读)。
|
|
290
|
+
export function gcScript(store, gitExe) {
|
|
291
|
+
return [
|
|
292
|
+
"$ErrorActionPreference = 'Stop'",
|
|
293
|
+
'$git = ' + psq(gitExe),
|
|
294
|
+
'$g = ' + psq(store.git),
|
|
295
|
+
'& $git --git-dir=$g gc --quiet --prune=now',
|
|
296
|
+
"Set-Content -LiteralPath (Join-Path $g 'gc.stamp') -Value ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) -Encoding ascii",
|
|
297
|
+
"Write-Output 'GC_OK'"
|
|
298
|
+
].join('\n')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
|
|
302
|
+
// git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
|
|
303
|
+
// 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
|
|
304
|
+
export function purgeTagsScript(store, gitExe, tags) {
|
|
305
|
+
return [
|
|
306
|
+
"$ErrorActionPreference = 'Stop'",
|
|
307
|
+
'$git = ' + psq(gitExe),
|
|
308
|
+
'$g = ' + psq(store.git),
|
|
309
|
+
'& $git --git-dir=$g tag -d ' + tags.map((t) => psq(t)).join(' '),
|
|
310
|
+
"Write-Output 'PURGE_DONE'",
|
|
311
|
+
'exit 0'
|
|
312
|
+
].join('\n')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 索引写入:base64 以内联字面量传递时受 Windows 命令行 32767 字符硬上限
|
|
316
|
+
// 约束(DSH 的 pwsh 执行器把命令串作为 -Command 的单个 argv 元素 spawn),
|
|
317
|
+
// 快照攒到几百条就会超限 spawn 失败——按 20000 字符分块,首块 Set-Content、
|
|
318
|
+
// 续块 Add-Content。piece 为当前块的 base64 解码表达式,first 决定覆盖还是追加。
|
|
319
|
+
export function indexWriteCmd(dir, piece, first) {
|
|
320
|
+
const file = psq(dir + '\\index.json')
|
|
321
|
+
if (first) {
|
|
322
|
+
return 'New-Item -ItemType Directory -Force -Path ' + psq(dir) + ' | Out-Null; ' + piece + 'Set-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
|
|
323
|
+
}
|
|
324
|
+
return piece + 'Add-Content -LiteralPath ' + file + ' -Encoding utf8 -NoNewline'
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function indexReadCmd(dir) {
|
|
328
|
+
return 'Get-Content -LiteralPath ' + psq(dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue'
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 旧版项目内 blobs 目录清理(仅 home 存储可用时调用,见 store.js cleanupLegacy)
|
|
332
|
+
export function legacyRmScript(path) {
|
|
333
|
+
return 'Remove-Item -Recurse -Force -LiteralPath ' + psq(path)
|
|
334
|
+
}
|
package/lib/snapshots.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
+
*
|
|
4
|
+
* 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
|
|
5
|
+
* diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
|
|
6
|
+
* (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
|
|
7
|
+
* 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function createSnapshots(ctx, rt) {
|
|
11
|
+
const sessions = ctx.sessions
|
|
12
|
+
const state = rt.state
|
|
13
|
+
// 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
|
|
14
|
+
// 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
|
|
15
|
+
const S = rt.scripts
|
|
16
|
+
|
|
17
|
+
// 索引落盘。win32:base64 分块内联(见 scripts.pwsh.js indexWriteCmd 注释,
|
|
18
|
+
// 受 Windows 命令行 32767 字符上限约束)。POSIX:官方 ShellExecRequest
|
|
19
|
+
// 的 stdin 契约字段直写全文——不经命令行传参,没有 argv 长度上限,
|
|
20
|
+
// 也省掉 base64 往返;单次调用即完成。
|
|
21
|
+
async function saveIndex(root, sessionId) {
|
|
22
|
+
const store = state.stores.get(root)
|
|
23
|
+
if (!store) return
|
|
24
|
+
const entries = Array.from(state.snapshots.entries())
|
|
25
|
+
.filter(([, s]) => s.root === root)
|
|
26
|
+
.map(([id, s]) => ({ id, time: s.time, count: s.count, sessionId: s.sessionId }))
|
|
27
|
+
const json = JSON.stringify(entries)
|
|
28
|
+
try {
|
|
29
|
+
if (rt.isWin) {
|
|
30
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64')
|
|
31
|
+
let first = true
|
|
32
|
+
for (let i = 0; i < b64.length; i += 20000) {
|
|
33
|
+
const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64.slice(i, i + 20000) + "')) | "
|
|
34
|
+
await rt.runShell(S.indexWriteCmd(store.dir, piece, first), { stdoutMaxBytes: 4096 })
|
|
35
|
+
first = false
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
await rt.runShell('cat > ' + S.psq(store.dir + '/index.json'), { stdin: json, stdoutMaxBytes: 4096 })
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('recall saveIndex failed:', String(error))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadIndex(root, sessionId) {
|
|
46
|
+
if (state.indexLoaded.has(root)) return
|
|
47
|
+
state.indexLoaded.add(root)
|
|
48
|
+
const store = state.stores.get(root)
|
|
49
|
+
if (!store) return
|
|
50
|
+
try {
|
|
51
|
+
const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
|
|
52
|
+
if (!raw) return
|
|
53
|
+
const entries = JSON.parse(raw)
|
|
54
|
+
if (!Array.isArray(entries)) return
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (!entry || typeof entry.id !== 'string') continue
|
|
57
|
+
state.snapshots.set(entry.id, {
|
|
58
|
+
root,
|
|
59
|
+
time: typeof entry.time === 'number' ? entry.time : Date.now(),
|
|
60
|
+
count: typeof entry.count === 'number' ? entry.count : 0,
|
|
61
|
+
sessionId: entry.sessionId || sessionId
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
/* 索引缺失或损坏时按空历史处理 */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
|
|
70
|
+
async function rebuildOrphans(root, sessionId) {
|
|
71
|
+
const store = state.stores.get(root)
|
|
72
|
+
const gitExe = await rt.resolveGit()
|
|
73
|
+
if (!store || !gitExe) return
|
|
74
|
+
try {
|
|
75
|
+
const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
|
|
76
|
+
if (!listing) return
|
|
77
|
+
for (const name of listing.split(/\r?\n/)) {
|
|
78
|
+
const id = name.trim().replace(/^snap-/, '')
|
|
79
|
+
if (!id || state.snapshots.has(id)) continue
|
|
80
|
+
state.snapshots.set(id, { root, time: 0, count: 0, sessionId })
|
|
81
|
+
}
|
|
82
|
+
await saveIndex(root, sessionId)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('recall rebuildOrphans failed:', String(error))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function captureSnapshot(sessionId, messageId, time) {
|
|
89
|
+
const root = await rt.resolveRoot(sessionId)
|
|
90
|
+
if (!root) return
|
|
91
|
+
let store = await rt.resolveStore(root)
|
|
92
|
+
store = await rt.tryUpgradeToHome(root)
|
|
93
|
+
const ok = await rt.ensureGit(root, store)
|
|
94
|
+
if (!ok) return
|
|
95
|
+
await loadIndex(root, sessionId)
|
|
96
|
+
try {
|
|
97
|
+
await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
98
|
+
state.snapshots.set(String(messageId), { root, time: time || Date.now(), count: 0, sessionId })
|
|
99
|
+
await saveIndex(root, sessionId)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('recall snapshot failed:', String(error))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
|
|
106
|
+
// 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
|
|
107
|
+
function parseChanges(text) {
|
|
108
|
+
if (rt.isWin) {
|
|
109
|
+
const parsed = JSON.parse(text)
|
|
110
|
+
if (Array.isArray(parsed)) return parsed
|
|
111
|
+
if (parsed && typeof parsed === 'object') return [parsed]
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
const out = []
|
|
115
|
+
for (const line of text.split(/\r?\n/)) {
|
|
116
|
+
if (!line) continue
|
|
117
|
+
const tab = line.indexOf('\t')
|
|
118
|
+
if (tab < 0) continue
|
|
119
|
+
out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function diffFor(messageId) {
|
|
125
|
+
const snap = state.snapshots.get(String(messageId))
|
|
126
|
+
if (!snap) return null
|
|
127
|
+
const store = state.stores.get(snap.root)
|
|
128
|
+
if (!store) return null
|
|
129
|
+
const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }))
|
|
130
|
+
const trimmed = text.trim()
|
|
131
|
+
if (!trimmed) return []
|
|
132
|
+
return parseChanges(trimmed)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function rollbackFor(messageId) {
|
|
136
|
+
const snap = state.snapshots.get(String(messageId))
|
|
137
|
+
if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
|
|
138
|
+
const store = state.stores.get(snap.root)
|
|
139
|
+
if (!store) return { ok: false, error: '快照存储不可用' }
|
|
140
|
+
const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
|
|
141
|
+
const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
|
|
142
|
+
const deleted = m ? parseInt(m[1], 10) : 0
|
|
143
|
+
const restored = m ? parseInt(m[2], 10) : 0
|
|
144
|
+
return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
|
|
148
|
+
function scanCutSeq(events, messageId) {
|
|
149
|
+
let anchor = -1
|
|
150
|
+
for (let i = 0; i < events.length; i++) {
|
|
151
|
+
const e = events[i]
|
|
152
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
153
|
+
anchor = i
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (anchor < 0) return null
|
|
158
|
+
for (let i = anchor - 1; i >= 0; i--) {
|
|
159
|
+
const e = events[i]
|
|
160
|
+
if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
|
|
161
|
+
}
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
|
|
166
|
+
// 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
|
|
167
|
+
// 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
|
|
168
|
+
async function resolveCutSeq(sessionId, messageId) {
|
|
169
|
+
if (!sessionId || !messageId) return null
|
|
170
|
+
const cacheKey = String(sessionId) + '\u0000' + String(messageId)
|
|
171
|
+
if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
|
|
172
|
+
let result = null
|
|
173
|
+
const live = sessions.get(sessionId)
|
|
174
|
+
if (live && Array.isArray(live.events)) {
|
|
175
|
+
result = scanCutSeq(live.events, messageId)
|
|
176
|
+
} else {
|
|
177
|
+
const query = ctx.get('sessionQuery')
|
|
178
|
+
if (query) {
|
|
179
|
+
try {
|
|
180
|
+
const log = await query.readSession(sessionId)
|
|
181
|
+
result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
|
|
182
|
+
} catch (error) {
|
|
183
|
+
result = null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
state.cutSeqCache.set(cacheKey, result)
|
|
188
|
+
return result
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { saveIndex, loadIndex, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
|
|
192
|
+
}
|