Skip to content

Releases

1.4.4

Choose a tag to compare

@SuCriss SuCriss released this 02 Oct 04:18

dsh-version-update 1.4.4

一个补丁版:修掉两处「说法与事实不符」的地方,并把类型检查的门禁补齐。

修复

  • 被拒绝的恢复不再把责任推给一个不存在的宿主。 两处锁拒绝文案都把持有者说成「另一个宿主」,而持有者可能就是本进程自己:硬超时把任务判成终态(于是面板里「安装进行中」的守卫不再触发),却刻意把机器级锁留到被杀死的 npm 真正退出为止;dispose() 之后留下的孤儿也是这样持锁的。在这个窗口里被拒绝的恢复或安装,会告诉用户去找一个并不存在的第二个宿主。现在两种情况分开表述,且都保留面板所依据的措辞。
  • 失败后修复的定时器不再拖住正在退出的宿主。 它是插件里唯一没有 unref() 的定时器;树被占用时它会重新 arm,所以最坏不是一次延迟而是整个重试预算——约 24 秒。它推迟的只是尽力而为的维护,下一次启动的扫描会补上。现在插件里每一个定时器都 unref() 了。

变更

  • 全部宿主模块重新回到类型检查之下。 opevents、preflight、restart-log、snapshot-inventory、updatelock 这五个模块曾漂移出 tsconfig.json 的 include,而它们仍带着只有这份配置才会变成约束的 JSDoc——也就是说没人在检查它们:在其中故意植入一个类型错误,加入清单后报 6 条诊断,加入之前一条都不报。这份数组就是门禁,新的 lib/*.js 一存在就该登记。
  • 移除 lib/relaunch.js 里一个吞掉了重启日志辅助函数 JSDoc 的游离注释开头。纯注释改动。

测试

发版门禁:263 用例通过,tsc --checkJs 干净。已发布 tarball 上的 6 条行为断言全部通过——本机自己 vs 另一个宿主的锁文案两支、修复定时器不持有事件循环,以及 1.4.3 两条不变量的回归(退休目录年龄、锁的原子发布)。

完整对比:v1.4.3...v1.4.4

发版之后 main 又补了一条针对本次 unref 的回归用例(process.getActiveResourcesInfo() 只列保持事件循环存活的资源,因此可以确定性断言修复定时器是 unref 的)。该 tag 上的套件为 263 例,main 现为 264。

1.4.3

Choose a tag to compare

@SuCriss SuCriss released this 01 Oct 23:40

dsh-version-update 1.4.3

本版修掉五处会静默出错的问题:树修复会误删或覆盖正在运行的安装、更新锁在极端交错下会让两个宿主同时安装、被超时抛弃的安装会去装旧版本并把新版本记成成功,以及审计记录把一次纯清理写成「快照恢复」。

修复

  • 退休目录的年龄改从「它所在的目录」读取。 npm 的 retire 是一次改名(arborist lib/retire-path.js),而改名只更新父目录的 mtime、被改名目录自身的 mtime 原样不动。于是一秒前刚被退休的目录仍报告它上次被写入的时间——对几天前安装的包就是几天前——10 分钟新鲜度闸门把它读成「陈旧」,引导修复会删掉仍在运行的 reify 的退休副本,或在 npm 还在写的时候把快照恢复上去。现在取两者中更年轻的一个,只会更保守。实测:一次改名后子目录仍是 30.0 天,父目录变成 0.0 秒。测试改用真实的 renameSync(不再用 utimesSync 给子目录造年龄),这正是该 bug 能存活的原因:夹具伪造了闸门本该推断出的年龄。
  • 延后清扫的定时器不再拖住退出中的宿主。 它现在与其他所有定时器一样 unref()。上一条修复让常见情形(刚刚退休的目录)把等待表达为整个闸门,于是用户退出后进程还会存活最多十分钟。这是测试挂了两分钟才暴露出来的。
  • 所有会写安装树的路径现在都拿机器级锁。 原先只有失败后修复拿锁;引导修复和延后清扫只看 updater.busy(),而那只覆盖本进程——于是另一个宿主正在安装时(桌面端在更新、用户又在终端起了一个 dsh web),它的树可能被就地重建。年龄闸门不能替代锁:它约束的是删除,约束不了恢复分支。发现树被占用时是延后而非丢弃,且延后有界。
  • 被硬超时抛弃的那一轮安装,不会再把自己的陈旧目标装上去。 所有守卫都用共享的 task.state 判断「我这轮还在跑吗」,而 settle() 与 start() 都会整体替换 task 对象——于是在快照还在拷贝时被抛弃的流水线,从那个绑定里读到的是下一轮的 running,为自己的陈旧版本 spawn npm,然后把新一轮判成成功。面板与 history.json 双双记下一次从未发生的安装,而用户真正点的版本一次都没被 spawn。现在每轮持有单调身份,settle() 对已不再存活的那一轮完全惰性。
  • 更新锁的发布是原子的,抢占带身份校验。 记录原先在 open(path,'wx') 创建路径之后才写入,于是锁路径有一个系统调用时长的空文件窗口;空锁与损坏的锁不可区分,而每个读者都把损坏当陈旧抢走——两个同时启动的宿主可能都以为自己拿到了锁。现在记录先写进私有临时文件、再 link 进锁路径。抢占也是同样的两步结构:判定陈旧之后裸 rmSync 可能删掉期间替换上来的那条记录(也就是新持有者的锁),两个宿主随即安装进同一棵全局树。现在先 rename 成私有墓碑、核对实际拿到的是不是被判陈旧的那条,不是就放回去。
  • 审计记录不再把一次纯清理叫作「快照恢复」。 restored 原先对每一次修复都置位,于是一次只删残留目录的修复在面板上读作「快照恢复」——一条从未发生过的版本迁移。现在事件种类(repair)与它做了什么(restored、removed)分开,这也让一次只报错的修复不会再渲染成普通的失败安装。

测试

262 用例通过,tsc --checkJs 干净。五处修复都用「把修复回退掉、断言必须失败」实测过。已发布 tarball 上的 10 条行为断言全部通过。

完整对比:v1.4.2...v1.4.3

1.4.2

Choose a tag to compare

@SuCriss SuCriss released this 29 Sep 02:55

dsh-version-update 1.4.2

快照的记录不再落进 live 安装树,被推迟的残留清理也不再等下辈子。

修复

  • 恢复快照不再把快照自己的记录丢进 live 安装树。 一个快照目录同时是两样东西:store 条目,以及 payload。payload 是安装树的副本,而 store 条目带着插件自己的 meta.json——版本、时间戳、文件清单——由 createSnapshot 写进同一个目录。restore 原本把这个目录整个拷回去,于是 meta.json 落在了 live 树的根上;那既不是包会发布的文件,也不是 npm 会删的文件。本机 history.json 里有 9 条 restored: true,最后一条在 09:35:42,而对应的 live 树里 meta.json 已经长到 2,496,985 字节 / 26,513 条记录。现在只过滤这一个路径,而且只过滤这一个——payload 子目录里的同名 meta.json 是 payload、不是记录,照旧复制。
  • boot 期不得不推迟的残留目录,现在会被补收。 npm 替换一个包的方式,是把它 rename 成点前缀的兄弟目录(.dsh-<8 位随机字符>),reify 正常跑完就删掉;被 kill 的安装会把它留下,而 npm 自己的读取器忽略一切点前缀目录,所以它永远收不回去。boot 期会扫这些目录,但刻意跳过不足十分钟的,以免和仍在写入的安装抢。问题在于它再也不会回来:挂载时发现的、太年轻而不能碰的残留,会被上报给面板,然后原样留到下一次启动——而一个长期不重启的宿主,下一次启动永远不来。现在挂载会为「它老过闸门的那一刻」武装一个一次性定时器,运行前再确认 updater 空闲。本机那个 .dsh-MzqSjRg1——134 MB,创建于 2026-09-18,并不是 09-28 那次失败安装的产物——正是这种情况:每次挂载都被发现,一次都没被删。
  • 没有 APPDATA 也能找到 Windows 的每用户 npm 前缀。 全局安装搜索原本以 %APPDATA%\npm\node_modules 为根,那是权威位置,但不保证存在于宿主被启动时的环境里——从一个没收到它的 shell 启动的宿主,解析不出任何全局前缀,设置页于是把这个安装报成未纳管。现在由 USERPROFILE 兜底。本机实测:process.env.APPDATA 是 undefined,而 USERPROFILE 有值——所以兜底的那条路径,才是这里真正解析得出来的那条。

测试

254 用例通过,tsc --checkJs 干净。

Full Changelog: v1.4.1...v1.4.2

1.4.1

Choose a tag to compare

@SuCriss SuCriss released this 28 Sep 06:24

dsh-version-update 1.4.1

安装与修复不再被宿主自己的工作目录挡住。

修复

  • 宿主从自己的树内启动时,安装和随后的修复都会失败。 npm 的 reify 替换一个包的方式,是先把被替换的目录改名让开;而 Windows 不允许 rename 一个「有任何活进程把它当工作目录」的目录——目录本身被占用报 EBUSY,被占用的子目录会让父目录的 rename 报 EPERM。dsh web 继承 shell 的 cwd,于是一个从自己树内启动的宿主,恰好把 npm 即将替换的那个目录交到了 npm 手里:0.1.7-rc.2 的每一次安装都死在 dsh\lib 上的 EBUSY,而 npm 自己的 cwd 正是那个目录;紧接着跑的那次修复则栽在同一个条件上,以一句裸 EPERM 收场。npm 现在从临时目录启动——在本插件管理的每一棵树之外。分离的 relaunch helper 也一并挪开,因为它正是那个可能要把 live 树 rename 回去的进程;而替换进程的 cwd 仍然走 payload,它必须与被替换的宿主一致。
  • 因树被占用而失败的 rename 现在会说明原因。 live 树 rename 上的 EPERM 读起来像权限问题,会让人去找一个并不存在的权限。这里的 EPERM/EBUSY/EACCES 现在会被翻译成真正的原因和出路——退出 dsh,然后在终端里 npm install -g @deepseek-ai/dsh@latest。而 rename 目标只是名字被占时,在 Windows 上同样报 EPERM,这种情况被刻意不归因到占用。
  • 什么都没恢复的修复不再被记成成功。 只要 package.json 还能解析,审计记录就写 ok,于是一次已经告诉面板自己失败(且把 treeHealth.healthy 留成 false)的修复,在 history.json 里却是一次干净的修复。它现在与面板消息、operations.end、treeHealth.healthy 用同一个判据。

测试

250 用例通过,tsc --checkJs 干净。

Full Changelog: v1.4.0...v1.4.1

1.4.0

Choose a tag to compare

@SuCriss SuCriss released this 18 Sep 08:15

dsh-version-update 1.4.0

面板的读路径不再能装东西,回滚本身可以再回滚,静默策略每次开机都保证查一次。

新增

  • 每次开机保证查一次,auto 不再靠运气。 checkAt 只是一个墙钟时刻:机器在那个时刻没开机就整天错过,而一个生命周期里从不包含那个时刻的宿主(短会话、睡过去的笔记本)是每天错过——mode: 'auto' 因此在任何不常开的机器上都形同虚设。调度器现在在启动后 60 秒(STARTUP_CHECK_MS)挂一个一次性检查。只在 mode: 'auto' 下挂:off 和 notify 是用户在说"告诉我,别动手",两者都不需要一个后台 registry 读取。mode 在定时器触发时重读,而不是沿用挂载时的值——延迟期间改主意是被尊重的,切走 auto 连一次 registry 往返都不付。policyChanged 也会重新挂载它:打开 auto 本身就是用户在要求无人值守安装,而面板那次 acting read 是在旧 mode 下跑完的,没有这一条,一个在 off 下启动、之后切到 auto 的宿主会一直等到下一个 checkAt——没配置就是永远不查。

修复

  • 一个 GET 能替换整棵安装树。 GET /check 会喂给自动更新决策,于是任何打到该 URL 的请求——浏览器预取、第二个标签页、任何遍历 loopback 路由族的本机进程——都能启动一次静默安装并重写 551 MB 文件;而面板自己那个显式「检查」按钮反倒被保证永不安装,方向是反的。该路由现在只观察:刷新调度器的事实以便「上次检查」保持诚实,永不安装、也永不泊车。动手的那次读取搬到了新的 POST /check/auto,由面板在打开时调用一次——正是这个挂载点,让一个没有 checkAt 的静默 mode: 'auto' 可达。
  • 一次回滚会把自己撤销。 恢复之后的那次刷新用的是裸读,会喂给自动决策——于是 mode: 'auto' 下,回滚到旧版本几秒后就被静默装回刚刚滚离的那个版本,而页面还显示着旧的那个。那次刷新现在走观察。
  • 读一次快照卡会卡住整个宿主。 listSnapshots 为每个条目跑一遍 inventory()——整棵树的递归 lstat——再用 JSON.stringify 和 meta.json 逐项比对。本机这棵安装树(26 513 文件 / 551 MB)一次 inventory 实测 1.578 s,而面板每次检查都读这个列表,于是几个快照就能把事件循环堵上数秒。校验现在分两级:列表与裁剪只读元数据(meta.json 自洽且声明版本与目录名相符),而两条不可撤销的路径——恢复、以及复用既有快照——仍然跑完整的 inventory 比对。这样做是可靠的,因为一个快照只有在拷贝完成之后才会被 rename 成版本名;撕裂的拷贝住在 .tmp-* 下,永远不是版本名。
  • 一次恢复是不可逆的。 恢复把快照拷到安装上,被它替换的版本就此消失。restoreSnapshotAsync 现在把让开的那棵树收养为那个版本的快照(adoptSnapshot)——同卷 rename,不是拷贝,所以额外代价是一次 inventory 走查(1.6 s),而不是整棵树拷贝实测的 87.91 s。因此回滚可以被再回滚。当被替换的版本已经有一份完好快照时收养被跳过,且收养自己的裁剪会被告知保护这次恢复要去的那个版本。
  • 恢复路径把自己的成功报成失败。 一次恢复要拷整棵树——本机实测 87.91 s——而面板给每个请求 15 s 就中止。面板现在点名正在恢复的版本(正在恢复 <版本>),并把那一个请求的预算提到 10 分钟。
  • 被拒绝的操作没有任何反馈。 面板只在 state.status === 'error' 时渲染 state.error,而一次被拒的恢复、一次失败的快照删除、一次被拒绝的策略保存都会让状态停在 ready——于是完全看不到任何提示。现在任何 error 都会渲染。
  • 只有字节校验不过的快照会卡死整个修复。 repairTree 挑最新的候选恢复一次;一个通过了元数据校验却没过字节校验的快照,此前会带着整个修复一起失败,而不会去试下一个候选。现在它按从新到旧走完候选直到有一个恢复成功,且一次成功的修复不再把失败的候选折进它的健康结论。

变更

  • 新端点 POST /api/dsh-version-update/check/auto——唯一能让更新策略动手的检查。GET /check 与 POST /check/run 现在都只观察。三者仍都会刷新 lastCheck。
  • POST /snapshots/restore 的结果可能带 backup(被收养为快照的那个版本)和 backupError(收养为什么失败;恢复本身仍然成功)。
  • 快照行现在渲染宿主一直在发的负载体积(entry.bytes)。

移除

  • 死导出 CHANNELS——全仓库零引用。

测试

246 用例通过,tsc --checkJs 干净。

Full Changelog: v1.3.0...v1.4.0

1.3.0

Choose a tag to compare

@SuCriss SuCriss released this 18 Sep 03:26

dsh-version-update 1.3.0

重启不再有第二条路径,删除快照不再是死按钮。

变更

  • 重启只由面板的按钮触发,没有别的入口。 策略字段 restart(ask / auto)、宿主侧的 restartAfterDelay 兜底、/restart/cancel 路由、以及浏览器半区的 offer / 倒计时弹窗全部删除。安装落定后不弹任何窗,唯一交出端口的方式是点「立即重启」。理由正是旧兜底存在的原因本身:安装会重写页面赖以提供的那些文件,所以本该回答弹窗的页面往往已经是白屏;而一个会自己重启的宿主,和一个崩溃的宿主无法区分。仍写着 "restart": "auto" 的 policy 文件能正常加载,该键被忽略,而不是被"修"成新形状。
  • 「立即重启」按钮从「安装任务」卡移到「当前安装」卡。 前者只在任务非 idle 时渲染,而这个按钮存在的原因恰恰是任务 idle 的时候:安装落定后刷新页面,宿主报的就是 state: 'idle' + needsRestart: true——最该出现它的时刻它不渲染。现在它和「检查」按钮同一行,正上方就是解释它为什么在那里的那行字,页面上任何时刻有且只有一个。

修复

  • 「删除快照」看起来是个死按钮。 removeSnapshot 在路由处理器里同步递归 unlink 快照目录,而一个快照是已安装包树的完整副本——本机那个是 223 MB,实测一棵 129 MB / 15 569 文件的树在 Windows 上要 5.6 s(Defender 逐条目扫描),超过面板 15 s 的请求超时,并且运行期间冻住整个宿主。用户看到的是:第一次点击武装该行,第二次点击毫无反应,行还在那里。现在删除改为把目录 rename 成隐藏的 .trash-* 墓碑——元数据操作,快照多大都是瞬时的——立刻带回新列表作答,字节交给线程池回收。墓碑不是版本名,所以调用返回的瞬间该版本就离开了面板。
  • 被中断的进程留下的快照目录从来没被回收过。 一次中断的拷贝(.tmp-*)和一次 unlink 随宿主一同死掉的墓碑(.trash-*)对面板和每一轮 prune 都是隐形的,因为两者都不是版本名——本机有一个从 2026-09-17 就一直躺在库里。现在宿主启动时、以及每次丢弃之后都会跑一次清扫。

测试

232 用例通过,tsc --checkJs 干净。

Full Changelog: v1.2.0...v1.3.0

1.2.0

Choose a tag to compare

@SuCriss SuCriss released this 18 Sep 03:29

dsh-version-update 1.2.0

安装过程第一次有了可看的进度,源下载慢时有退路,安装前有本地预检,失败后能恢复回它替换掉的那个版本。

关于版本号:本 tag 指向 fdf74f7(把快照管理与重启诊断那条分支并回主线的合并提交,
也是这批功能真正落地的地方),但该提交的 package.json 仍写着 1.1.8 ——
版本号是连同 1.3.0 一起在 7688b85 里提交的,1.2.0 从未作为一次单独的提交存在过。
需要装这个版本的话请以 tag 为准,不要以 manifest 里的版本字符串为准。

新增

  • 安装卡从滚动的日志变成进度条。 npm 在抓取阶段什么都不打印、目录也不动,面板此前只能显示一行快照然后就沉默。宿主现在把一次运行建模成 preparing → snapshot → download → extract → done,并且只度量它真能度量的东西:快照自身的字节与文件数、安装树的字节数(以快照的实测大小作分母)、以及 npm 自己的缓存暂存文件 _cacache/tmp(按 npm 自己的解析方式定位)作为"下载确实在动"的唯一证据。没有分母时进度条故意是不确定的,只显示看到的字节,绝不给出它支撑不了的百分比;一个没有信号的阶段被当作 blind 而不是"卡住",因为"没有信号"和"没有变化"是两件不同的事。npm 的输出仍然收集——那是让一次运行看起来还活着的东西——但退到「显示详细日志」开关后面,不再往用户脸上推。
  • 备用安装源(淘宝镜像)。 POST /update 接受可选的 source:auto / official / mirror,在确认卡上选;auto 继续跟随真正服务了版本读取的那个 registry。宿主永不接受来自浏览器的 URL——地址写在入口配置里(registry,以及新增的 mirrorRegistry,默认 https://registry.npmmirror.com),不是这三个标识符之一一律 400。一次运行 30 秒没有可测进展会说明并点名镜像作为下一步,但不中断它:在 reify 中途杀掉 npm 正是留下半提交树的原因。落定之后失败卡提供一键用镜像重试同一版本。
  • 安装前的本地预检。 确认卡读取 GET /preflight(npm 可解析、安装父目录可写、磁盘余量、快照存储可用),在用户还在决定时就把答案摆在面前。每项检查都降级为警告而不是拒绝,并且不跑 npm、不碰网络——它是建议,不是闸门。
  • 本宿主运行做过什么的活动时间线。 安装、恢复与修复保留在有界的内存日志里(最新 200 条),经 GET /operations?since=N 与一个单调游标提供,面板因此是追加而不是重读。宿主重载即重置,并且一次安装只记录它的落定——绝不记一个编造的开始。
  • 等待中的自动任务可以取消。 POST /pending/cancel 解除一个泊车的自动安装及其窗口唤醒 / busy 重试,且不碰策略与每日检查;面板显示目标版本与它是什么时候被排入的。
  • snapshotMaxBytes 给快照存储设上限。 一次成功创建之后,先清损坏的与超出保留数的,再从最旧可用的开始清,直到存储符合配额——刚做出来的那个快照永远存活,即使它自己就超了配额。
  • 失败的安装恢复它替换掉的那个版本。 修复路径接受 preferredVersion——那次运行开始时的版本——并恢复那个确切的快照,而不是"最新的那个快照"。调用方知道用户跑的是哪个版本,而安装本身出问题的时候,最新恰恰是错的答案。

变更

  • 安装前快照变成强制项。 requireSnapshot(默认 true)让写不出快照的安装在 spawn npm 之前就中止(FatalPreparationError),而不是记一行日志跨过去——一次没有回滚点的安装,恰恰是那种可能让用户手上一个能跑的版本都不剩的安装。要回到旧的尽力而为行为就设成 false。
  • 树修复把"起不来的树"当作损坏。 只有 manifest 从来不够——一次被中断的安装可以留下一个能解析、但 lib/bin.js 已经没了的树——所以 inspectTreeHealth 现在报告 launcherOk,并且每一轮修复都要求它,不只是失败之后那一轮。recent-retirement 保护是让这个检查不至于和还在写盘的 npm 抢跑的东西。

测试

234 用例通过,tsc --checkJs 干净。

Full Changelog: v1.1.8...v1.2.0

1.1.8

Choose a tag to compare

@SuCriss SuCriss released this 13 Sep 04:45

dsh-version-update 1.1.8

修复

  • 热替换会把样式表从活下来的那个实例脚下抽走。 installStyles() 发现 <style> 已存在时返回一个空 disposer:这对"泄漏"是对的,对"移除"是错的——热替换的常见顺序是新挂载先跑、旧实例后 dispose,于是新实例没拿到所有权,旧实例的 dispose 删掉了它正在依赖的规则,设置页裸奔无样式。现在按 claim 计数,只有最后一个释放;disposer 重复调用不会把计数扣穿。
  • 每一次轮询都重新读取并解析整条审计历史。 这些事实随每个响应返回,而 appendHistory 是整文件重写并截断到上限:面板每 800 ms 就付一次 readFileSync + 全量 JSON.parse,还是在同一个正泵着 npm 输出的进程里。摘要现在按 (mtime, size) 复用,并且本进程自己的写入立即失效缓存——修复路径可能在同一毫秒记两条,而到上限后的重写完全可能字节数不变,只靠时间戳会给出昨天的历史。

1.1.7

Choose a tag to compare

@SuCriss SuCriss released this 13 Sep 04:45

dsh-version-update 1.1.7

新增

  • 设置页开始显示 host 早就知道的安装树状况。 从 1.1.0 起,组合层就会测量自己启动于的那棵全局树、在被中断的 npm 留下半提交状态时修复它,并把结果塞进每一个轮询响应——而浏览器半区完全忽略它。于是一台 dsh 的 manifest 已经消失、进程还靠内存继续提供服务的机器,面板看起来一切正常。现在树不健康、或启动时被重建过就会出现一张卡:点名读不到的目录、还占着磁盘的 retired 目录数量、以及修复没能完成的部分。健康且无事可报时什么都不显示——每次都报平安的卡片会被用户训练成跳过。
  • 该结论同时经由安装轮询送达,不只靠面板自己的检查:产生这个事实的修复发生在安装失败之后,而那正是轮询在盯的时刻,一次陈旧的 check 覆盖不了它。响应里没有该字段时保留上一次结论而非清空——缺席的含义是"这台 host 从未找到树",不是"上一棵 healed 了"。

1.1.6

Choose a tag to compare

@SuCriss SuCriss released this 13 Sep 04:45

dsh-version-update 1.1.6

新增

  • 快照可以删除了。 POST /snapshots/delete(body { version })+ 快照中心行内操作。removeSnapshot 从写下那天起就存在,却没有任何入口能碰到它——清一个备份只能打开数据目录、在文件管理器里猜哪个目录对应哪个版本。不可用快照(中断拷贝留下的、永远恢复不了的那些)此前一个按钮都没有,而它们恰恰最该被手动清掉。
    • 与恢复同一道闸门:非精确版本 400;本机有安装在跑 409——正在安装的运行失败时要靠刚做的那个快照脱身,中途删掉等于留下坏树无路可走。
    • 走机器锁但不碰安装树:不能与另一个宿主的同版本恢复交叉(一边 copy 一边 unlink)。
    • 不写审计记录:history 答的是"这台机器跑过哪个版本",删备份不是版本迁移,写了会被读成一次没发生的恢复。
    • 成功响应带回剩余列表,被删的行随答案消失,不靠客户端猜。
    • 同一行点两次才删(此处没有撤销);任何一次检查都会解除武装,避免按旧列表武装的行打到已经不存在的版本。

变更

  • npm test 不再指向 test/protocol.test.js:该文件在这个仓库里从未存在过(git 无记录,连删除都没有)。它混在 14 个真实路径里被 node 折掉不出声,单独点它则 Could not find。协议层本就由 policy / routes / index 三处覆盖,所以选择删掉幽灵而不是为名字造一个文件。