dsh-autorestart 0.8.4 → 0.8.5

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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.5 — 2026-10-30
4
+
5
+ ### 备份
6
+
7
+ - **每份备份都有身份,只剩「安全份」能被自动回滚**(用户决定,2026-10-30):备份目录里多了一张 `meta.json`,把每份拷贝标成 `known-good`(安全份)/ `snapshot`(本次快照)/ `unknown`(旧备份,未验证)。第一次安装时种子一份安全份;每次启动成功(健康检查通过)后,把这次重启前拍的那份提升为安全份;重启失败时只回到最新的安全份——**本次快照永远不会被当成回滚目标**,因为它可能正是坏掉的那一份(改坏插件再点重启就是这个场景)。
8
+ - **没有安全份就不动任何文件**:回滚前先找 newest known-good,找不到就零还原,报告里写 `no-known-good: <原因>`,看门狗也不 arm(没有可还原的目标,挂上去只会再写一遍坏配置)。
9
+ - **保留 3 份,安全份受保护**:默认 `maxBackups = 3`,超出删最旧的那份,但 `known-good` 不会被 prune 掉。
10
+ - **旧备份不会被自动回滚**:没有 `meta.json` 的拷贝(身份层之前的版本)标成 `unknown`,只展示、不参与自动回滚。
11
+ - **设置页看得见身份**:备份列表每行先写「身份 · 版本」(安全份 / 本次快照 / 旧备份(未验证)),下面是原有的时间与大小;列表上方加一段中文说明,讲清两种身份、看门狗只回滚安全份、第一次安装会生成安全份、最多保留 3 份、旧备份不会被自动回滚、手动回退会先二次确认再重启。`?state=1` 与 `health?state=1` 的 `backups.items[]` 因此多了 `kind` 与 `version` 两个字段。
12
+ - **可以手动回退到任意一份备份**(含更早、未验证的那份):列表每行多一个「回退到这一份」按钮,点了先弹二次确认(写清还原哪一份、当前状态会被覆盖、还原后会自动重启),**确认后才动**;取消则什么都不发。服务端新增 `POST /__dsh_autorestart/rollback-to`,只接受**备份目录名**(必须是本机列表里的那一份,路径 / 路径穿越直接拒绝),顺序是:①先把当前状态存一份快照(`reason: before-manual-rollback`,所以这次回退本身还能再退回来)②还原选中的那份 ③写 `last-manual-rollback.json` 回执(何时 / 哪一份 / 结果 / 那份安全快照)④走既有重启流程请求一次重启(插件自己绝不 kill 进程)。当前状态存不下来、或看门狗正持有回滚锁时直接拒绝(409),不会出现「还原了一半」。
13
+
14
+ ### 现场
15
+
16
+ 三次真机反馈说的是同一件事:**浮层说的不是真话**。先是重启被打断、备份早就结束了,「DSH 重启前备份中」却一直挂着不消失;修完这个,又出现备份做完后**整整 25 秒**界面(浮层和底部胶囊)一起说「正在重启…」而实际什么都没在重启——宿主把重启交给了 detached helper,helper 还在等自己的空闲复查,被拒之后才把阶段改回等待;再后来是「被打断之后我还有大活要干,一直悬浮很烦人」。
17
+
18
+ ### 修复
19
+
20
+ - **阶段必须有去有回**:新增 `deferRestartStage()`,把「重启被推迟」的每一条路径都写成 `waiting`:闸门回收(又有会话忙)、detached helper 拒绝杀进程、重排重试、helper 调度失败;记录里带 `reason`(`deferred: conversation-active` 之类),事后能看出是推迟而不是在跑。放弃重启时一并清掉阶段。
21
+ - **`snapshotting` 只在真拷贝期间存在**:备份函数一返回(拷完 / 取消 / 抛错),阶段立刻离开 `snapshotting`——拷完就标 `restarting`,取消就清空,抛错就走推迟。以前从拷贝结束到真正执行重启之间那一整段(arm watchdog、写判定记录、调度 helper)都还挂着 `snapshotting`,一旦推迟就再也回不来。
22
+ - **`restarting` 只表示「即将 / 正在杀进程」**:备份一结束、标记它**之前**先当场复查一次空闲门,被挡就在同一次 tick 退回等待(`deferred: conversation-active after snapshot: …`),命令行不跑、watchdog 不 arm;进入 `restarting` 之后(等 helper / 等调度)**每 350ms** 复查一次,一有会话忙立刻降级回等待(`deferred: conversation-active while restarting`)并重新排上——几十秒的假 `restarting` 窗口没有了。
23
+ - **残留中间态不许跨进程继承**:启动时读到 `snapshotting` / `restarting` 一律改写——新进程起来是 `back`,同一进程里插件重载则是 `waiting`(`reason: boot: …`)。
24
+ - **同一次重启请求不重复备份**:快照是这次重启的回滚点,不因为重启要等待就作废。请求里记下本次已完成快照(`state.restart.snapshot`:请求时刻 + 目录 + 体积/文件数),重试时三个条件都满足就直接复用——**同一请求**、**目录还在**、**manifest 体积对得上**;任一不满足才重新拷贝。请求被取消 / 完成,标记随请求对象一起消失,下次重新备份。用户那次 205 MB 白拷一遍的事故不会再有。
25
+ - 新增 `restartPhase.reason`(状态路由与 health 简报都带)。
26
+
27
+ ### 界面
28
+
29
+ - **被暂缓就说被暂缓,绝不说「正在重启」**:`waiting` 且 `reason` 以 `deferred:` 开头时,浮层文案是「**有新会话在跑,重启已暂缓**」+ 小字「等它结束会自动继续 · 已等 N 个会话」(英文 `A conversation just started — restart postponed` / `It will retry automatically when idle`);渲染优先级最高,同一次渲染里阶段还写着 `restarting` 也直接画暂缓文案,旧词一帧不留。「正在重启…」只在真的进入杀进程阶段才出现。
30
+ - **暂缓卡片是通知,不是进度条**:显示 **4 秒**后自动收起(胶囊继续显示「待重启 · 等待 N 个会话」),新的暂缓(新的阶段时间戳)会再通知 4 秒;「备份中」「正在重启…」不受影响,活儿还在就一直显示。
31
+ - **陈旧保护**:`snapshotting` 必须 10 秒内有过更新(拷贝期间每个进度 tick 都刷新 `at`),任何中间阶段 15 秒内 `at` 没变就当过期,**直接不画**。
32
+ - **状态轮询跟着状态提速**:重启进行中(`pendingRestart` 且阶段是等待 / 备份中 / 正在重启)时,共享轮询从 2 秒临时降到 **500ms**,被新会话打断这件事一秒内就能反映到浮层;重启结束立刻恢复 2 秒。页面隐藏时不轮询,可见性恢复立刻补查一次;同一时刻只允许一个在途请求。
33
+
34
+ ### 文档
35
+
36
+ - **npm 一句话描述换成新口径**(`package.json` 的 `description`):原来只讲「等空闲→重启→续跑」,现在把「重启前快照 + 起不来自动还原 + 倒计时关机 + Windows 桌面 / 托管 dsh web(Windows / WSL / Linux)都可用」一起讲清;这句是 npm 搜索结果那一行,**必须随新版本发布才生效**。
37
+ - **README(英)/ docs/README.zh.md 补行为说明**:被打断时显示「重启已暂缓」而不是「正在重启」、暂缓卡片 4 秒后收起、阶段超过 15 秒无更新视为过期不再显示(胶囊仍显示等待数)。
38
+
39
+ ### 验证
40
+
41
+ - `node --test plugins/dsh-autorestart/test/*.test.mjs` → **全绿**(细节见本次提交说明)。
42
+ - 宿主侧新增用例:拷完被拒的重启最终是 `waiting` 且 arm watchdog 那一刻已是 `restarting`;helper 门禁拒绝后阶段回到 `waiting`;另一进程残留的 `snapshotting` 不带进新进程;同进程重载残留的 `restarting` 被重置;备份完成后会话立刻变忙 → 同一次 tick 就是等待;`restarting` 期间会话变忙 → 守卫回调一触发即降级(复查周期断言落在 300–500ms);备份完成 → 暂缓 → 重试**不产生第二个快照目录**且 `snapshotBytes` 不变;取消后重新请求 → 重新备份;快照目录被删 → 重新备份。
43
+ - 界面侧新增用例:15 秒 / 10 秒陈旧边界;`snapshotting` 15 秒无更新不渲染、3 秒内有更新正常渲染;`waiting` + `deferred:` 渲染「重启已暂缓」且不含「正在重启」(中英各一条);真 `restarting` 仍「正在重启…」;降级后下一次渲染即改口;暂缓卡片 4 秒后收起、再暂缓再出现;运行中的卡片不会被收起;轮询 500ms / 2000ms 切换、隐藏页不发请求、慢响应单飞。
44
+ - 真机复现路径由主会话验证(打断一次重启,看浮层是否立刻改口、是否 4 秒后收起、重试是否不再拷一遍)。
45
+
3
46
  ## 0.8.4 — 2026-10-29
4
47
 
5
48
  **只改文档与版本号,行为没有变化。**
package/README.md CHANGED
@@ -201,7 +201,7 @@ what it adds before you install it.
201
201
 
202
202
  ## Install
203
203
 
204
- Current version: **0.8.4**.
204
+ Current version: **0.8.5**.
205
205
 
206
206
  From npm — the package is published under the name `dsh-autorestart`:
207
207
 
@@ -300,13 +300,24 @@ Two blocks of the settings page are worth a word, because they show facts instea
300
300
  one, its size, whether the installed plugins are in it and where it lives are all real. Only
301
301
  the directory metadata is read — the contents are never walked (one copy can be 150 MB). With no
302
302
  backup at all it says: "No backup yet; one is written before every restart."
303
- Every copy is **two labelled rows**, so nothing runs into anything else:
303
+ Every copy leads with **what it is** — its identity — so the list answers the only question that
304
+ matters ("may this one be restored?"), then two labelled rows, so nothing runs into anything else:
304
305
 
305
306
  ```
307
+ Identity Known good · version 0.8.5
306
308
  Backup time 2026-10-29 22:08
307
309
  Backup size 156 MB (plugins included)
308
310
  ```
309
311
 
312
+ The identity is one of **known good** (a copy a boot has already proven — the only kind an automatic
313
+ rollback may restore), **this restart's snapshot** (taken right before a restart and not proven yet)
314
+ or **old backup (unverified)** (written before the identity layer existed, or without an identity
315
+ file — shown, never rolled back automatically). A short paragraph above the list says the same in
316
+ plain words, plus the rules behind it (only a known-good copy is rolled back, the first install
317
+ seeds one, at most three copies are kept, a manual rollback asks for confirmation and then
318
+ restarts). The identity and the version it was proven with come from the Host
319
+ (`backups.items[].kind` / `backups.items[].version`) — the browser cannot read `meta.json`.
320
+
310
321
  The time is the local clock of the machine that took the copy, **to the minute and with no
311
322
  timezone offset** (a `+08:00` only adds noise — the reader is looking at their own clock). The
312
323
  size picks its own unit (`92 KB` / `156 MB`), keeps the tenth only when it says something
@@ -358,7 +369,7 @@ show up in the Plugin Manager and in the config editor.
358
369
  | `continuationText` | `''` | Overrides the default continuation prompt text. |
359
370
  | `backupOnRestart` | `true` | Snapshot the profile before every restart, so a restart that leaves the host unable to boot can be rolled back. |
360
371
  | `backupIncludePlugins` | `true` | Include the installed plugin tree in that snapshot (about 117 MB here, ~3 s). `false` keeps only the configuration files. |
361
- | `maxBackups` | `2` | How many snapshots are kept; older ones are deleted when a new one is written. |
372
+ | `maxBackups` | `3` | How many snapshots are kept (the current one plus the two before it); older ones are deleted when a new one is written — **except the newest known-good copy, which is never deleted**. |
362
373
  | `rollbackTimeoutMs` | `90000` | How long the detached watcher waits for the new host before it restores the snapshot. |
363
374
  | `restartCommandTimeoutMs` | `30000` | How long the restart command may run before the plugin stops waiting for it. A command that starts the host in the foreground never returns — that is reported as "still running", not as a failure. |
364
375
  | `rollbackCopyTimeoutMs` | `120000` | How long ONE restore step (the configuration files, then the plugin tree) may take. A restore that does not finish in time is given up and the reason is written into the rollback report. |
@@ -440,11 +451,16 @@ with its plugins is ~150 MB). The button looked dead. It is not — and now it s
440
451
 
441
452
  | Stage | What the interface shows | Where the data comes from |
442
453
  |---|---|---|
443
- | `waiting` | Nothing on the overlay — the **centered overlay card does not appear at all** while the gate only waits (no backup is running, nothing happens but waiting). The capsule says it in one short line ("pending restart · waiting for N sessions"). | the idle gate's blockers |
454
+ | `waiting` | Nothing on the overlay — the **centered overlay card does not appear at all** while the gate only waits (no backup is running, nothing happens but waiting). The capsule says it in one short line ("pending restart · waiting for N sessions"). **One exception:** when a restart was interrupted by a new conversation, the card stays and says so — "A conversation just started — restart postponed" + "It will retry automatically when idle" — instead of claiming "Restarting…". | the idle gate's blockers |
444
455
  | `snapshotting` | The **centered overlay card in the middle of the shell**: "Backing up before DSH restart" + a real progress bar, "12.3 MB / 40 MB" and a percentage; the settings page and the tooltip keep the long line, in the same words ("Backing up before DSH restart (about 12s, 78 MB done)…"). The card has NO minimum shown time: it lives exactly as long as the Host reports the stage (a 92 KB copy is gone in about 5 ms, a slow one stays as long as it runs); the size picks its own unit (`92 KB` / `156 MB`) so a small backup never reads "0 MB" | the REAL copy loop: files and bytes written, measured total |
445
- | `restarting` | "Restarting; the work continues by itself in a moment…" | the host is on its way out |
456
+ | `restarting` | "Restarting…" — and ONLY while the process is really being killed: the idle gate is re-checked every 350 ms, so a conversation that starts here turns the card into the postponed one above instead of leaving a false "Restarting…" on screen | the host is on its way out |
446
457
  | `back` | "Back — carrying on where we left off", for **1.5 seconds**, then the card is gone | the new host, once it boots |
447
458
 
459
+ **The overlay never hangs.** When a restart is postponed back to waiting, the stage is reset and the
460
+ overlay disappears at once; and a stage that has not been updated for more than **15 seconds** is
461
+ treated as stale and is not drawn at all (the capsule keeps showing the waiting count). A stale stage
462
+ can therefore never sit on top of the interface.
463
+
448
464
  Three things make this honest rather than decorative:
449
465
 
450
466
  1. **The first stage is written by the click itself** and persisted at once, so the pill (which
@@ -547,8 +563,20 @@ it was started from, so a restart that leaves the host unable to boot can be und
547
563
  `backupIncludePlugins` is turned off — the installed plugin tree (`node_modules`). Snapshots live in
548
564
  `<storage>/dsh-autorestart/backups/<timestamp>/` with a `manifest.json` next to them. On a profile
549
565
  like this one the full snapshot measures about 117 MB and takes under three seconds.
550
- - **How many are kept:** the newest `maxBackups` snapshots (default `2`); older ones are deleted when
551
- a new one is written.
566
+ - **The rollback target is the newest PROVEN copy, not the newest copy** (user decision,
567
+ 2026-10-30). Every copy carries its identity in a `meta.json`: `known-good` (a copy a boot has
568
+ already survived), `snapshot` (taken right before a restart, not proven yet) or `unknown` (written
569
+ before the identity layer). The snapshot taken right before THIS restart is never the rollback
570
+ target — it may be the broken state itself (that is exactly the "I edited a plugin and clicked
571
+ restart" case). The first install seeds one known-good copy, and every boot that passes its health
572
+ check promotes the snapshot of that restart to known-good.
573
+ - **No known-good copy, no restore at all.** When nothing has been proven yet, the plugin restores
574
+ nothing and says why: the report carries `no-known-good: <reason>`, the profile is left exactly as
575
+ the failed restart left it, and no watcher is armed (a watcher with nothing to restore would only
576
+ write the broken state back).
577
+ - **How many are kept:** the newest `maxBackups` snapshots (default `3`: the current one plus the two
578
+ before it); older ones are deleted when a new one is written. The newest **known-good** copy is
579
+ never deleted, whatever the limit says — it is the only thing a failed boot can roll back to.
552
580
  - **How a failed boot is detected:** a small watcher runs **outside** the host process (so it survives
553
581
  the host being killed). It waits for the **new** instance and probes **its** health route for
554
582
  `rollbackTimeoutMs` (default `90000`). The new instance publishes its own port and its own boot id
@@ -564,6 +592,14 @@ it was started from, so a restart that leaves the host unable to boot can be und
564
592
  usually kills the very process the plugin runs in, so the plugin cannot confirm anything itself.
565
593
  The watcher is therefore started **before** the command runs, and it is the one that decides
566
594
  whether the new instance came up.
595
+ - **A manual rollback to ANY copy** (user decision, 2026-10-30): the settings page offers
596
+ "roll back to this copy" on every row, so a copy from last week — even an unverified one — can be
597
+ restored on purpose. It asks first (which copy, that the current state is replaced, that a restart
598
+ follows); the Host saves the current state as a fresh snapshot BEFORE it restores anything, writes
599
+ `last-manual-rollback.json`, and only then requests a normal restart (the plugin never kills the
600
+ host itself). The route (`POST /__dsh_autorestart/rollback-to`) takes a backup NAME only — a path
601
+ or a traversal is refused before anything is read — and it stands down when the watchdog already
602
+ holds the rollback lock.
567
603
  - **The command is judged, not assumed.** A restart command that cannot be started, or that exits
568
604
  with an error, is reported as a failure (with its exit code and output) and the snapshot is
569
605
  restored — it is never reported as a successful restart.