dsh-autorestart 0.3.3 → 0.3.4
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 +56 -2
- package/cordis.patch.yml +11 -0
- package/docs/README.zh.md +23 -0
- package/index.js +191 -1
- package/lib/backup.js +350 -0
- package/lib/restart.js +129 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,13 +29,34 @@ round starts, so the session goes idle and the restart can proceed. The goal is
|
|
|
29
29
|
restart and continues.
|
|
30
30
|
|
|
31
31
|
The browser shows the state: a compact "pending restart" pill on the left side of the input toolbar
|
|
32
|
-
and a small dot next to the session title in the sidebar. The pill
|
|
33
|
-
the
|
|
32
|
+
and a small dot next to the session title in the sidebar. The pill's text reports how many
|
|
33
|
+
conversations are still in the way (counted once per conversation, and the tooltip explains that a
|
|
34
|
+
conversation with queued-but-not-started work counts too); it does not open a details panel. The dot
|
|
35
|
+
breathes when the restart is about to fire.
|
|
36
|
+
|
|
37
|
+
Before every restart the plugin snapshots the profile, so a restart that leaves the host unable to
|
|
38
|
+
boot is rolled back automatically — see
|
|
39
|
+
[Pre-restart backup and automatic rollback](#pre-restart-backup-and-automatic-rollback).
|
|
34
40
|
|
|
35
41
|
Two safety gates protect the restart. The host checks that no conversation is busy, and the detached
|
|
36
42
|
helper checks the same gate over loopback immediately before it kills the process, so a turn that
|
|
37
43
|
starts in between is never killed.
|
|
38
44
|
|
|
45
|
+
## What it occupies in DSH
|
|
46
|
+
|
|
47
|
+
Nothing here is hidden: this is the complete list of where the plugin attaches itself, so you can see
|
|
48
|
+
what it adds before you install it.
|
|
49
|
+
|
|
50
|
+
| Kind | What it adds |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| Agent tools (6) | `continuum_restart`, `continuum_status`, `continuum_cancel_restart`, `continuum_park_goal`, `continuum_recover_now`, `continuum_handoff` |
|
|
53
|
+
| HTTP routes (4) | Under `<healthPath>` (default `/__dsh_autorestart`): `GET /health` (also `?state=1`, `?gate=1`, `?probeIndex=1`), `POST /park`, `POST /recover`, `POST /handoff`. Loopback or an authenticated browser only |
|
|
54
|
+
| Client slots (2) | `conversation.input.left` — the pending-restart pill (`autorestart-restart-pill`); `sidebar.session.row.leading` — the session status dot (`autorestart-restart-dot`) |
|
|
55
|
+
| System prompt | One "Restart policy" section that tells the agent how to ask for a restart and what the plugin will do |
|
|
56
|
+
| Harness hooks | `agent/status` (tracks which conversations are busy), `tools/pre-execute` (blocks a shell command that would kill or relaunch DSH by hand), and the boot pass that resumes interrupted sessions |
|
|
57
|
+
| Profile row | One bundle row, `dsh-autorestart` |
|
|
58
|
+
| Writes | Only under `<DSH_HOME>/storages/dsh-autorestart/` — `state.json` (checkpoint), `backups/` (pre-restart snapshots), `restart-<gen>.cmd` / `restart-<gen>-watch.ps1` (the detached helper), `last-rollback.json` (rollback report). The only time it writes into the profile itself is a rollback, which copies that snapshot's `cordis.patch.yml`, `package.json`, `pnpm-lock.yaml` and `node_modules` back |
|
|
59
|
+
|
|
39
60
|
## Why
|
|
40
61
|
|
|
41
62
|
| Situation | Without it | With it |
|
|
@@ -116,6 +137,10 @@ show up in the Plugin Manager and in the config editor.
|
|
|
116
137
|
| `blockManualRestart` | `true` | Hard-block an agent that tries to kill or restart DSH with a shell command, and point it at `continuum_restart` instead. |
|
|
117
138
|
| `healthPath` | `/__dsh_autorestart/health` | Health check route. Returns the boot id, the pid, the phase and the self-check flags. |
|
|
118
139
|
| `continuationText` | `''` | Overrides the default continuation prompt text. |
|
|
140
|
+
| `backupOnRestart` | `true` | Snapshot the profile before every restart, so a restart that leaves the host unable to boot can be rolled back. |
|
|
141
|
+
| `backupIncludePlugins` | `true` | Include the installed plugin tree in that snapshot (about 117 MB here, ~3 s). `false` keeps only the configuration files. |
|
|
142
|
+
| `maxBackups` | `2` | How many snapshots are kept; older ones are deleted when a new one is written. |
|
|
143
|
+
| `rollbackTimeoutMs` | `90000` | How long the detached watcher waits for the relaunched host before it restores the snapshot and launches the host again. |
|
|
119
144
|
|
|
120
145
|
The two waits are independent of each other, and both default to two seconds:
|
|
121
146
|
|
|
@@ -141,6 +166,35 @@ restart command of its own:
|
|
|
141
166
|
|
|
142
167
|
Remember that this replaces the whole config instead of merging into it.
|
|
143
168
|
|
|
169
|
+
## Pre-restart backup and automatic rollback
|
|
170
|
+
|
|
171
|
+
A restart is only safe if the host comes back. Before every restart the plugin snapshots the profile
|
|
172
|
+
it was started from, so a restart that leaves the host unable to boot can be undone automatically.
|
|
173
|
+
|
|
174
|
+
- **What is backed up:** `cordis.patch.yml`, `package.json` and `pnpm-lock.yaml`, plus — unless
|
|
175
|
+
`backupIncludePlugins` is turned off — the installed plugin tree (`node_modules`). Snapshots live in
|
|
176
|
+
`<storage>/dsh-autorestart/backups/<timestamp>/` with a `manifest.json` next to them. On a profile
|
|
177
|
+
like this one the full snapshot measures about 117 MB and takes under three seconds.
|
|
178
|
+
- **How many are kept:** the newest `maxBackups` snapshots (default `2`); older ones are deleted when
|
|
179
|
+
a new one is written.
|
|
180
|
+
- **How a failed boot is detected:** the detached helper that kills and relaunches the host also runs
|
|
181
|
+
a small PowerShell watcher. It polls the health route for `rollbackTimeoutMs` (default `90000`). A
|
|
182
|
+
healthy answer ends the watch and nothing is touched.
|
|
183
|
+
- **What happens when the host never answers:** the watcher copies the snapshot back over the profile
|
|
184
|
+
(configuration files, and the plugin tree when the snapshot holds one), writes
|
|
185
|
+
`last-rollback.json` next to the checkpoint — including the tail of the newest crash log written
|
|
186
|
+
*after* this restart — and launches the host again with the restored configuration.
|
|
187
|
+
- **How you learn about it:** the next boot reports it. `continuum_status` gains a `lastRollback`
|
|
188
|
+
object (`at`, `waitedMs`, `rolledBack`, `backupDir`, `crashLog`, `crashTail`), and the next restart
|
|
189
|
+
clears the report so it is never mistaken for a fresh one.
|
|
190
|
+
- **Turning it off:** `backupOnRestart: false` skips the snapshot entirely (no rollback point, and no
|
|
191
|
+
watcher), which is what a user who keeps their own backups may prefer. `backupIncludePlugins:
|
|
192
|
+
false` keeps the snapshot but limits it to the configuration files.
|
|
193
|
+
|
|
194
|
+
The rollback is deliberately conservative: it only runs when the relaunched host never answers its
|
|
195
|
+
own health route. A slow start inside `rollbackTimeoutMs` is never treated as a failure, and a
|
|
196
|
+
healthy host is never touched.
|
|
197
|
+
|
|
144
198
|
## Requirements and limits
|
|
145
199
|
|
|
146
200
|
- **Windows desktop.** `restartMode: app` restarts the DSH desktop app on Windows. Any other
|
package/cordis.patch.yml
CHANGED
|
@@ -31,3 +31,14 @@
|
|
|
31
31
|
blockManualRestart: true
|
|
32
32
|
healthPath: /__dsh_autorestart/health
|
|
33
33
|
continuationText: ''
|
|
34
|
+
# Take a snapshot of the profile before every restart, so a restart that
|
|
35
|
+
# leaves the host unable to boot can be rolled back automatically.
|
|
36
|
+
backupOnRestart: true
|
|
37
|
+
# Include the installed plugin tree in that snapshot (bigger: ~120 MB here,
|
|
38
|
+
# but it is what lets a rollback undo "a plugin upgrade broke the boot").
|
|
39
|
+
backupIncludePlugins: true
|
|
40
|
+
# Keep at most this many snapshots; older ones are deleted automatically.
|
|
41
|
+
maxBackups: 2
|
|
42
|
+
# How long the detached watcher waits for the relaunched host (ms) before
|
|
43
|
+
# it restores the snapshot and launches the host again.
|
|
44
|
+
rollbackTimeoutMs: 90000
|
package/docs/README.zh.md
CHANGED
|
@@ -48,6 +48,29 @@ session resume on boot, durable-goal re-arm, and an agent-callable idle-aware gr
|
|
|
48
48
|
|
|
49
49
|
---
|
|
50
50
|
|
|
51
|
+
## 占用 DSH 的哪些位置(安装前请先看一眼)
|
|
52
|
+
|
|
53
|
+
下面就是它动到的全部位置,没有别的:
|
|
54
|
+
|
|
55
|
+
| 类型 | 具体占用 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Agent 工具(6 个) | `continuum_restart`、`continuum_status`、`continuum_cancel_restart`、`continuum_park_goal`、`continuum_recover_now`、`continuum_handoff` |
|
|
58
|
+
| HTTP 路由(4 条) | `<healthPath>`(默认 `/__dsh_autorestart`)下的 `GET /health`(另有 `?state=1`、`?gate=1`、`?probeIndex=1`)、`POST /park`、`POST /recover`、`POST /handoff`;只接受本机回环或已认证的浏览器 |
|
|
59
|
+
| 前端槽位(2 个) | `conversation.input.left`(待重启小胶囊,id `autorestart-restart-pill`)、`sidebar.session.row.leading`(侧栏状态圆点,id `autorestart-restart-dot`) |
|
|
60
|
+
| 系统提示词 | 一段「重启政策」说明,告诉 agent 怎么请求重启、插件会做什么 |
|
|
61
|
+
| 宿主事件 | `agent/status`(跟踪哪些会话在忙)、`tools/pre-execute`(拦下"手动杀/重启 DSH"的 shell 命令)、启动时的恢复流程 |
|
|
62
|
+
| profile 行 | 一条 bundle 行:`dsh-autorestart` |
|
|
63
|
+
| 写文件 | 只在 `<DSH_HOME>/storages/dsh-autorestart/` 下写:`state.json`(检查点)、`backups/`(重启前快照)、`restart-<代次>.cmd` / `restart-<代次>-watch.ps1`(脱离进程的重启 helper)、`last-rollback.json`(回退报告)。唯一会写进 profile 的时机是**回退**:把快照里的 `cordis.patch.yml`、`package.json`、`pnpm-lock.yaml` 和 `node_modules` 拷回去 |
|
|
64
|
+
|
|
65
|
+
## 重启前备份与自动回退
|
|
66
|
+
|
|
67
|
+
- **每次重启前**先给 profile 拍快照:`cordis.patch.yml`、`package.json`、`pnpm-lock.yaml`,以及(默认开启)已安装插件目录 `node_modules`。本机实测:**2.76 秒 / 117.6 MB / 5,562 个文件**。
|
|
68
|
+
- 快照放在 `storages/dsh-autorestart/backups/<时间戳>/`,带 `manifest.json`;**只保留最新 2 份**,写新快照时自动删旧的。
|
|
69
|
+
- 重启后 helper 会跑一个隐藏的看护脚本,轮询健康端点 **90 秒**(`rollbackTimeoutMs`):正常回答就静默退出,**什么都不动**。
|
|
70
|
+
- 宿主**一直不回应**时:把快照拷回去 → 写 `last-rollback.json`(含"本次重启之后"的崩溃日志尾部,旧日志不会被误当证据)→ **自动再拉起一次**。
|
|
71
|
+
- 下次启动时 `continuum_status` 会报 `lastRollback`(时间、等了多久、是否回滚、快照路径、崩溃日志名与尾部摘要)。
|
|
72
|
+
- 开关:`backupOnRestart`(默认 `true`)、`backupIncludePlugins`(默认 `true`,关掉就只备配置文件)、`maxBackups`(默认 `2`)、`rollbackTimeoutMs`(默认 `90000`)。
|
|
73
|
+
|
|
51
74
|
## 工作方式
|
|
52
75
|
|
|
53
76
|
```
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import crypto from 'node:crypto'
|
|
22
|
+
import fs from 'node:fs'
|
|
22
23
|
import os from 'node:os'
|
|
23
24
|
import path from 'node:path'
|
|
24
25
|
|
|
@@ -57,8 +58,17 @@ import {
|
|
|
57
58
|
restartGate,
|
|
58
59
|
selectGoalDisarms,
|
|
59
60
|
} from './lib/lifecycle.js'
|
|
61
|
+
import {
|
|
62
|
+
backupsRoot,
|
|
63
|
+
clearRollbackReport,
|
|
64
|
+
createBackup,
|
|
65
|
+
readRollbackReport,
|
|
66
|
+
ROLLBACK_REPORT_NAME,
|
|
67
|
+
} from './lib/backup.js'
|
|
60
68
|
import {
|
|
61
69
|
buildCustomCommand,
|
|
70
|
+
buildWatchScript,
|
|
71
|
+
DEFAULT_ROLLBACK_TIMEOUT_MS,
|
|
62
72
|
detectEnvironment,
|
|
63
73
|
runCommandNow,
|
|
64
74
|
scheduleDetachedRestart,
|
|
@@ -196,6 +206,13 @@ function buildConfigSchema() {
|
|
|
196
206
|
recoveryBatchDelayMs: schemastery.number().min(0).default(1200),
|
|
197
207
|
// pause between two resolveAgent attempts inside one boot
|
|
198
208
|
resumeRetryDelayMs: schemastery.number().min(0).default(1000),
|
|
209
|
+
// pre-restart snapshot + automatic rollback when the host never comes back
|
|
210
|
+
backupOnRestart: schemastery.boolean().default(true),
|
|
211
|
+
// include the installed plugin tree in the snapshot (bigger, but it makes a
|
|
212
|
+
// rollback of "a plugin upgrade broke the boot" possible)
|
|
213
|
+
backupIncludePlugins: schemastery.boolean().default(true),
|
|
214
|
+
maxBackups: schemastery.number().step(1).min(1).default(2),
|
|
215
|
+
rollbackTimeoutMs: schemastery.number().min(1000).default(90000),
|
|
199
216
|
})
|
|
200
217
|
} catch (error) {
|
|
201
218
|
schemasterySource = `construction-failed: ${String(error)}`
|
|
@@ -227,6 +244,14 @@ const DEFAULTS = {
|
|
|
227
244
|
blockManualRestart: true,
|
|
228
245
|
healthPath: '/__dsh_autorestart/health',
|
|
229
246
|
continuationText: '',
|
|
247
|
+
// Pre-restart snapshot + automatic rollback. The plugin copies the profile
|
|
248
|
+
// configuration (and, unless disabled, the installed plugin tree) before every
|
|
249
|
+
// restart; when the relaunched host never answers its health route, the
|
|
250
|
+
// detached watcher puts the snapshot back and launches the host again.
|
|
251
|
+
backupOnRestart: true,
|
|
252
|
+
backupIncludePlugins: true,
|
|
253
|
+
maxBackups: 2,
|
|
254
|
+
rollbackTimeoutMs: 90000,
|
|
230
255
|
}
|
|
231
256
|
|
|
232
257
|
/** Merge a loader row config over {@link DEFAULTS}, dropping wrong-typed values. */
|
|
@@ -246,6 +271,11 @@ function normalizeConfig(raw) {
|
|
|
246
271
|
}
|
|
247
272
|
if (config.deferPolicy !== 'auto' && config.deferPolicy !== 'wait') config.deferPolicy = 'auto'
|
|
248
273
|
if (config.restartMode !== 'app' && config.restartMode !== 'command') config.restartMode = 'app'
|
|
274
|
+
const maxBackups = Number(config.maxBackups)
|
|
275
|
+
config.maxBackups = Number.isFinite(maxBackups) && maxBackups >= 1 ? Math.trunc(maxBackups) : 2
|
|
276
|
+
const rollbackTimeout = Number(config.rollbackTimeoutMs)
|
|
277
|
+
config.rollbackTimeoutMs =
|
|
278
|
+
Number.isFinite(rollbackTimeout) && rollbackTimeout >= 1000 ? Math.trunc(rollbackTimeout) : 90000
|
|
249
279
|
return config
|
|
250
280
|
}
|
|
251
281
|
|
|
@@ -454,6 +484,75 @@ function resolveStateFile(config) {
|
|
|
454
484
|
return path.join(home, 'storages', 'dsh-autorestart', 'state.json')
|
|
455
485
|
}
|
|
456
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Resolve the profile directory the running host was started from.
|
|
489
|
+
*
|
|
490
|
+
* The host process does NOT necessarily carry `DSH_PROFILE_DIR` / `DSH_PROFILE`
|
|
491
|
+
* (those are injected into shell commands, not into the host environment), so this
|
|
492
|
+
* walks four independent sources and reports which one answered:
|
|
493
|
+
*
|
|
494
|
+
* 1. `DSH_PROFILE_DIR` — the host exported it.
|
|
495
|
+
* 2. `DSH_HOME` + `DSH_PROFILE`.
|
|
496
|
+
* 3. `--profile <name>` / `--profile=<name>` on the host command line.
|
|
497
|
+
* 4. the only profile whose `dsh.profile.bundles` lists this plugin.
|
|
498
|
+
*
|
|
499
|
+
* @returns {{ dir: string|null, source: string }}
|
|
500
|
+
*/
|
|
501
|
+
function resolveProfileDirDetailed() {
|
|
502
|
+
const explicit = process.env.DSH_PROFILE_DIR
|
|
503
|
+
if (typeof explicit === 'string' && explicit.trim() !== '') {
|
|
504
|
+
const dir = path.resolve(explicit.trim())
|
|
505
|
+
if (fs.existsSync(dir)) return { dir, source: 'DSH_PROFILE_DIR' }
|
|
506
|
+
}
|
|
507
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
|
|
508
|
+
? process.env.DSH_HOME.trim()
|
|
509
|
+
: ''
|
|
510
|
+
const profile = process.env.DSH_PROFILE && process.env.DSH_PROFILE.trim() !== ''
|
|
511
|
+
? process.env.DSH_PROFILE.trim()
|
|
512
|
+
: ''
|
|
513
|
+
if (home !== '' && profile !== '') {
|
|
514
|
+
const dir = path.join(home, 'profiles', profile)
|
|
515
|
+
if (fs.existsSync(dir)) return { dir, source: 'DSH_HOME/DSH_PROFILE' }
|
|
516
|
+
}
|
|
517
|
+
// The host may have been started with an explicit --profile flag instead.
|
|
518
|
+
const argv = Array.isArray(process.argv) ? process.argv : []
|
|
519
|
+
let fromArgv = ''
|
|
520
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
521
|
+
const arg = String(argv[index] ?? '')
|
|
522
|
+
if (arg === '--profile' && index + 1 < argv.length) fromArgv = String(argv[index + 1] ?? '')
|
|
523
|
+
else if (arg.startsWith('--profile=')) fromArgv = arg.slice('--profile='.length)
|
|
524
|
+
}
|
|
525
|
+
const root = home !== '' ? home : path.join(os.homedir(), '.dsh')
|
|
526
|
+
if (fromArgv.trim() !== '') {
|
|
527
|
+
const dir = path.join(root, 'profiles', fromArgv.trim())
|
|
528
|
+
if (fs.existsSync(dir)) return { dir, source: 'host --profile flag' }
|
|
529
|
+
}
|
|
530
|
+
// Last resort: the only profile that lists this plugin as a bundle.
|
|
531
|
+
try {
|
|
532
|
+
const profilesDir = path.join(root, 'profiles')
|
|
533
|
+
const matches = []
|
|
534
|
+
for (const entry of fs.readdirSync(profilesDir, { withFileTypes: true })) {
|
|
535
|
+
if (!entry.isDirectory()) continue
|
|
536
|
+
const candidate = path.join(profilesDir, entry.name)
|
|
537
|
+
try {
|
|
538
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(candidate, 'package.json'), 'utf8'))
|
|
539
|
+
const bundles = manifest && manifest.dsh && manifest.dsh.profile
|
|
540
|
+
&& Array.isArray(manifest.dsh.profile.bundles)
|
|
541
|
+
? manifest.dsh.profile.bundles
|
|
542
|
+
: []
|
|
543
|
+
if (bundles.includes(name)) matches.push(candidate)
|
|
544
|
+
} catch {}
|
|
545
|
+
}
|
|
546
|
+
if (matches.length === 1) return { dir: matches[0], source: 'profile bundles scan' }
|
|
547
|
+
} catch {}
|
|
548
|
+
return { dir: null, source: 'unknown' }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The profile directory, or null when it cannot be resolved. */
|
|
552
|
+
function resolveProfileDir() {
|
|
553
|
+
return resolveProfileDirDetailed().dir
|
|
554
|
+
}
|
|
555
|
+
|
|
457
556
|
/** A logger that never throws and never requires a particular Cordis logger shape. */
|
|
458
557
|
function createLogger(ctx) {
|
|
459
558
|
const logger = ctx.logger
|
|
@@ -1105,6 +1204,13 @@ function createRuntime(ctx, config) {
|
|
|
1105
1204
|
return 'http://127.0.0.1:' + port + resolveHealthPath(config) + '?gate=1'
|
|
1106
1205
|
}
|
|
1107
1206
|
|
|
1207
|
+
/** The plain health URL the rollback watcher polls after the relaunch. */
|
|
1208
|
+
const restartHealthUrl = () => {
|
|
1209
|
+
const port = runtime.webPort
|
|
1210
|
+
if (!Number.isFinite(port) || port <= 0) return null
|
|
1211
|
+
return 'http://127.0.0.1:' + port + resolveHealthPath(config)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1108
1214
|
/** Execute the restart now; the detached helper survives our own death. */
|
|
1109
1215
|
const executeRestart = async () => {
|
|
1110
1216
|
stopPolling()
|
|
@@ -1158,11 +1264,60 @@ function createRuntime(ctx, config) {
|
|
|
1158
1264
|
} else if (url === null) {
|
|
1159
1265
|
log.warn('the web server port is unknown; scheduling an UNGATED restart (the helper cannot re-check the idle gate)')
|
|
1160
1266
|
}
|
|
1267
|
+
// Snapshot the profile BEFORE anything is killed: the configuration files
|
|
1268
|
+
// decide whether every bundle loads, so a snapshot is what makes a
|
|
1269
|
+
// "the host does not come back" rollback possible at all.
|
|
1270
|
+
const stateDir = path.dirname(stateFile)
|
|
1271
|
+
const profileInfo = resolveProfileDirDetailed()
|
|
1272
|
+
const profileDir = profileInfo.dir
|
|
1273
|
+
let backup = null
|
|
1274
|
+
if (config.backupOnRestart === true && profileDir !== null) {
|
|
1275
|
+
backup = createBackup({
|
|
1276
|
+
profileDir,
|
|
1277
|
+
backupsDir: backupsRoot(stateDir),
|
|
1278
|
+
includePlugins: config.backupIncludePlugins !== false,
|
|
1279
|
+
maxBackups: config.maxBackups,
|
|
1280
|
+
})
|
|
1281
|
+
if (backup.ok === true) {
|
|
1282
|
+
log.warn('pre-restart backup written', {
|
|
1283
|
+
dir: backup.dir,
|
|
1284
|
+
bytes: backup.bytes,
|
|
1285
|
+
includePlugins: backup.includePlugins,
|
|
1286
|
+
pruned: backup.pruned,
|
|
1287
|
+
profileDirSource: profileInfo.source,
|
|
1288
|
+
})
|
|
1289
|
+
// A report from an older boot must not be mistaken for this one.
|
|
1290
|
+
clearRollbackReport(stateDir)
|
|
1291
|
+
} else {
|
|
1292
|
+
log.warn('the pre-restart backup failed; continuing without a rollback point', { error: backup.error })
|
|
1293
|
+
}
|
|
1294
|
+
} else if (config.backupOnRestart === true) {
|
|
1295
|
+
log.warn('no profile directory is known; skipping the pre-restart backup', { source: profileInfo.source })
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const healthUrl = restartHealthUrl()
|
|
1299
|
+
const watchScriptPath = backup && backup.ok === true && healthUrl !== null && profileDir !== null
|
|
1300
|
+
? path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
|
|
1301
|
+
: null
|
|
1302
|
+
const watchScript = watchScriptPath === null
|
|
1303
|
+
? null
|
|
1304
|
+
: buildWatchScript({
|
|
1305
|
+
healthUrl,
|
|
1306
|
+
backupDir: backup.dir,
|
|
1307
|
+
profileDir,
|
|
1308
|
+
reportPath: path.join(stateDir, ROLLBACK_REPORT_NAME),
|
|
1309
|
+
appExePath: env.appExePath,
|
|
1310
|
+
timeoutMs: config.rollbackTimeoutMs,
|
|
1311
|
+
// Only a crash log written after this point belongs to this restart.
|
|
1312
|
+
since: new Date().toISOString(),
|
|
1313
|
+
})
|
|
1161
1314
|
const result = scheduleDetachedRestart({
|
|
1162
1315
|
appExePath: env.appExePath,
|
|
1163
1316
|
relaunchDelaySeconds: config.relaunchDelaySeconds,
|
|
1164
1317
|
gateUrl: url,
|
|
1165
|
-
scriptPath: url === null ? null : path.join(
|
|
1318
|
+
scriptPath: url === null ? null : path.join(stateDir, 'restart-' + state.generation + '.cmd'),
|
|
1319
|
+
watchScriptPath,
|
|
1320
|
+
watchScript,
|
|
1166
1321
|
})
|
|
1167
1322
|
log.warn('scheduled detached restart:', result)
|
|
1168
1323
|
if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
|
|
@@ -1244,6 +1399,33 @@ function createRuntime(ctx, config) {
|
|
|
1244
1399
|
get isNewBoot() {
|
|
1245
1400
|
return boot.isNewBoot
|
|
1246
1401
|
},
|
|
1402
|
+
/**
|
|
1403
|
+
* Where the pre-restart backup will be taken from, and which source answered.
|
|
1404
|
+
* `dir` is null when no source could resolve it (the backup is then skipped).
|
|
1405
|
+
*/
|
|
1406
|
+
get profileDir() {
|
|
1407
|
+
return resolveProfileDirDetailed()
|
|
1408
|
+
},
|
|
1409
|
+
/**
|
|
1410
|
+
* The rollback report the detached watcher left behind, or null.
|
|
1411
|
+
*
|
|
1412
|
+
* It means the previous restart never brought the host back and the snapshot
|
|
1413
|
+
* taken before that restart was restored. Read once per process: the next
|
|
1414
|
+
* restart clears it.
|
|
1415
|
+
*/
|
|
1416
|
+
get lastRollback() {
|
|
1417
|
+
const report = readRollbackReport(path.dirname(stateFile))
|
|
1418
|
+
if (!report || report.ok !== false) return null
|
|
1419
|
+
const tail = typeof report.crashTail === 'string' ? report.crashTail : ''
|
|
1420
|
+
return {
|
|
1421
|
+
at: typeof report.at === 'string' ? report.at : null,
|
|
1422
|
+
waitedMs: Number.isFinite(report.waitedMs) ? report.waitedMs : null,
|
|
1423
|
+
rolledBack: report.rolledBack === true,
|
|
1424
|
+
backupDir: typeof report.backupDir === 'string' ? report.backupDir : null,
|
|
1425
|
+
crashLog: typeof report.crashLog === 'string' ? report.crashLog : null,
|
|
1426
|
+
crashTail: tail.length > 4000 ? tail.slice(-4000) : tail,
|
|
1427
|
+
}
|
|
1428
|
+
},
|
|
1247
1429
|
env,
|
|
1248
1430
|
stateFile,
|
|
1249
1431
|
log,
|
|
@@ -1990,6 +2172,9 @@ function registerTools(ctx, runtime, config) {
|
|
|
1990
2172
|
parkedGoals: { type: 'array', items: { type: 'object' } },
|
|
1991
2173
|
recovery: { type: 'array', items: { type: 'object' } },
|
|
1992
2174
|
handoffs: { type: 'array', items: { type: 'object' } },
|
|
2175
|
+
profileDir: { type: 'string' },
|
|
2176
|
+
profileDirSource: { type: 'string' },
|
|
2177
|
+
lastRollback: { type: 'object' },
|
|
1993
2178
|
environment: { type: 'object' },
|
|
1994
2179
|
sessions: { type: 'array', items: { type: 'object' } },
|
|
1995
2180
|
},
|
|
@@ -2013,6 +2198,11 @@ function registerTools(ctx, runtime, config) {
|
|
|
2013
2198
|
parkedGoals: parkedGoalDetails(),
|
|
2014
2199
|
recovery: recoveryList(state),
|
|
2015
2200
|
handoffs: diagnosticSessions(state),
|
|
2201
|
+
// Where the pre-restart backup comes from (null dir = it would be skipped).
|
|
2202
|
+
profileDirSource: runtime.profileDir.source,
|
|
2203
|
+
...(runtime.profileDir.dir ? { profileDir: runtime.profileDir.dir } : {}),
|
|
2204
|
+
// Present only when the previous restart had to be rolled back.
|
|
2205
|
+
...(runtime.lastRollback ? { lastRollback: runtime.lastRollback } : {}),
|
|
2016
2206
|
environment: {
|
|
2017
2207
|
platform: runtime.env.platform,
|
|
2018
2208
|
isDesktopApp: runtime.env.isDesktopApp,
|
package/lib/backup.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — pre-restart backups.
|
|
3
|
+
*
|
|
4
|
+
* A restart can leave the host unable to boot: the profile configuration is what
|
|
5
|
+
* decides whether every installed bundle loads, and the host's own rescue path
|
|
6
|
+
* rewrites exactly those files when a boot fails. So the restart helper keeps a
|
|
7
|
+
* snapshot of the profile (its configuration files, and — when enabled — the
|
|
8
|
+
* installed plugin tree) and can put it back when the host does not come up.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is synchronous and bounded: a backup is taken right before the
|
|
11
|
+
* detached restart is scheduled, and only the newest `maxBackups` snapshots are
|
|
12
|
+
* kept (older ones are removed so the disk never grows without a limit).
|
|
13
|
+
*
|
|
14
|
+
* The restore path used by the detached helper lives in the generated PowerShell
|
|
15
|
+
* watcher (lib/restart.js); `restoreBackup` here is the same operation for tests
|
|
16
|
+
* and for a manual recovery from inside the host.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** The profile files that decide whether the host can boot. */
|
|
23
|
+
export const CONFIG_FILES = Object.freeze(['cordis.patch.yml', 'package.json', 'pnpm-lock.yaml']);
|
|
24
|
+
|
|
25
|
+
/** The directory inside one backup that holds the configuration files. */
|
|
26
|
+
export const CONFIG_DIR = 'config';
|
|
27
|
+
|
|
28
|
+
/** The directory inside one backup that holds the installed plugin tree. */
|
|
29
|
+
export const PLUGINS_DIR = 'node_modules';
|
|
30
|
+
|
|
31
|
+
/** The manifest written into every backup. */
|
|
32
|
+
export const MANIFEST_NAME = 'manifest.json';
|
|
33
|
+
|
|
34
|
+
/** How many backups are kept by default. */
|
|
35
|
+
export const DEFAULT_MAX_BACKUPS = 2;
|
|
36
|
+
|
|
37
|
+
/** The report the detached helper writes when it rolls a backup back. */
|
|
38
|
+
export const ROLLBACK_REPORT_NAME = 'last-rollback.json';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A filesystem-safe timestamp for a backup directory name.
|
|
42
|
+
*
|
|
43
|
+
* @param {number} [now]
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function backupStamp(now = Date.now()) {
|
|
47
|
+
const date = new Date(Number.isFinite(Number(now)) ? Number(now) : Date.now());
|
|
48
|
+
return date.toISOString().replace(/[:.]/g, '-');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The backups directory that belongs to a state directory.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} stateDir
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function backupsRoot(stateDir) {
|
|
58
|
+
return path.join(String(stateDir ?? ''), 'backups');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Recursively measure a directory; never throws. */
|
|
62
|
+
function directorySize(target) {
|
|
63
|
+
let bytes = 0;
|
|
64
|
+
let files = 0;
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(target, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
return { bytes, files };
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const child = path.join(target, entry.name);
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
const nested = directorySize(child);
|
|
75
|
+
bytes += nested.bytes;
|
|
76
|
+
files += nested.files;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
bytes += fs.statSync(child).size;
|
|
81
|
+
files += 1;
|
|
82
|
+
} catch {
|
|
83
|
+
// A file that vanished between readdir and stat is simply not counted.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { bytes, files };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Copy one entry (file or directory) into a destination directory.
|
|
91
|
+
*
|
|
92
|
+
* @returns {boolean} whether anything was copied
|
|
93
|
+
*/
|
|
94
|
+
function copyEntry(source, destination) {
|
|
95
|
+
try {
|
|
96
|
+
const stat = fs.statSync(source);
|
|
97
|
+
if (stat.isDirectory()) {
|
|
98
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
102
|
+
fs.copyFileSync(source, destination);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Keep only the newest `maxBackups` backup directories.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} backupsDir
|
|
113
|
+
* @param {number} [maxBackups]
|
|
114
|
+
* @returns {string[]} the names that were removed
|
|
115
|
+
*/
|
|
116
|
+
export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
|
|
117
|
+
const limit = Number.isFinite(Number(maxBackups)) && Number(maxBackups) >= 1
|
|
118
|
+
? Math.trunc(Number(maxBackups))
|
|
119
|
+
: DEFAULT_MAX_BACKUPS;
|
|
120
|
+
let names;
|
|
121
|
+
try {
|
|
122
|
+
names = fs.readdirSync(backupsDir, { withFileTypes: true })
|
|
123
|
+
.filter((entry) => entry.isDirectory())
|
|
124
|
+
.map((entry) => entry.name)
|
|
125
|
+
.sort();
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const removed = [];
|
|
130
|
+
for (const name of names.slice(0, Math.max(0, names.length - limit))) {
|
|
131
|
+
const target = path.join(backupsDir, name);
|
|
132
|
+
try {
|
|
133
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
134
|
+
removed.push(name);
|
|
135
|
+
} catch {
|
|
136
|
+
// A backup that cannot be removed must not fail the restart.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return removed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* List the backups, newest first.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} backupsDir
|
|
146
|
+
* @returns {Array<{name: string, dir: string, at: number|null, bytes: number|null, includePlugins: boolean}>}
|
|
147
|
+
*/
|
|
148
|
+
export function listBackups(backupsDir) {
|
|
149
|
+
let names;
|
|
150
|
+
try {
|
|
151
|
+
names = fs.readdirSync(backupsDir, { withFileTypes: true })
|
|
152
|
+
.filter((entry) => entry.isDirectory())
|
|
153
|
+
.map((entry) => entry.name);
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const name of names.sort().reverse()) {
|
|
159
|
+
const dir = path.join(backupsDir, name);
|
|
160
|
+
const manifest = readManifest(dir);
|
|
161
|
+
out.push({
|
|
162
|
+
name,
|
|
163
|
+
dir,
|
|
164
|
+
at: manifest && Number.isFinite(manifest.at) ? manifest.at : null,
|
|
165
|
+
bytes: manifest && Number.isFinite(manifest.bytes) ? manifest.bytes : null,
|
|
166
|
+
includePlugins: Boolean(manifest && manifest.includePlugins === true),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read one backup manifest.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} backupDir
|
|
176
|
+
* @returns {object|null}
|
|
177
|
+
*/
|
|
178
|
+
export function readManifest(backupDir) {
|
|
179
|
+
try {
|
|
180
|
+
const text = fs.readFileSync(path.join(String(backupDir ?? ''), MANIFEST_NAME), 'utf8');
|
|
181
|
+
const parsed = JSON.parse(text);
|
|
182
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Take one backup of the profile.
|
|
190
|
+
*
|
|
191
|
+
* `includePlugins` copies the whole installed plugin tree, which is what makes a
|
|
192
|
+
* rollback of "a plugin upgrade broke the boot" actually possible; without it only
|
|
193
|
+
* the configuration files are copied.
|
|
194
|
+
*
|
|
195
|
+
* Never throws: a backup that cannot be written is reported as `{ ok: false }` so
|
|
196
|
+
* the caller can decide whether the restart may still proceed.
|
|
197
|
+
*
|
|
198
|
+
* @param {{
|
|
199
|
+
* profileDir: string,
|
|
200
|
+
* backupsDir: string,
|
|
201
|
+
* includePlugins?: boolean,
|
|
202
|
+
* maxBackups?: number,
|
|
203
|
+
* now?: number,
|
|
204
|
+
* }} options
|
|
205
|
+
* @returns {{ ok: boolean, dir: string|null, stamp: string|null, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
206
|
+
*/
|
|
207
|
+
export function createBackup({
|
|
208
|
+
profileDir,
|
|
209
|
+
backupsDir,
|
|
210
|
+
includePlugins = true,
|
|
211
|
+
maxBackups = DEFAULT_MAX_BACKUPS,
|
|
212
|
+
now = Date.now(),
|
|
213
|
+
} = {}) {
|
|
214
|
+
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
215
|
+
const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
|
|
216
|
+
const base = {
|
|
217
|
+
ok: false,
|
|
218
|
+
dir: null,
|
|
219
|
+
stamp: null,
|
|
220
|
+
bytes: 0,
|
|
221
|
+
files: [],
|
|
222
|
+
includePlugins: includePlugins === true,
|
|
223
|
+
pruned: [],
|
|
224
|
+
error: null,
|
|
225
|
+
};
|
|
226
|
+
if (profile === '') return { ...base, error: 'createBackup requires profileDir' };
|
|
227
|
+
if (root === '') return { ...base, error: 'createBackup requires backupsDir' };
|
|
228
|
+
if (!fs.existsSync(profile)) return { ...base, error: 'the profile directory does not exist' };
|
|
229
|
+
|
|
230
|
+
const stamp = backupStamp(now);
|
|
231
|
+
const dir = path.join(root, stamp);
|
|
232
|
+
const files = [];
|
|
233
|
+
let bytes = 0;
|
|
234
|
+
try {
|
|
235
|
+
fs.mkdirSync(path.join(dir, CONFIG_DIR), { recursive: true });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return { ...base, stamp, error: 'could not create the backup directory: ' + (error && error.message ? error.message : String(error)) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const name of CONFIG_FILES) {
|
|
241
|
+
const source = path.join(profile, name);
|
|
242
|
+
if (!fs.existsSync(source)) continue;
|
|
243
|
+
if (copyEntry(source, path.join(dir, CONFIG_DIR, name))) {
|
|
244
|
+
files.push(path.join(CONFIG_DIR, name));
|
|
245
|
+
try {
|
|
246
|
+
bytes += fs.statSync(path.join(dir, CONFIG_DIR, name)).size;
|
|
247
|
+
} catch {
|
|
248
|
+
// Size reporting is advisory only.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (includePlugins === true) {
|
|
254
|
+
const source = path.join(profile, PLUGINS_DIR);
|
|
255
|
+
if (fs.existsSync(source) && copyEntry(source, path.join(dir, PLUGINS_DIR))) {
|
|
256
|
+
files.push(PLUGINS_DIR);
|
|
257
|
+
bytes += directorySize(path.join(dir, PLUGINS_DIR)).bytes;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const manifest = {
|
|
262
|
+
stamp,
|
|
263
|
+
at: Number(now),
|
|
264
|
+
profileDir: profile,
|
|
265
|
+
includePlugins: includePlugins === true,
|
|
266
|
+
files,
|
|
267
|
+
bytes,
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2), 'utf8');
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return { ...base, stamp, files, bytes, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const pruned = pruneBackups(root, maxBackups);
|
|
276
|
+
return { ...base, ok: true, dir, stamp, bytes, files, pruned };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Put a backup back over the live profile.
|
|
281
|
+
*
|
|
282
|
+
* The configuration files are copied over the existing ones; the plugin tree is
|
|
283
|
+
* copied with `force` so the snapshot wins file by file. The caller decides what
|
|
284
|
+
* to do with the host afterwards (the detached helper relaunches it).
|
|
285
|
+
*
|
|
286
|
+
* @param {{ backupDir: string, profileDir: string }} options
|
|
287
|
+
* @returns {{ ok: boolean, restored: string[], error: string|null }}
|
|
288
|
+
*/
|
|
289
|
+
export function restoreBackup({ backupDir, profileDir } = {}) {
|
|
290
|
+
const backup = typeof backupDir === 'string' ? backupDir.trim() : '';
|
|
291
|
+
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
292
|
+
const restored = [];
|
|
293
|
+
if (backup === '') return { ok: false, restored, error: 'restoreBackup requires backupDir' };
|
|
294
|
+
if (profile === '') return { ok: false, restored, error: 'restoreBackup requires profileDir' };
|
|
295
|
+
if (!fs.existsSync(backup)) return { ok: false, restored, error: 'the backup directory does not exist' };
|
|
296
|
+
|
|
297
|
+
const configDir = path.join(backup, CONFIG_DIR);
|
|
298
|
+
if (fs.existsSync(configDir)) {
|
|
299
|
+
for (const name of CONFIG_FILES) {
|
|
300
|
+
const source = path.join(configDir, name);
|
|
301
|
+
if (!fs.existsSync(source)) continue;
|
|
302
|
+
if (copyEntry(source, path.join(profile, name))) restored.push(name);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const plugins = path.join(backup, PLUGINS_DIR);
|
|
306
|
+
if (fs.existsSync(plugins) && copyEntry(plugins, path.join(profile, PLUGINS_DIR))) {
|
|
307
|
+
restored.push(PLUGINS_DIR);
|
|
308
|
+
}
|
|
309
|
+
if (restored.length === 0) {
|
|
310
|
+
return { ok: false, restored, error: 'the backup holds nothing that could be restored' };
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, restored, error: null };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Read the report the detached helper writes after a rolled-back boot.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} stateDir
|
|
319
|
+
* @returns {object|null}
|
|
320
|
+
*/
|
|
321
|
+
export function readRollbackReport(stateDir) {
|
|
322
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
323
|
+
if (dir === '') return null;
|
|
324
|
+
try {
|
|
325
|
+
let text = fs.readFileSync(path.join(dir, ROLLBACK_REPORT_NAME), 'utf8');
|
|
326
|
+
// Windows PowerShell 5.1 writes UTF-8 files with a BOM; JSON.parse rejects it.
|
|
327
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
328
|
+
const parsed = JSON.parse(text);
|
|
329
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Remove the rollback report once it has been reported.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} stateDir
|
|
339
|
+
* @returns {boolean} whether a report was removed
|
|
340
|
+
*/
|
|
341
|
+
export function clearRollbackReport(stateDir) {
|
|
342
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
343
|
+
if (dir === '') return false;
|
|
344
|
+
try {
|
|
345
|
+
fs.rmSync(path.join(dir, ROLLBACK_REPORT_NAME), { force: true });
|
|
346
|
+
return true;
|
|
347
|
+
} catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
package/lib/restart.js
CHANGED
|
@@ -158,6 +158,7 @@ export function buildGateScript({
|
|
|
158
158
|
relaunchDelaySeconds = DEFAULT_RELAUNCH_DELAY_SECONDS,
|
|
159
159
|
gateUrl = '',
|
|
160
160
|
taskkillImageName = DEFAULT_IMAGE_NAME,
|
|
161
|
+
watchScriptPath = null,
|
|
161
162
|
} = {}) {
|
|
162
163
|
const seconds =
|
|
163
164
|
typeof relaunchDelaySeconds === 'number' && Number.isFinite(relaunchDelaySeconds) && relaunchDelaySeconds >= 0
|
|
@@ -171,6 +172,7 @@ export function buildGateScript({
|
|
|
171
172
|
+ '"try { $r = Invoke-RestMethod -Uri '
|
|
172
173
|
+ gateArgument
|
|
173
174
|
+ ' -TimeoutSec 5; if ($r.allowRestart -ne $true) { exit 3 } } catch { exit 4 }"';
|
|
175
|
+
const watch = nonEmptyString(watchScriptPath);
|
|
174
176
|
const lines = [
|
|
175
177
|
'@echo off',
|
|
176
178
|
'rem dsh-autorestart: wait, re-check the idle gate, then kill + relaunch DeepSeek Harness.',
|
|
@@ -180,6 +182,103 @@ export function buildGateScript({
|
|
|
180
182
|
'taskkill /IM "' + image + '" /T /F',
|
|
181
183
|
'ping -n 3 127.0.0.1 >nul',
|
|
182
184
|
'start "" "' + exe + '"',
|
|
185
|
+
];
|
|
186
|
+
if (watch !== null) {
|
|
187
|
+
lines.push('rem dsh-autorestart: wait for the host to come back; roll the backup back when it does not.');
|
|
188
|
+
// Launched as its own hidden process so the .cmd can end immediately: the
|
|
189
|
+
// helper console must close as soon as the relaunch is issued (a visible
|
|
190
|
+
// console window parked in front of the app is not acceptable), and the
|
|
191
|
+
// watcher must survive that console closing.
|
|
192
|
+
lines.push('start "" powershell.exe -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File "' + watch + '"');
|
|
193
|
+
}
|
|
194
|
+
lines.push('');
|
|
195
|
+
return lines.join('\r\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** How long the watcher waits for the relaunched host before it rolls back. */
|
|
199
|
+
export const DEFAULT_ROLLBACK_TIMEOUT_MS = 90000;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the PowerShell watcher the `.cmd` helper runs right after the relaunch.
|
|
203
|
+
*
|
|
204
|
+
* It polls the health route for `timeoutMs`; a healthy answer ends the watch. When
|
|
205
|
+
* the host does not come back, the snapshot taken before the restart is copied back
|
|
206
|
+
* over the profile (configuration files, and the plugin tree when the backup holds
|
|
207
|
+
* one), a report is written next to the checkpoint, and the host is launched again
|
|
208
|
+
* with the restored configuration.
|
|
209
|
+
*
|
|
210
|
+
* The report is written WITHOUT a BOM even on PowerShell 5.1, because the plugin
|
|
211
|
+
* parses it with JSON.parse.
|
|
212
|
+
*
|
|
213
|
+
* @param {{
|
|
214
|
+
* healthUrl?: string,
|
|
215
|
+
* backupDir?: string,
|
|
216
|
+
* profileDir?: string,
|
|
217
|
+
* reportPath?: string,
|
|
218
|
+
* appExePath?: string|null,
|
|
219
|
+
* timeoutMs?: number,
|
|
220
|
+
* }} [options]
|
|
221
|
+
* @returns {string} script text (CRLF line endings)
|
|
222
|
+
*/
|
|
223
|
+
export function buildWatchScript({
|
|
224
|
+
healthUrl = '',
|
|
225
|
+
backupDir = '',
|
|
226
|
+
profileDir = '',
|
|
227
|
+
reportPath = '',
|
|
228
|
+
appExePath = null,
|
|
229
|
+
timeoutMs = DEFAULT_ROLLBACK_TIMEOUT_MS,
|
|
230
|
+
since = null,
|
|
231
|
+
} = {}) {
|
|
232
|
+
const timeout =
|
|
233
|
+
typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs >= 1000
|
|
234
|
+
? Math.trunc(timeoutMs)
|
|
235
|
+
: DEFAULT_ROLLBACK_TIMEOUT_MS;
|
|
236
|
+
// Only a crash log written AFTER the restart is evidence about it: an older one
|
|
237
|
+
// would blame last week's failure for this one.
|
|
238
|
+
const sinceText = typeof since === 'string' && since.trim() !== ''
|
|
239
|
+
? since.trim()
|
|
240
|
+
: (Number.isFinite(Number(since)) ? new Date(Number(since)).toISOString() : '');
|
|
241
|
+
const quote = (value) => "'" + psSingle(typeof value === 'string' ? value : '') + "'";
|
|
242
|
+
const lines = [
|
|
243
|
+
'# dsh-autorestart: wait for the relaunched host; roll the pre-restart backup back when it never answers.',
|
|
244
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
245
|
+
'$health = ' + quote(nonEmptyString(healthUrl) ?? ''),
|
|
246
|
+
'$backup = ' + quote(nonEmptyString(backupDir) ?? ''),
|
|
247
|
+
'$profile = ' + quote(nonEmptyString(profileDir) ?? ''),
|
|
248
|
+
'$report = ' + quote(nonEmptyString(reportPath) ?? ''),
|
|
249
|
+
'$exe = ' + quote(nonEmptyString(appExePath) ?? ''),
|
|
250
|
+
'$deadline = (Get-Date).AddMilliseconds(' + timeout + ')',
|
|
251
|
+
'$healthy = $false',
|
|
252
|
+
'while ((Get-Date) -lt $deadline) {',
|
|
253
|
+
' try { $r = Invoke-RestMethod -Uri $health -TimeoutSec 5; if ($r.ok -eq $true) { $healthy = $true; break } } catch { }',
|
|
254
|
+
' Start-Sleep -Seconds 2',
|
|
255
|
+
'}',
|
|
256
|
+
'if ($healthy) { exit 0 }',
|
|
257
|
+
'$crashDir = Join-Path $env:APPDATA \'@deepseek-ai\\dsh-desktop\\logs\'',
|
|
258
|
+
'$since = ' + quote(sinceText),
|
|
259
|
+
'$crash = $null',
|
|
260
|
+
'if ($since -ne \'\') { $crash = Get-ChildItem $crashDir -Filter \'crash-*.log\' | Where-Object { $_.LastWriteTime -gt ([datetime]$since) } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 }',
|
|
261
|
+
'if (-not $crash) { $crash = Get-ChildItem $crashDir -Filter \'crash-*.log\' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 }',
|
|
262
|
+
"$tail = ''",
|
|
263
|
+
'if ($crash) { $tail = ((Get-Content $crash.FullName -Tail 60) -join [char]10) }',
|
|
264
|
+
'$rolledBack = $false',
|
|
265
|
+
'$configBackup = Join-Path $backup \'config\'',
|
|
266
|
+
'if (Test-Path $configBackup) {',
|
|
267
|
+
' robocopy $configBackup $profile /E /NFL /NDL /NJH /NJS /NP | Out-Null',
|
|
268
|
+
' $rolledBack = $true',
|
|
269
|
+
'}',
|
|
270
|
+
'$pluginsBackup = Join-Path $backup \'node_modules\'',
|
|
271
|
+
'if (Test-Path $pluginsBackup) {',
|
|
272
|
+
' robocopy $pluginsBackup (Join-Path $profile \'node_modules\') /MIR /NFL /NDL /NJH /NJS /NP | Out-Null',
|
|
273
|
+
' $rolledBack = $true',
|
|
274
|
+
'}',
|
|
275
|
+
'$crashName = $null',
|
|
276
|
+
'if ($crash) { $crashName = $crash.Name }',
|
|
277
|
+
'$payload = [ordered]@{ ok = $false; at = (Get-Date).ToString(\'o\'); waitedMs = ' + timeout + '; rolledBack = $rolledBack; backupDir = $backup; crashLog = $crashName; crashTail = $tail }',
|
|
278
|
+
'$json = $payload | ConvertTo-Json -Depth 4',
|
|
279
|
+
'[System.IO.File]::WriteAllText($report, $json, (New-Object System.Text.UTF8Encoding($false)))',
|
|
280
|
+
'Start-Process -FilePath $exe',
|
|
281
|
+
'exit 0',
|
|
183
282
|
'',
|
|
184
283
|
];
|
|
185
284
|
return lines.join('\r\n');
|
|
@@ -226,15 +325,25 @@ export function scheduleDetachedRestart({
|
|
|
226
325
|
dryRun = false,
|
|
227
326
|
gateUrl = null,
|
|
228
327
|
scriptPath = null,
|
|
328
|
+
watchScriptPath = null,
|
|
329
|
+
watchScript = null,
|
|
229
330
|
} = {}) {
|
|
230
331
|
const task = nonEmptyString(taskName) ?? DEFAULT_TASK_NAME;
|
|
231
332
|
const gate = nonEmptyString(gateUrl);
|
|
232
333
|
const scriptFile = nonEmptyString(scriptPath);
|
|
334
|
+
const watchFile = nonEmptyString(watchScriptPath);
|
|
335
|
+
const watchText = typeof watchScript === 'string' ? watchScript : null;
|
|
233
336
|
const gated = gate !== null;
|
|
234
337
|
let script = null;
|
|
235
338
|
let command;
|
|
236
339
|
if (gated) {
|
|
237
|
-
script = buildGateScript({
|
|
340
|
+
script = buildGateScript({
|
|
341
|
+
appExePath,
|
|
342
|
+
relaunchDelaySeconds,
|
|
343
|
+
gateUrl: gate,
|
|
344
|
+
taskkillImageName,
|
|
345
|
+
watchScriptPath: watchFile,
|
|
346
|
+
});
|
|
238
347
|
command = scriptFile === null ? '' : 'cmd.exe /c "' + scriptFile + '"';
|
|
239
348
|
} else {
|
|
240
349
|
command = buildRestartCommand({ appExePath, relaunchDelaySeconds, taskkillImageName });
|
|
@@ -260,6 +369,8 @@ export function scheduleDetachedRestart({
|
|
|
260
369
|
gated,
|
|
261
370
|
scriptPath: scriptFile,
|
|
262
371
|
script,
|
|
372
|
+
watchScriptPath: watchFile,
|
|
373
|
+
watchScript: watchText,
|
|
263
374
|
};
|
|
264
375
|
|
|
265
376
|
if (!nonEmptyString(appExePath)) {
|
|
@@ -270,10 +381,27 @@ export function scheduleDetachedRestart({
|
|
|
270
381
|
return { ...base, error: 'scheduleDetachedRestart requires scriptPath when gateUrl is set' };
|
|
271
382
|
}
|
|
272
383
|
|
|
384
|
+
if (watchFile !== null && watchText === null) {
|
|
385
|
+
return { ...base, error: 'scheduleDetachedRestart requires watchScript when watchScriptPath is set' };
|
|
386
|
+
}
|
|
387
|
+
|
|
273
388
|
if (dryRun === true) {
|
|
274
389
|
return { ...base, ok: true, dryRun: true };
|
|
275
390
|
}
|
|
276
391
|
|
|
392
|
+
if (watchFile !== null && watchText !== null) {
|
|
393
|
+
// The watcher must exist before the .cmd runs it: same atomic write as the
|
|
394
|
+
// gate script, so a half-written .ps1 can never be executed.
|
|
395
|
+
try {
|
|
396
|
+
mkdirSync(path.dirname(watchFile), { recursive: true });
|
|
397
|
+
const temporary = watchFile + '.tmp';
|
|
398
|
+
writeFileSync(temporary, String(watchText), { encoding: 'utf8' });
|
|
399
|
+
renameSync(temporary, watchFile);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
return { ...base, error: 'could not write the rollback watcher: ' + errorText(error) };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
277
405
|
if (gated) {
|
|
278
406
|
// The helper must exist before the scheduler runs it: write it atomically
|
|
279
407
|
// (tmp file + rename) so a half-written .cmd can never be executed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-autorestart",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DeepSeek Harness plugin: restart without losing work. Waits until every conversation is idle, restarts the host, then automatically resumes the interrupted sessions. AI-generated, not human-reviewed: use with caution.",
|
|
6
6
|
"license": "MIT",
|