dsh-recall-plugin 2.0.0 → 2.1.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 +55 -29
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +1459 -1479
- package/lib/diagnostics.js +59 -0
- package/lib/errors.js +73 -0
- package/lib/index.js +426 -1129
- package/lib/routes-core.js +162 -0
- package/lib/routes-manage.js +541 -0
- package/lib/scripts.posix.js +165 -27
- package/lib/scripts.pwsh.js +148 -33
- package/lib/session-info.js +71 -0
- package/lib/snapshots.js +243 -39
- package/lib/store.js +157 -19
- package/package.json +65 -61
package/lib/scripts.posix.js
CHANGED
|
@@ -30,6 +30,19 @@ export const UTF8_PRELUDE = 'export LC_ALL=C'
|
|
|
30
30
|
// 实际生效值以 store.maxFileBytes(用户 config 可调)经 oversizeBlock 注入为准
|
|
31
31
|
export const MAX_FILE_BYTES = 104857600
|
|
32
32
|
|
|
33
|
+
// 失败清扫的 stale 锁阈值(分钟,M3):锁文件 mtime 超过该值才视为残留可清;
|
|
34
|
+
// 更新的锁视为「有 git 操作正在进行」让路。本插件单条快照/回退的超时是
|
|
35
|
+
// 10 分钟,阈值取其一半,保证本方超时遗留的锁(≥10 分钟)一定能被清。
|
|
36
|
+
// 与 pwsh 版必须同值(scripts-contract 钉);属内部安全策略常量,与
|
|
37
|
+
// snapshots.js 的 FUSE_AFTER 同类,不走 Config。
|
|
38
|
+
export const STALE_LOCK_MIN = 5
|
|
39
|
+
|
|
40
|
+
// 心跳文件有效窗口(秒,M3):超过该时长的心跳视为失效实例(崩溃/重启
|
|
41
|
+
// 遗留或长期空闲),不再阻止清扫。心跳随 ensureGit/快照刷新,活动中的
|
|
42
|
+
// 实例窗口内必然有新心跳;长 git 操作(≤10 分钟)期间心跳写于操作开头,
|
|
43
|
+
// 仍在窗口内。
|
|
44
|
+
export const HEARTBEAT_TTL_S = 900
|
|
45
|
+
|
|
33
46
|
// bash/cat 输出无 BOM;保留同名导出维持两套模板接口一致(幂等无害)
|
|
34
47
|
export function stripBom(text) {
|
|
35
48
|
return text.replace(/^\uFEFF/, '')
|
|
@@ -83,18 +96,31 @@ function excludeSyncBlock(excludeFile, base) {
|
|
|
83
96
|
' while IFS= read -r line || [ -n "$line" ]; do',
|
|
84
97
|
" t=${line%$'\\r'}",
|
|
85
98
|
' t="${t#"${t%%[![:space:]]*}"}"; t="${t%"${t##*[![:space:]]}"}"',
|
|
86
|
-
' [ -z "$t" ]
|
|
99
|
+
' if [ -z "$t" ]; then continue; fi',
|
|
87
100
|
' case "$t" in \\#*) continue ;; esac',
|
|
88
101
|
' user_pats="$user_pats$t\\n"',
|
|
89
102
|
' done < "$ex_file"',
|
|
90
103
|
'fi',
|
|
91
104
|
"printf '\\n" + baseLines.replace(/\\/g, '\\\\').replace(/%/g, '%%') + "%b' \"$user_pats\" > \"$exc\"",
|
|
92
105
|
'"$git" -c core.quotePath=false --literal-pathspecs --git-dir="$g" ls-files -i -c --exclude-from="$exc" -z 2>/dev/null | while IFS= read -r -d \'\' p; do',
|
|
93
|
-
' [ -n "$p" ]
|
|
106
|
+
' if [ -n "$p" ]; then "$git" --literal-pathspecs --git-dir="$g" update-index --force-remove -- "$p"; fi',
|
|
94
107
|
'done || true'
|
|
95
108
|
].join('\n')
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
// 心跳写入(M3):随 git 操作顺手把「宿主 PID + epoch 秒」写进 store 目录
|
|
112
|
+
// (store.dir = git-dir 上两级),供对方实例的失败清扫判定「另一个 DSH 实例
|
|
113
|
+
// 正在使用此快照库」。PID 在模板生成期取宿主进程的 process.pid,无需调用方
|
|
114
|
+
// 传参。fail-open(|| true,set -e 下安全):心跳写失败绝不能连累快照本身
|
|
115
|
+
// ——最坏退化为无心跳,清扫只剩新锁分级保护。
|
|
116
|
+
// 依赖外层已定义的 $g;被 ensureGitScript / snapshotScript 复用。
|
|
117
|
+
function heartbeatBlock() {
|
|
118
|
+
return [
|
|
119
|
+
'hbf="$(dirname "$(dirname "$g")")/heartbeat"',
|
|
120
|
+
'printf \'%s %s\\n\' ' + psq(String(process.pid)) + ' "$(date +%s)" > "$hbf" 2>/dev/null || true'
|
|
121
|
+
].join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
// 解析 git 可执行文件路径:bash 从 PATH 找(POSIX 上 git 装了就在 PATH,
|
|
99
125
|
// 没有 Windows 那种四类安装位置的散装问题)
|
|
100
126
|
export function resolveGitScript() {
|
|
@@ -132,6 +158,25 @@ export function migrateScript(src, dst) {
|
|
|
132
158
|
].join('\n')
|
|
133
159
|
}
|
|
134
160
|
|
|
161
|
+
// 旧快照容器一次性迁移(POSIX 专属,I24 漂移修复的存量数据兜底,输出四态
|
|
162
|
+
// 由 store.js resolvePosixHomeBase 消费):仅当「旧容器存在且新容器不存在」
|
|
163
|
+
// 才整容器 mv——同卷(home 内部)rename 原子,无部分移动状态;容器级整移
|
|
164
|
+
// 自然带上根级 exclude.txt,语义无损。BOTH_PRESENT(双容器并存)/MIGRATE_FAIL
|
|
165
|
+
// 输出后不动任何数据。无 set -e(要靠分支输出状态而非中途退出);if 条件
|
|
166
|
+
// 内的 && 链豁免 I16 约束(该坑只针对循环体与裸列表)。非 git 命令,不适用
|
|
167
|
+
// g= 赋值 / RECALL_CLEANUP 哨兵约定。
|
|
168
|
+
export function legacyHomeMigrateScript(homedir) {
|
|
169
|
+
return [
|
|
170
|
+
'old=' + psq(homedir + '/dsh-recall-snapshots'),
|
|
171
|
+
'new=' + psq(homedir + '/.dsh/dsh-recall-snapshots'),
|
|
172
|
+
'if [ -d "$old" ] && [ ! -d "$new" ]; then',
|
|
173
|
+
' if mkdir -p -- "$(dirname "$new")" && mv -f -- "$old" "$new"; then echo MIGRATE_OK',
|
|
174
|
+
' else echo MIGRATE_FAIL; fi',
|
|
175
|
+
'elif [ -d "$old" ]; then echo BOTH_PRESENT',
|
|
176
|
+
'else echo OLD_ABSENT; fi'
|
|
177
|
+
].join('\n')
|
|
178
|
+
}
|
|
179
|
+
|
|
135
180
|
// 建立影子仓库 + 排除同步 + 回读 gc.stamp(语义与 pwsh 版一致,
|
|
136
181
|
// 见 scripts.pwsh.js 同名函数注释)
|
|
137
182
|
export function ensureGitScript(store, gitExe, base) {
|
|
@@ -140,7 +185,19 @@ export function ensureGitScript(store, gitExe, base) {
|
|
|
140
185
|
'git=' + psq(gitExe),
|
|
141
186
|
'repo=' + psq(store.repo),
|
|
142
187
|
'g=' + psq(store.git),
|
|
143
|
-
|
|
188
|
+
heartbeatBlock(),
|
|
189
|
+
// 冷启动首消息快照与启动预热(或双实例)并发时,两个 git init 在空
|
|
190
|
+
// repo 目录同跑,输家报 fatal: cannot mkdir <git>: File exists——窗口
|
|
191
|
+
// 极小但首条消息的快照会因此丢失。git init 幂等:失败后 HEAD 已出现
|
|
192
|
+
// 即同伴建成,视同成功继续;HEAD 也没有才是真失败,带错误退出(诊断
|
|
193
|
+
// 不丢)。检查 HEAD 而非目录存在:半截目录也会被 init 补齐。pwsh 版
|
|
194
|
+
// 无需同款改动——native 非零退出不抛(I14),输家继续跑 config 时
|
|
195
|
+
// 同伴已建好 repo,竞态天然容忍,真失败由快照 add 的显式检查兜底。
|
|
196
|
+
'if [ ! -f "$g/HEAD" ]; then',
|
|
197
|
+
' init_log=$("$git" init "$repo" 2>&1) || {',
|
|
198
|
+
' if [ ! -f "$g/HEAD" ]; then printf \'%s\\n\' "$init_log" >&2; exit 1; fi',
|
|
199
|
+
' }',
|
|
200
|
+
'fi',
|
|
144
201
|
'"$git" --git-dir="$g" config core.longpaths true',
|
|
145
202
|
'"$git" --git-dir="$g" config core.autocrlf false',
|
|
146
203
|
'"$git" --git-dir="$g" config advice.addEmbeddedRepo false',
|
|
@@ -159,6 +216,7 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
159
216
|
'git=' + psq(gitExe),
|
|
160
217
|
'g=' + psq(store.git),
|
|
161
218
|
'root=' + psq(root),
|
|
219
|
+
heartbeatBlock(),
|
|
162
220
|
dropGitlinksBlock(),
|
|
163
221
|
excludeSyncBlock(store.excludeFile, base),
|
|
164
222
|
// fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
|
|
@@ -240,6 +298,11 @@ export function diffScript(root, store, gitExe, tag, base) {
|
|
|
240
298
|
// 回退:archive | tar 直接管到工作区(无需 Windows 的 zip 中转),
|
|
241
299
|
// 空目标跳过;再删除「当前有、目标无」的文件(awk 求差集)。
|
|
242
300
|
// pipefail 保证 git archive 失败时整条非零退出。
|
|
301
|
+
// 删除侧失败必须响亮(F-G2):set -e 豁免 if 条件内的 rm 失败——若写成
|
|
302
|
+
// `rm ... && deleted++` 裸链,rm 失败(权限等)被静默跳过、脚本仍输出
|
|
303
|
+
// ROLLBACK_OK,半回退报成功、救援永不触发;改为 if/fi + 失败显式 exit 1,
|
|
304
|
+
// 与 pwsh 版「EAP=Stop 下 Remove-Item 抛终止错误」对齐「删除失败即失败」
|
|
305
|
+
// 的语义(见 scripts.pwsh.js rollbackScript 同位置注释)。
|
|
243
306
|
export function rollbackScript(root, store, gitExe, tag, base) {
|
|
244
307
|
return [
|
|
245
308
|
'set -e -o pipefail',
|
|
@@ -265,13 +328,31 @@ export function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
265
328
|
"' \"$tmpc\" \"$tmpt\" > \"$tmpd\"",
|
|
266
329
|
'deleted=0',
|
|
267
330
|
'while IFS= read -r p; do',
|
|
268
|
-
|
|
269
|
-
|
|
331
|
+
// 循环体禁裸 && 链(AGENTS.md 已知坑同款规矩):&& 列表条件为假时整条
|
|
332
|
+
// 退出码为 1,set -e 会把脚本杀掉;if 语句天然豁免。rm 失败必须响亮
|
|
333
|
+
// exit 1——半回退假成功会让救援永不触发(F-G2)
|
|
334
|
+
' if [ -z "$p" ]; then continue; fi',
|
|
335
|
+
' if rm -f -- "$root/$p"; then deleted=$((deleted + 1)); else echo "RM_FAILED $p" >&2; exit 1; fi',
|
|
270
336
|
'done < "$tmpd"',
|
|
271
337
|
'echo "ROLLBACK_OK $deleted $restored"'
|
|
272
338
|
].join('\n')
|
|
273
339
|
}
|
|
274
340
|
|
|
341
|
+
// 回退失败救援(H1,语义见 pwsh 版同款注释):reset --hard 回安全快照。
|
|
342
|
+
// 入参 tag 是完整 tag 名(snap- 前缀由调用侧 rescueRollback 拼齐)。
|
|
343
|
+
// set -e 下 git 非零退出自然终止脚本,与 pwsh 版 $LASTEXITCODE 显式自检
|
|
344
|
+
// 对齐「失败即抛」语义。
|
|
345
|
+
export function rescueScript(root, store, gitExe, tag) {
|
|
346
|
+
return [
|
|
347
|
+
'set -e',
|
|
348
|
+
'git=' + psq(gitExe),
|
|
349
|
+
'g=' + psq(store.git),
|
|
350
|
+
'root=' + psq(root),
|
|
351
|
+
'"$git" --git-dir="$g" --work-tree="$root" reset --hard ' + psq(tag),
|
|
352
|
+
'echo RESCUE_OK'
|
|
353
|
+
].join('\n')
|
|
354
|
+
}
|
|
355
|
+
|
|
275
356
|
export function listTagsScript(store, gitExe) {
|
|
276
357
|
return [
|
|
277
358
|
'set -e',
|
|
@@ -284,6 +365,20 @@ export function listTagsScript(store, gitExe) {
|
|
|
284
365
|
].join('\n')
|
|
285
366
|
}
|
|
286
367
|
|
|
368
|
+
// 孤儿重建用 tag 清单(带 creatordate):rebuildOrphans 据此恢复快照
|
|
369
|
+
// 时间——只列 tag 名会让重建条目 time=0,管理列表时间前缀缺失、
|
|
370
|
+
// retention/limits 按「最旧」误清。lightweight tag 无 tag 对象,
|
|
371
|
+
// creatordate 即指向 commit 的提交日期。输出每行「<tag名> <秒级时间戳>」。
|
|
372
|
+
export function listTagsWithTimeScript(store, gitExe) {
|
|
373
|
+
return [
|
|
374
|
+
'set -e',
|
|
375
|
+
'git=' + psq(gitExe),
|
|
376
|
+
'g=' + psq(store.git),
|
|
377
|
+
'[ -d "$g" ] || exit 0',
|
|
378
|
+
'"$git" --git-dir="$g" for-each-ref --format=\'%(refname:short) %(creatordate:unix)\' \'refs/tags/snap-*\''
|
|
379
|
+
].join('\n')
|
|
380
|
+
}
|
|
381
|
+
|
|
287
382
|
// 定期 gc(语义同 pwsh 版);date +%s 写秒级时间戳,JS 侧 ×1000
|
|
288
383
|
export function gcScript(store, gitExe) {
|
|
289
384
|
return [
|
|
@@ -308,28 +403,59 @@ export function pruneScript(store, gitExe) {
|
|
|
308
403
|
].join('\n')
|
|
309
404
|
}
|
|
310
405
|
|
|
311
|
-
// 失败后的孤儿进程清扫 + stale 锁清理(issue #7
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
'
|
|
329
|
-
'
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
406
|
+
// 失败后的孤儿进程清扫 + stale 锁清理(issue #7 兜底 + issue #11 根因治理)。
|
|
407
|
+
// 三级出口(M3):
|
|
408
|
+
// 1. CLEANUP_OTHER_INSTANCE <pid>——心跳文件(宿主 PID + epoch 秒)显示另一
|
|
409
|
+
// 个存活实例正在使用同一快照库:直接让路,不杀进程、不动锁。这是两个
|
|
410
|
+
// DSH 实例并发互踩(一方清扫误杀另一方活跃 git → 对方也失败 → 循环)的
|
|
411
|
+
// 根治;心跳由 ensureGit/snapshotScript 随操作刷新,TTL 外视为失效。
|
|
412
|
+
// 2. CLEANUP_SKIPPED_FRESH_LOCK——存在 STALE_LOCK_MIN 分钟内的新锁(疑似有
|
|
413
|
+
// git 操作正在进行):同样让路,锁陈旧后下次失败自然进入第 3 级。
|
|
414
|
+
// 3. CLEANUP_DONE——两级保护均未命中,按原有行为清孤儿进程与 stale 锁。
|
|
415
|
+
// POSIX 版差异:pgrep -f 按扩展正则匹配整条命令行——路径元字符([、+ 等)
|
|
416
|
+
// 会让模式失配,清扫静默失效,属安全降级(等价于本兜底加入前的行为);
|
|
417
|
+
// 标记在运行期拼接,本脚本自身命令行里只有未展开的 $g 字面量,不会自杀。
|
|
418
|
+
// 全程无 set -e + 逐步容错:清扫自身的失败不能抛。mtime 判定用 find -mmin
|
|
419
|
+
// 而非 stat:GNU/BSD stat 参数不同(macOS bash 3.2 约束),find 的 -mmin
|
|
420
|
+
// 两平台语义一致。
|
|
421
|
+
export function killOrphansScript(gitDir) {
|
|
422
|
+
return [
|
|
423
|
+
'# RECALL_CLEANUP',
|
|
424
|
+
'g=' + psq(gitDir),
|
|
425
|
+
'hbf="$(dirname "$(dirname "$g")")/heartbeat"',
|
|
426
|
+
'if [ -f "$hbf" ]; then',
|
|
427
|
+
' hbl=$(head -n1 "$hbf" 2>/dev/null | tr -d \'\\r\')',
|
|
428
|
+
' hbp=${hbl%% *}',
|
|
429
|
+
' hbt=${hbl#* }',
|
|
430
|
+
" case \"$hbp\" in ''|*[!0-9]*) hbp='' ;; esac",
|
|
431
|
+
" case \"$hbt\" in ''|*[!0-9]*) hbt=0 ;; esac",
|
|
432
|
+
' hbage=$(( $(date +%s) - hbt ))',
|
|
433
|
+
' if [ -n "$hbp" ] && [ "$hbp" != ' + psq(String(process.pid)) + ' ] && [ "$hbage" -ge 0 ] && [ "$hbage" -lt ' + HEARTBEAT_TTL_S + ' ]; then',
|
|
434
|
+
' if kill -0 "$hbp" 2>/dev/null; then',
|
|
435
|
+
' echo "CLEANUP_OTHER_INSTANCE $hbp"',
|
|
436
|
+
' exit 0',
|
|
437
|
+
' fi',
|
|
438
|
+
' fi',
|
|
439
|
+
'fi',
|
|
440
|
+
"fresh=$(find \"$g\" -maxdepth 1 -type f \\( -name '*.lock' -o -name 'gc.pid' \\) -mmin -" + STALE_LOCK_MIN + " 2>/dev/null; find \"$g/refs\" -type f -name '*.lock' -mmin -" + STALE_LOCK_MIN + ' 2>/dev/null)',
|
|
441
|
+
'if [ -n "$fresh" ]; then',
|
|
442
|
+
' echo CLEANUP_SKIPPED_FRESH_LOCK',
|
|
443
|
+
' exit 0',
|
|
444
|
+
'fi',
|
|
445
|
+
'marker="--git-dir=$g"',
|
|
446
|
+
'for p in $(pgrep -f -- "$marker" 2>/dev/null); do',
|
|
447
|
+
' [ "$p" = "$$" ] && continue',
|
|
448
|
+
' kill "$p" 2>/dev/null || true',
|
|
449
|
+
'done',
|
|
450
|
+
// 锁清单对齐 pwsh 版:index.lock 是 add/checkout 持久锁,其余是
|
|
451
|
+
// gc/tag/pack 链路残留;refs 下 per-ref 锁用 find 兜底(只删陈旧锁,
|
|
452
|
+
// 新锁已被上方分级保护拦下)
|
|
453
|
+
'rm -f "$g/index.lock" "$g/config.lock" "$g/HEAD.lock" "$g/gc.pid" "$g/packed-refs.lock" "$g/shallow.lock" 2>/dev/null || true',
|
|
454
|
+
'find "$g/refs" -type f -name "*.lock" -mmin +' + STALE_LOCK_MIN + ' -delete 2>/dev/null || true',
|
|
455
|
+
'echo CLEANUP_DONE'
|
|
456
|
+
].join('\n')
|
|
457
|
+
}
|
|
458
|
+
|
|
333
459
|
// 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
|
|
334
460
|
// || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
|
|
335
461
|
export function purgeTagsScript(store, gitExe, tags) {
|
|
@@ -341,12 +467,24 @@ export function purgeTagsScript(store, gitExe, tags) {
|
|
|
341
467
|
].join('\n')
|
|
342
468
|
}
|
|
343
469
|
|
|
470
|
+
// 原子 rename(H2,语义见 pwsh 版同款注释):mv -f 同卷 move 为 O(1) 元
|
|
471
|
+
// 数据操作;也用于 loadIndex 把损坏索引改名 .corrupt-<ts> 保留现场。
|
|
472
|
+
export function renameFileCmd(src, dst) {
|
|
473
|
+
return 'mv -f -- ' + psq(src) + ' ' + psq(dst)
|
|
474
|
+
}
|
|
475
|
+
|
|
344
476
|
// 索引读取(写入走 stdin:见 snapshots.js saveIndex 的 POSIX 分支,
|
|
345
477
|
// 不经命令行传参,天然没有 32767/128KB argv 上限问题)
|
|
346
478
|
export function indexReadCmd(dir) {
|
|
347
479
|
return 'cat ' + psq(dir + '/index.json') + ' 2>/dev/null || true'
|
|
348
480
|
}
|
|
349
481
|
|
|
482
|
+
// fork lineage 读取(F1,语义见 pwsh 版同款注释):lineage.json 与 index.json
|
|
483
|
+
// 同层、原子写;缺失文件输出空串。
|
|
484
|
+
export function lineageReadCmd(dir) {
|
|
485
|
+
return 'cat ' + psq(dir + '/lineage.json') + ' 2>/dev/null || true'
|
|
486
|
+
}
|
|
487
|
+
|
|
350
488
|
// 旧版项目内 blobs 目录清理(仅 home 存储可用时调用)
|
|
351
489
|
export function legacyRmScript(path) {
|
|
352
490
|
return 'rm -rf -- ' + psq(path)
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -27,6 +27,12 @@ export const UTF8_PRELUDE = '$OutputEncoding = [Text.UTF8Encoding]::new($false)\
|
|
|
27
27
|
// 生效值以 store.maxFileBytes(用户 config 可调)经 oversizeBlock 注入为准。
|
|
28
28
|
export const MAX_FILE_BYTES = 104857600
|
|
29
29
|
|
|
30
|
+
// 失败清扫的 stale 锁阈值(分钟,M3)与心跳有效窗口(秒,M3):语义、取值
|
|
31
|
+
// 理由与「不走 Config」的定性见 scripts.posix.js 同名常量注释,两侧必须
|
|
32
|
+
// 同值(scripts-contract 钉)。
|
|
33
|
+
export const STALE_LOCK_MIN = 5
|
|
34
|
+
export const HEARTBEAT_TTL_S = 900
|
|
35
|
+
|
|
30
36
|
// 去除 PS 5.1 Set-Content -Encoding utf8 写出的 BOM:JSON 解析前必须剥掉,
|
|
31
37
|
// 否则 JSON.parse 把 BOM 当正文首字符直接抛错。
|
|
32
38
|
export function stripBom(text) {
|
|
@@ -89,6 +95,18 @@ function excludeSyncBlock(excludeFile, base) {
|
|
|
89
95
|
].join('\n')
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
// 心跳写入(M3,语义见 scripts.posix.js 同名注释):Set-Content 用 ascii
|
|
99
|
+
// 编码——内容纯数字,关键是绝不能带 BOM(POSIX 侧按空白分词解析首字段,
|
|
100
|
+
// 5.1 的 utf8 默认编码会写 BOM 破坏首字段);-ErrorAction SilentlyContinue
|
|
101
|
+
// 让心跳写失败不连累外层 EAP=Stop 的快照主流程(与 POSIX 的 || true 对齐)。
|
|
102
|
+
// 依赖外层已定义的 $g;被 ensureGitScript / snapshotScript 复用。
|
|
103
|
+
function heartbeatBlock() {
|
|
104
|
+
return [
|
|
105
|
+
"$hbf = Join-Path (Split-Path -Parent (Split-Path -Parent $g)) 'heartbeat'",
|
|
106
|
+
"Set-Content -LiteralPath $hbf -Value ('" + String(process.pid) + " ' + [DateTimeOffset]::Now.ToUnixTimeSeconds()) -Encoding ascii -ErrorAction SilentlyContinue"
|
|
107
|
+
].join('\n')
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
// 解析 git 可执行文件路径:DSH 进程 PATH 可能不含 git,脚本里用绝对路径调用。
|
|
93
111
|
// 逐项判空再 Join-Path:个别 env 在特殊环境(32 位系统无 ProgramFiles(x86))
|
|
94
112
|
// 取到 null,EAP=Stop 下 Join-Path 抛错会让整个探测失败、误报 gitMissing。
|
|
@@ -151,6 +169,7 @@ export function ensureGitScript(store, gitExe, base) {
|
|
|
151
169
|
'$git = ' + psq(gitExe),
|
|
152
170
|
'$repo = ' + psq(store.repo),
|
|
153
171
|
'$g = ' + psq(store.git),
|
|
172
|
+
heartbeatBlock(),
|
|
154
173
|
'if (-not (Test-Path -LiteralPath $g)) {',
|
|
155
174
|
' & $git init $repo | Out-Null',
|
|
156
175
|
'}',
|
|
@@ -175,6 +194,7 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
175
194
|
'$git = ' + psq(gitExe),
|
|
176
195
|
'$g = ' + psq(store.git),
|
|
177
196
|
'$root = ' + psq(root),
|
|
197
|
+
heartbeatBlock(),
|
|
178
198
|
dropGitlinksBlock(),
|
|
179
199
|
excludeSyncBlock(store.excludeFile, base),
|
|
180
200
|
// fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
|
|
@@ -296,6 +316,10 @@ export function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
296
316
|
' Expand-Archive -LiteralPath $zip -DestinationPath $root -Force',
|
|
297
317
|
' Remove-Item -LiteralPath $zip -Force',
|
|
298
318
|
'}',
|
|
319
|
+
// 删除失败语义与 POSIX 版对齐(F-G2):本侧 EAP=Stop 下 Remove-Item
|
|
320
|
+
// 失败直接抛终止错误、pwsh 以非零码退出;POSIX 侧 rm 在 set -e 的 if
|
|
321
|
+
// 条件里失败不会自动终止,须显式 exit 1(见 scripts.posix.js rollbackScript)。
|
|
322
|
+
// 任何一侧半回退都不许报 ROLLBACK_OK——假成功会让救援永不触发(H1)。
|
|
299
323
|
'$deleted = 0',
|
|
300
324
|
'foreach ($r in @($curOut)) {',
|
|
301
325
|
' if (-not $r) { continue }',
|
|
@@ -309,6 +333,25 @@ export function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
309
333
|
].join('\n')
|
|
310
334
|
}
|
|
311
335
|
|
|
336
|
+
// 回退失败救援(H1):rollback 脚本未输出 ROLLBACK_OK(工作区可能半回退)
|
|
337
|
+
// 时,用 execute 预先打下的安全快照把工作区 reset 回「回退前」状态。
|
|
338
|
+
// 入参 tag 是完整 tag 名(snap-pre-rollback-<ts>,snap- 前缀由调用侧
|
|
339
|
+
// rescueRollback 拼齐——snapshotScript 打 tag 无条件加前缀,本模板保持
|
|
340
|
+
// 通用只接受完整名)。命令与 rollbackScript 同款 --git-dir/--work-tree 形态;
|
|
341
|
+
// pwsh 对 native 非零退出不抛(EAP 不作用于 native),必须显式查
|
|
342
|
+
// $LASTEXITCODE 并 throw,否则救援失败被静默吞掉、工作区停在半回退状态。
|
|
343
|
+
export function rescueScript(root, store, gitExe, tag) {
|
|
344
|
+
return [
|
|
345
|
+
"$ErrorActionPreference = 'Stop'",
|
|
346
|
+
'$git = ' + psq(gitExe),
|
|
347
|
+
'$g = ' + psq(store.git),
|
|
348
|
+
'$root = ' + psq(root),
|
|
349
|
+
'& $git --git-dir=$g --work-tree=$root reset --hard ' + psq(tag),
|
|
350
|
+
'if ($LASTEXITCODE -ne 0) { throw ("git reset --hard failed (exit " + $LASTEXITCODE + ")") }',
|
|
351
|
+
"Write-Output 'RESCUE_OK'"
|
|
352
|
+
].join('\n')
|
|
353
|
+
}
|
|
354
|
+
|
|
312
355
|
export function listTagsScript(store, gitExe) {
|
|
313
356
|
return [
|
|
314
357
|
"$ErrorActionPreference = 'Stop'",
|
|
@@ -321,6 +364,20 @@ export function listTagsScript(store, gitExe) {
|
|
|
321
364
|
].join('\n')
|
|
322
365
|
}
|
|
323
366
|
|
|
367
|
+
// 孤儿重建用 tag 清单(带 creatordate):语义同 POSIX 版同名导出——
|
|
368
|
+
// rebuildOrphans 据此恢复快照时间,time=0 会让管理列表时间前缀缺失、
|
|
369
|
+
// retention/limits 按「最旧」误清。lightweight tag 的 creatordate 即
|
|
370
|
+
// 指向 commit 的提交日期。输出每行「<tag名> <秒级时间戳>」。
|
|
371
|
+
export function listTagsWithTimeScript(store, gitExe) {
|
|
372
|
+
return [
|
|
373
|
+
"$ErrorActionPreference = 'Stop'",
|
|
374
|
+
'$git = ' + psq(gitExe),
|
|
375
|
+
'$g = ' + psq(store.git),
|
|
376
|
+
'if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }',
|
|
377
|
+
'& $git --git-dir=$g for-each-ref --format="%(refname:short) %(creatordate:unix)" "refs/tags/snap-*"'
|
|
378
|
+
].join('\n')
|
|
379
|
+
}
|
|
380
|
+
|
|
324
381
|
// 定期 gc:全量保留策略下对象只增不减,且默认 loose 存储(每对象一个
|
|
325
382
|
// 小文件,NTFS 最小簇 4KB)非常浪费;gc 压 pack + 跨版本 delta 通常省一半
|
|
326
383
|
// 以上。--prune=now 让「会话删除联动清理」删掉的 tag 立即真正释放空间
|
|
@@ -354,36 +411,73 @@ export function pruneScript(store, gitExe) {
|
|
|
354
411
|
].join('\n')
|
|
355
412
|
}
|
|
356
413
|
|
|
357
|
-
// 失败后的孤儿进程清扫 + stale 锁清理(issue #7 实测:超时被杀的 shell 留下
|
|
358
|
-
// 孤儿 git 继续持有 index.lock 30+ 分钟)。
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
"$
|
|
374
|
-
|
|
375
|
-
'
|
|
376
|
-
'
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
'
|
|
381
|
-
'
|
|
382
|
-
|
|
383
|
-
"Write-Output '
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
414
|
+
// 失败后的孤儿进程清扫 + stale 锁清理(issue #7 实测:超时被杀的 shell 留下
|
|
415
|
+
// 孤儿 git 继续持有 index.lock 30+ 分钟)。M3 三级出口(语义与动机见
|
|
416
|
+
// scripts.posix.js 同名函数注释):另一活实例心跳让路(CLEANUP_OTHER_INSTANCE)
|
|
417
|
+
// → 新锁让路(CLEANUP_SKIPPED_FRESH_LOCK)→ 原有清扫(CLEANUP_DONE)。
|
|
418
|
+
// DSH 的 subprocess 服务本身已做树级终止(taskkill /T /F),这里是竞态窗口
|
|
419
|
+
// 与旧版本漏网的兜底:按「命令行含 --git-dir=<本仓库>」定位孤儿——该标记只
|
|
420
|
+
// 出现在本插件派生的 git 进程参数里,编辑器等无关进程不会命中。
|
|
421
|
+
// 全程 SilentlyContinue + best-effort:调用点在 runShell 的失败路径上,
|
|
422
|
+
// 清扫自身再抛错只会掩盖原始错误。首行哨兵注释供 runShell 识别本脚本、
|
|
423
|
+
// 防止「清扫失败 → 再清扫」的递归。
|
|
424
|
+
export function killOrphansScript(gitDir) {
|
|
425
|
+
return [
|
|
426
|
+
'# RECALL_CLEANUP',
|
|
427
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
428
|
+
'$g = ' + psq(gitDir),
|
|
429
|
+
// —— 第 1 级保护:另一活实例心跳(store.dir 是 git-dir 上两级)——
|
|
430
|
+
"$hbf = Join-Path (Split-Path -Parent (Split-Path -Parent $g)) 'heartbeat'",
|
|
431
|
+
'if (Test-Path -LiteralPath $hbf -PathType Leaf) {',
|
|
432
|
+
' $hl = Get-Content -LiteralPath $hbf -TotalCount 1',
|
|
433
|
+
' if ($hl) {',
|
|
434
|
+
" $hp = ('' + $hl).Trim() -split '\\s+'",
|
|
435
|
+
' $a = [int64]0; $b = [int64]0',
|
|
436
|
+
' if (($hp.Count -ge 2) -and [int64]::TryParse($hp[0], [ref]$a) -and [int64]::TryParse($hp[1], [ref]$b)) {',
|
|
437
|
+
' $age = [DateTimeOffset]::Now.ToUnixTimeSeconds() - $b',
|
|
438
|
+
' if (($a -gt 0) -and ($a -ne ' + String(process.pid) + ') -and ($age -ge 0) -and ($age -lt ' + HEARTBEAT_TTL_S + ')) {',
|
|
439
|
+
' if (Get-Process -Id $a -ErrorAction SilentlyContinue) {',
|
|
440
|
+
" Write-Output ('CLEANUP_OTHER_INSTANCE ' + $a)",
|
|
441
|
+
' exit 0',
|
|
442
|
+
' }',
|
|
443
|
+
' }',
|
|
444
|
+
' }',
|
|
445
|
+
' }',
|
|
446
|
+
'}',
|
|
447
|
+
// —— 第 2 级保护:新锁(有 git 操作可能正在进行)——
|
|
448
|
+
"$cutoff = (Get-Date).AddMinutes(-" + STALE_LOCK_MIN + ')',
|
|
449
|
+
'$fresh = $false',
|
|
450
|
+
"foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
|
|
451
|
+
' $lp = Join-Path $g $n',
|
|
452
|
+
' if (Test-Path -LiteralPath $lp -PathType Leaf) {',
|
|
453
|
+
' if ((Get-Item -LiteralPath $lp).LastWriteTime -gt $cutoff) { $fresh = $true; break }',
|
|
454
|
+
' }',
|
|
455
|
+
'}',
|
|
456
|
+
'if (-not $fresh) {',
|
|
457
|
+
" $fl = @(Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $cutoff })",
|
|
458
|
+
' if ($fl.Count -gt 0) { $fresh = $true }',
|
|
459
|
+
'}',
|
|
460
|
+
'if ($fresh) {',
|
|
461
|
+
" Write-Output 'CLEANUP_SKIPPED_FRESH_LOCK'",
|
|
462
|
+
' exit 0',
|
|
463
|
+
'}',
|
|
464
|
+
// —— 保护未命中:原有清扫(杀孤儿 + 清 stale 锁)——
|
|
465
|
+
// 标记用变量拼接而非字面量:本脚本进程的命令行(-Command 全文)只有
|
|
466
|
+
// 未展开的 '$g',Where-Object 不会匹配到自己
|
|
467
|
+
"$marker = '--git-dir=' + $g",
|
|
468
|
+
"Get-CimInstance Win32_Process -Filter 'CommandLine IS NOT NULL' | Where-Object { $_.CommandLine.Contains($marker) } | ForEach-Object {",
|
|
469
|
+
' & taskkill /T /F /PID $_.ProcessId | Out-Null',
|
|
470
|
+
'}',
|
|
471
|
+
// 锁清单:index.lock 是 add/checkout 的持久锁,其余是 gc/tag/pack 链路
|
|
472
|
+
// 可能残留的;refs 下的 per-ref 锁用递归兜底(能走到这里说明锁已陈旧)
|
|
473
|
+
"foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
|
|
474
|
+
' Remove-Item -LiteralPath (Join-Path $g $n) -Force',
|
|
475
|
+
'}',
|
|
476
|
+
"Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' | Remove-Item -Force",
|
|
477
|
+
"Write-Output 'CLEANUP_DONE'"
|
|
478
|
+
].join('\n')
|
|
479
|
+
}
|
|
480
|
+
|
|
387
481
|
// 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
|
|
388
482
|
// git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
|
|
389
483
|
// 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
|
|
@@ -406,14 +500,35 @@ export function purgeTagsScript(store, gitExe, tags) {
|
|
|
406
500
|
// 表达式,first 决定覆盖还是追加。目录创建归调用方(writeExclude 的
|
|
407
501
|
// mkdirScript 兜底 / index.json 的父目录在建仓时已存在)。
|
|
408
502
|
export function fileWriteCmd(file, piece, first) {
|
|
503
|
+
// EAP=Stop:Set-Content/Add-Content 是 cmdlet,失败(权限/磁盘满)在
|
|
504
|
+
// 默认 EAP=Continue 下只写 error stream、退出码仍 0,半截写入被静默吞掉。
|
|
505
|
+
// 显式 Stop 让失败抛终止错误,runShell 据此检测非零退出并抛错(H2)。
|
|
506
|
+
const prelude = "$ErrorActionPreference = 'Stop'\n"
|
|
409
507
|
if (first) {
|
|
410
|
-
return piece + 'Set-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
|
|
508
|
+
return prelude + piece + 'Set-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
|
|
411
509
|
}
|
|
412
|
-
return piece + 'Add-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
|
|
510
|
+
return prelude + piece + 'Add-Content -LiteralPath ' + psq(file) + ' -Encoding utf8 -NoNewline'
|
|
413
511
|
}
|
|
414
512
|
|
|
513
|
+
// 原子 rename(H2):同卷 move 是 O(1) 元数据操作,把「已完整写完的 tmp」
|
|
514
|
+
// 一步替换成目标文件,杜绝分块写中途崩溃留下的截断 JSON;也用于 loadIndex
|
|
515
|
+
// 把损坏索引改名 .corrupt-<ts> 保留现场(见 snapshots.js quarantineCorruptIndex)。
|
|
516
|
+
export function renameFileCmd(src, dst) {
|
|
517
|
+
return "$ErrorActionPreference = 'Stop'\nMove-Item -Force -LiteralPath " + psq(src) + ' -Destination ' + psq(dst)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 显式 -Encoding UTF8:写侧(writeTextViaShell base64 解码落盘)产出的是
|
|
521
|
+
// 无 BOM UTF-8,PS 7 默认即按 UTF-8 读,但 PS 5.1 兜底(pwsh-local 解析链
|
|
522
|
+
// 降级)按 ANSI 活动代码页解码——中文 root 乱码 → JSON.parse 失败 → 误走
|
|
523
|
+
// H2 隔离分支。与 excludeReadCmd 等同文件其他读取处的既有写法对齐。
|
|
415
524
|
export function indexReadCmd(dir) {
|
|
416
|
-
return 'Get-Content -LiteralPath ' + psq(dir + '\\index.json') + ' -Raw -ErrorAction SilentlyContinue'
|
|
525
|
+
return 'Get-Content -LiteralPath ' + psq(dir + '\\index.json') + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// fork lineage 读取(F1):lineage.json 记录 childId↔parentId 撤回链,
|
|
529
|
+
// 与 index.json 同层、原子写(writeTextViaShell)。文件不存在时输出空串。
|
|
530
|
+
export function lineageReadCmd(dir) {
|
|
531
|
+
return 'Get-Content -LiteralPath ' + psq(dir + '\\lineage.json') + ' -Raw -Encoding UTF8 -ErrorAction SilentlyContinue'
|
|
417
532
|
}
|
|
418
533
|
|
|
419
534
|
// 旧版项目内 blobs 目录清理(仅 home 存储可用时调用,见 store.js cleanupLegacy)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 会话信息域(R2 从 index.js 拆出)
|
|
3
|
+
*
|
|
4
|
+
* 会话标题/消息文本的「两段式」读取:纯函数(titleFromEvents /
|
|
5
|
+
* messageTextFromEvents)模块级导出供单测与工厂共用;live 快速查询
|
|
6
|
+
* (liveTitleFast / liveMessageTextFast)带 apply 级跨请求缓存(sessionTitles /
|
|
7
|
+
* messageTexts Map),由 createSessionInfo 工厂生产——无模块级可变状态(HMR 假设)。
|
|
8
|
+
*
|
|
9
|
+
* 冷会话标题/文本要 readSession 整日志解压(10 秒级),列表首屏只查 live/缓存
|
|
10
|
+
* (同步、瞬时),冷数据由 Client 拿到列表后异步调 titles/messages 端点补齐。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// 从事件序列里取最新一条 session/title(倒序,标题事件通常靠后)
|
|
14
|
+
export function titleFromEvents(events) {
|
|
15
|
+
if (!Array.isArray(events)) return null
|
|
16
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
17
|
+
const e = events[i]
|
|
18
|
+
if (e && e.type === 'session/title' && e.data && typeof e.data.title === 'string' && e.data.title) return e.data.title
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 从事件序列里取指定用户消息的纯文本(text 块拼接)
|
|
24
|
+
export function messageTextFromEvents(events, messageId) {
|
|
25
|
+
if (!Array.isArray(events) || !messageId) return null
|
|
26
|
+
for (const e of events) {
|
|
27
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
28
|
+
const blocks = Array.isArray(e.data.content) ? e.data.content : []
|
|
29
|
+
const text = blocks
|
|
30
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
31
|
+
.map((b) => b.text)
|
|
32
|
+
.join('')
|
|
33
|
+
return text || null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSessionInfo(ctx) {
|
|
40
|
+
// 会话标题缓存:值为 null 表示「查过、确实没有」(已删除会话),同样命中缓存
|
|
41
|
+
const sessionTitles = new Map()
|
|
42
|
+
// 消息文本缓存:null 也缓存(避免无文本消息每次刷新重复解压冷日志)
|
|
43
|
+
const messageTexts = new Map()
|
|
44
|
+
|
|
45
|
+
function liveMessageTextFast(sessionId, messageId) {
|
|
46
|
+
if (!sessionId || !messageId) return null
|
|
47
|
+
const key = String(sessionId) + '\u0000' + String(messageId)
|
|
48
|
+
if (messageTexts.has(key)) return messageTexts.get(key)
|
|
49
|
+
let text = null
|
|
50
|
+
try {
|
|
51
|
+
const live = ctx.sessions.get(sessionId)
|
|
52
|
+
if (live) text = messageTextFromEvents(live.events, messageId)
|
|
53
|
+
} catch (error) { text = null }
|
|
54
|
+
if (text !== null) messageTexts.set(key, text)
|
|
55
|
+
return text
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function liveTitleFast(sessionId) {
|
|
59
|
+
if (!sessionId) return null
|
|
60
|
+
if (sessionTitles.has(sessionId)) return sessionTitles.get(sessionId)
|
|
61
|
+
let t = null
|
|
62
|
+
try {
|
|
63
|
+
const live = ctx.sessions.get(sessionId)
|
|
64
|
+
if (live) t = titleFromEvents(live.events)
|
|
65
|
+
} catch (error) { t = null }
|
|
66
|
+
if (t !== null) sessionTitles.set(sessionId, t)
|
|
67
|
+
return t
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { sessionTitles, messageTexts, liveTitleFast, liveMessageTextFast }
|
|
71
|
+
}
|