dsh-autorestart 0.3.2 → 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 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 can be clicked for details, and
33
- the dot breathes when the restart is about to fire.
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/client.js CHANGED
@@ -93,6 +93,7 @@ window.__ModuleLoader__.load({
93
93
  parkBusy: '停泊中…',
94
94
  parkError: '停泊失败:{message}',
95
95
  tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
96
+ tooltipBusy: '等待其他会话空闲后自动重启(计数含“排队但还没开始”的任务);重启后本会话会自动继续进度',
96
97
  tooltipWithError: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度。{message}',
97
98
  dotAria: '待重启',
98
99
  panelHeading: '待重启',
@@ -127,6 +128,8 @@ window.__ModuleLoader__.load({
127
128
  parkError: 'Park failed: {message}',
128
129
  tooltip:
129
130
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
131
+ tooltipBusy:
132
+ 'Waits for the other sessions to become idle, then restarts automatically (the count includes work that is queued but not started yet); this session resumes its progress after the restart.',
130
133
  tooltipWithError:
131
134
  'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
132
135
  dotAria: 'Pending restart',
@@ -492,13 +495,11 @@ window.__ModuleLoader__.load({
492
495
  const sessionId = props && props.sessionId !== undefined ? props.sessionId : null
493
496
  const hasSession = sessionId !== null && sessionId !== ''
494
497
  const info = useSharedInfo(hasSession)
495
- const [open, setOpen] = useState(false)
496
498
  // The session id of the goal that is currently being parked, or null.
497
499
  const [parking, setParking] = useState(null)
498
500
  // The message of the last failed park request (the Host's own message).
501
+ // It is shown in the pill's tooltip: the pill has no details panel.
499
502
  const [parkError, setParkError] = useState('')
500
- const [, setTick] = useState(0)
501
- const wrapperRef = useRef(null)
502
503
  const t = translate ?? ((name) => name)
503
504
 
504
505
  const pending = Boolean(info && info.pendingRestart === true)
@@ -506,43 +507,14 @@ window.__ModuleLoader__.load({
506
507
  // happens as soon as this poll cycle ends.
507
508
  const pulsing = pending && Boolean(info.allowRestart === true) && !prefersReducedMotion()
508
509
 
509
- // The panel never outlives the pending state.
510
+ // A park error never outlives the pending state.
510
511
  useEffect(() => {
511
512
  if (!pending) {
512
- setOpen(false)
513
513
  setParkError('')
514
514
  setParking(null)
515
515
  }
516
516
  }, [pending])
517
517
 
518
- // Clicking outside the panel or pressing Escape closes it. Both
519
- // listeners are removed when the panel closes and on unmount.
520
- useEffect(() => {
521
- if (!open) return undefined
522
- const onPointerDown = (event) => {
523
- const node = wrapperRef.current
524
- if (node && event && event.target && typeof node.contains === 'function'
525
- && node.contains(event.target)) return
526
- setOpen(false)
527
- }
528
- const onKeyDown = (event) => {
529
- if (event && event.key === 'Escape') setOpen(false)
530
- }
531
- document.addEventListener('mousedown', onPointerDown, true)
532
- document.addEventListener('keydown', onKeyDown, true)
533
- return () => {
534
- document.removeEventListener('mousedown', onPointerDown, true)
535
- document.removeEventListener('keydown', onKeyDown, true)
536
- }
537
- }, [open])
538
-
539
- // A one-second tick keeps the "waiting for" duration in the panel fresh.
540
- useEffect(() => {
541
- if (!open) return undefined
542
- const timer = setInterval(() => setTick((value) => value + 1), 1000)
543
- return () => clearInterval(timer)
544
- }, [open])
545
-
546
518
  if (!info || info.pendingRestart !== true) return null
547
519
 
548
520
  const waiters = Array.isArray(info.waiters) ? info.waiters : []
@@ -551,15 +523,22 @@ window.__ModuleLoader__.load({
551
523
  if (!waiting) return null
552
524
 
553
525
  const blockedBy = Array.isArray(info.blockedBy) ? info.blockedBy : []
554
- const others = blockedBy.filter((entry) => entry && String(entry.sessionId) !== key)
526
+ // One entry per SESSION: a conversation that is running *and* still has
527
+ // queued work is one conversation, so the pill never counts it twice.
528
+ const seenSessions = new Set()
529
+ const others = blockedBy.filter((entry) => {
530
+ if (!entry) return false
531
+ const id = String(entry.sessionId)
532
+ if (id === key || seenSessions.has(id)) return false
533
+ seenSessions.add(id)
534
+ return true
535
+ })
555
536
  // A goal-driven blocker: an armed goal that keeps driving rounds by
556
537
  // itself (`goal-active`, parkable) or a goal whose round budget is used
557
538
  // up (`goal-exhausted`, not parkable at all).
558
539
  const goalActive = others.filter((entry) => String(entry.reason) === 'goal-active')
559
540
  const goalExhausted = others.filter((entry) => String(entry.reason) === 'goal-exhausted')
560
541
  const otherBusy = others.length - goalActive.length - goalExhausted.length
561
- // The goals the Host already parked: they continue after the restart.
562
- const parkedGoals = Array.isArray(info.parkedGoals) ? info.parkedGoals : []
563
542
 
564
543
  const status = goalActive.length > 0
565
544
  ? t('goalActive')
@@ -571,16 +550,6 @@ window.__ModuleLoader__.load({
571
550
 
572
551
  if (pulsing) ensurePulseStyle()
573
552
 
574
- const toggle = () => setOpen((value) => !value)
575
- const onKeyDown = (event) => {
576
- if (!event) return
577
- const pressed = event.key
578
- if (pressed === 'Enter' || pressed === ' ' || pressed === 'Spacebar') {
579
- event.preventDefault()
580
- toggle()
581
- }
582
- }
583
-
584
553
  /**
585
554
  * Park the goal of one blocking session and ask for the restart now.
586
555
  *
@@ -612,7 +581,6 @@ window.__ModuleLoader__.load({
612
581
  ? (body.error || body.message || '')
613
582
  : ''
614
583
  setParkError(fill(t('parkError'), { message: message || String(response.status) }))
615
- setOpen(true)
616
584
  return
617
585
  }
618
586
  if (body && Array.isArray(body.reported)) {
@@ -621,96 +589,18 @@ window.__ModuleLoader__.load({
621
589
  )
622
590
  if (reported) {
623
591
  setParkError(fill(t('parkError'), { message: String(reported.reason || 'not-parked') }))
624
- setOpen(true)
625
592
  }
626
593
  }
627
594
  } catch (error) {
628
595
  setParkError(fill(t('parkError'), { message: describeError(error) }))
629
- setOpen(true)
630
596
  } finally {
631
597
  setParking(null)
632
598
  }
633
599
  }
634
600
 
635
- const pendingSince = stateStore.pendingSince
636
- const waitingMs = pendingSince === null || pendingSince === undefined
637
- ? 0
638
- : Math.max(0, Date.now() - pendingSince)
639
-
640
- const panelLines = [
641
- h('div', { key: 'heading', style: PANEL_HEADING_STYLE }, t('panelHeading')),
642
- h(
643
- 'div',
644
- { key: 'state', style: PANEL_LINE_STYLE },
645
- goalActive.length > 0
646
- ? t('panelStateGoalActive')
647
- : goalExhausted.length > 0
648
- ? t('panelStateGoalExhausted')
649
- : otherBusy > 0
650
- ? t('panelStateBusy')
651
- : t('panelStateIdle'),
652
- ),
653
- ]
654
- if (parkError) {
655
- panelLines.push(h('div', { key: 'error', style: PANEL_ERROR_STYLE }, parkError))
656
- }
657
- others.forEach((entry, index) => {
658
- const title = entry && typeof entry.title === 'string' ? entry.title.trim() : ''
659
- const reason = entry && typeof entry.reason === 'string' ? entry.reason : ''
660
- const name = title || String(entry && entry.sessionId)
661
- if (reason === 'goal-active') {
662
- panelLines.push(h(
663
- 'div',
664
- { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
665
- fill(t('panelGoalActiveLine'), { title: name }),
666
- ))
667
- return
668
- }
669
- if (reason === 'goal-exhausted') {
670
- panelLines.push(h(
671
- 'div',
672
- { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
673
- fill(t('panelGoalExhaustedLine'), { title: name }),
674
- ))
675
- return
676
- }
677
- panelLines.push(h(
678
- 'div',
679
- { key: 'blocked-' + index, style: PANEL_LINE_STYLE },
680
- title ? fill(t('panelBlockedTitle'), { title }) : t('panelBlockedGeneric'),
681
- ))
682
- })
683
- if (parkedGoals.length > 0) {
684
- panelLines.push(h(
685
- 'div',
686
- { key: 'parked-title', style: PANEL_MUTED_STYLE },
687
- t('panelParkedTitle'),
688
- ))
689
- parkedGoals.forEach((entry, index) => {
690
- const parkedSession = entry && entry.sessionId != null ? String(entry.sessionId) : ''
691
- const parkedGoal = entry && entry.goalId != null ? String(entry.goalId) : '-'
692
- panelLines.push(h(
693
- 'div',
694
- { key: 'parked-' + index, style: PANEL_LINE_STYLE },
695
- fill(t('panelParkedGoal'), { session: parkedSession, goal: parkedGoal }),
696
- ))
697
- })
698
- }
699
- panelLines.push(h(
700
- 'div',
701
- { key: 'waiting', style: PANEL_LINE_STYLE },
702
- fill(t('panelWaiting'), { duration: formatWaiting(waitingMs, t) }),
703
- ))
704
- panelLines.push(h(
705
- 'div',
706
- { key: 'resume', style: PANEL_MUTED_STYLE },
707
- t('panelResume'),
708
- ))
709
-
710
601
  return h(
711
602
  'div',
712
603
  {
713
- ref: wrapperRef,
714
604
  style: WRAPPER_STYLE,
715
605
  role: 'status',
716
606
  'aria-live': 'polite',
@@ -721,13 +611,7 @@ window.__ModuleLoader__.load({
721
611
  style: PILL_STYLE,
722
612
  title: parkError
723
613
  ? fill(t('tooltipWithError'), { message: parkError })
724
- : t('tooltip'),
725
- role: 'button',
726
- tabIndex: 0,
727
- 'aria-haspopup': 'dialog',
728
- 'aria-expanded': open ? 'true' : 'false',
729
- onClick: toggle,
730
- onKeyDown,
614
+ : (otherBusy > 0 ? t('tooltipBusy') : t('tooltip')),
731
615
  },
732
616
  h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
733
617
  h('span', { style: TEXT_STYLE }, h('b', null, t('label')), status),
@@ -737,7 +621,6 @@ window.__ModuleLoader__.load({
737
621
  const target = String(entry.sessionId)
738
622
  const busy = parking === target
739
623
  const onParkClick = (event) => {
740
- // The button must not toggle the details panel of the pill.
741
624
  if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
742
625
  void parkAndRestart(target)
743
626
  }
@@ -745,7 +628,6 @@ window.__ModuleLoader__.load({
745
628
  if (!event) return
746
629
  const pressed = event.key
747
630
  if (pressed === 'Enter' || pressed === ' ' || pressed === 'Spacebar') {
748
- // Same here: the pill's own Enter / Space handler must not run.
749
631
  if (typeof event.stopPropagation === 'function') event.stopPropagation()
750
632
  }
751
633
  }
@@ -764,25 +646,7 @@ window.__ModuleLoader__.load({
764
646
  busy ? t('parkBusy') : t('parkButton'),
765
647
  )
766
648
  }),
767
- parkedGoals.length > 0
768
- ? h(
769
- 'span',
770
- { style: PARKED_LINE_STYLE },
771
- fill(t('parkedGoals'), { n: parkedGoals.length }),
772
- )
773
- : null,
774
649
  ),
775
- open
776
- ? h(
777
- 'div',
778
- {
779
- style: PANEL_STYLE,
780
- role: 'dialog',
781
- 'aria-label': t('panelHeading'),
782
- },
783
- panelLines,
784
- )
785
- : null,
786
650
  )
787
651
  }
788
652
 
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(path.dirname(stateFile), 'restart-' + state.generation + '.cmd'),
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({ appExePath, relaunchDelaySeconds, gateUrl: gate, taskkillImageName });
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.2",
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",