dsh-autorestart 0.8.1 → 0.8.3

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/lib/backup.js CHANGED
@@ -204,6 +204,164 @@ function copyEntry(source, destination) {
204
204
  }
205
205
  }
206
206
 
207
+ /**
208
+ * Measure a tree so the copy can report a REAL total (files and bytes).
209
+ *
210
+ * The plugin tree of a desktop profile is ~150 MB; walking it costs a few tens
211
+ * of milliseconds and it is the only way to say "154 MB of 156 MB done" instead
212
+ * of making a number up.
213
+ *
214
+ * @param {string} target
215
+ * @returns {{ files: number, bytes: number }}
216
+ */
217
+ export function measureTree(target) {
218
+ return directorySize(target);
219
+ }
220
+
221
+ /**
222
+ * Copy a directory tree FILE BY FILE, reporting real progress and honouring a
223
+ * cancel request.
224
+ *
225
+ * `fs.cpSync` (what this used to be) copies 150 MB in one call: no progress, no
226
+ * cancel, and no way to tell a busy disk from a dead host. The loop below copies
227
+ * one file at a time, adds up the bytes that really landed and asks the caller
228
+ * between files whether it should stop.
229
+ *
230
+ * IT YIELDS TO THE EVENT LOOP on purpose: the copy runs inside the host
231
+ * process, and a copy that never yields blocks the very route the "cancel"
232
+ * button posts to — the user could not call it off even though the code says
233
+ * they can. Every `yieldEveryFiles` files (and every `yieldEveryMs`
234
+ * milliseconds) the loop waits one tick, so the server stays answerable and the
235
+ * progress reaches the interface while the copy is running.
236
+ *
237
+ * @param {{
238
+ * source: string,
239
+ * destination: string,
240
+ * onProgress?: Function|null,
241
+ * shouldCancel?: Function|null,
242
+ * progressEveryFiles?: number,
243
+ * yieldEveryFiles?: number,
244
+ * yieldEveryMs?: number,
245
+ * yieldToEventLoop?: Function|null,
246
+ * now?: Function,
247
+ * }} options
248
+ * @returns {Promise<{ ok: boolean, files: number, bytes: number, cancelled: boolean, error: string|null }>}
249
+ */
250
+ export async function copyTreeWithProgress({
251
+ source,
252
+ destination,
253
+ onProgress = null,
254
+ shouldCancel = null,
255
+ progressEveryFiles = 25,
256
+ yieldEveryFiles = 40,
257
+ yieldEveryMs = 120,
258
+ yieldToEventLoop = null,
259
+ now = Date.now,
260
+ } = {}) {
261
+ const from = typeof source === 'string' ? source : '';
262
+ const into = typeof destination === 'string' ? destination : '';
263
+ if (from === '' || into === '') {
264
+ return { ok: false, files: 0, bytes: 0, cancelled: false, error: 'copyTreeWithProgress requires a source and a destination' };
265
+ }
266
+ const pause = typeof yieldToEventLoop === 'function'
267
+ ? yieldToEventLoop
268
+ : () => new Promise((resolve) => { setImmediate(resolve); });
269
+ const clock = typeof now === 'function' ? now : Date.now;
270
+ let files = 0;
271
+ let bytes = 0;
272
+ let cancelled = false;
273
+ let error = null;
274
+ const tick = { files: 0, at: clock(), yielded: clock() };
275
+ const report = () => {
276
+ if (typeof onProgress !== 'function') return;
277
+ tick.at = clock();
278
+ try {
279
+ onProgress({ files, bytes });
280
+ } catch {
281
+ // A broken progress callback must never fail the backup itself.
282
+ }
283
+ };
284
+
285
+ const walk = async (sourceDir, targetDir) => {
286
+ let entries;
287
+ try {
288
+ entries = fs.readdirSync(sourceDir, { withFileTypes: true });
289
+ } catch (failure) {
290
+ // A directory that cannot be read is skipped, exactly like before: a
291
+ // single unreadable plugin directory must not break the whole snapshot.
292
+ error = error === null ? (failure && failure.message ? failure.message : String(failure)) : error;
293
+ return;
294
+ }
295
+ try {
296
+ fs.mkdirSync(targetDir, { recursive: true });
297
+ } catch {
298
+ /* it may already exist */
299
+ }
300
+ for (const entry of entries) {
301
+ if (typeof shouldCancel === 'function' && shouldCancel() === true) {
302
+ cancelled = true;
303
+ return;
304
+ }
305
+ const childSource = path.join(sourceDir, entry.name);
306
+ const childTarget = path.join(targetDir, entry.name);
307
+ if (entry.isDirectory()) {
308
+ await walk(childSource, childTarget);
309
+ if (cancelled) return;
310
+ continue;
311
+ }
312
+ try {
313
+ fs.copyFileSync(childSource, childTarget);
314
+ files += 1;
315
+ try {
316
+ bytes += fs.statSync(childTarget).size;
317
+ } catch {
318
+ /* the size is advisory */
319
+ }
320
+ } catch (failure) {
321
+ error = error === null ? (failure && failure.message ? failure.message : String(failure)) : error;
322
+ continue;
323
+ }
324
+ tick.files += 1;
325
+ const moment = clock();
326
+ if (tick.files >= progressEveryFiles || moment - tick.at >= 150) {
327
+ tick.files = 0;
328
+ report();
329
+ }
330
+ if (moment - tick.yielded >= yieldEveryMs || tick.files >= yieldEveryFiles) {
331
+ tick.yielded = moment;
332
+ // eslint-disable-next-line no-await-in-loop
333
+ await pause();
334
+ if (typeof shouldCancel === 'function' && shouldCancel() === true) {
335
+ cancelled = true;
336
+ return;
337
+ }
338
+ }
339
+ }
340
+ };
341
+
342
+ await walk(from, into);
343
+ report();
344
+ if (cancelled) return { ok: false, files, bytes, cancelled: true, error: null };
345
+ return { ok: true, files, bytes, cancelled: false, error };
346
+ }
347
+
348
+ /**
349
+ * Remove a half-written backup (a cancelled copy must not look like a snapshot).
350
+ *
351
+ * @param {string} dir
352
+ * @returns {boolean}
353
+ */
354
+ export function removeBackupDir(dir) {
355
+ const target = typeof dir === 'string' ? dir.trim() : '';
356
+ if (target === '') return false;
357
+ try {
358
+ fs.rmSync(target, { recursive: true, force: true });
359
+ return true;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
207
365
  /**
208
366
  * Keep only the newest `maxBackups` backup directories.
209
367
  *
@@ -301,6 +459,11 @@ export function readManifest(backupDir) {
301
459
  * rollback of "a plugin upgrade broke the boot" actually possible; without it only
302
460
  * the configuration files are copied.
303
461
  *
462
+ * THE COPY IS INTERRUPTIBLE AND MEASURED: `onProgress` is called with the files
463
+ * and bytes that really landed (plus the measured total), and `shouldCancel` is
464
+ * asked between files. A cancelled copy removes its own half-written snapshot,
465
+ * so a cancel never leaves something that looks like a usable backup.
466
+ *
304
467
  * Never throws: a backup that cannot be written is reported as `{ ok: false }` so
305
468
  * the caller can decide whether the restart may still proceed.
306
469
  *
@@ -310,27 +473,36 @@ export function readManifest(backupDir) {
310
473
  * includePlugins?: boolean,
311
474
  * maxBackups?: number,
312
475
  * now?: number,
476
+ * onProgress?: ((progress: { files: number, bytes: number, total: number, filesTotal: number }) => void)|null,
477
+ * shouldCancel?: (() => boolean)|null,
478
+ * measure?: ((profileDir: string, includePlugins: boolean) => { files: number, bytes: number })|null,
313
479
  * }} options
314
- * @returns {{ ok: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
480
+ * @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
315
481
  */
316
- export function createBackup({
482
+ export async function createBackup({
317
483
  profileDir,
318
484
  backupsDir,
319
485
  includePlugins = true,
320
486
  maxBackups = DEFAULT_MAX_BACKUPS,
321
487
  now = Date.now(),
488
+ onProgress = null,
489
+ shouldCancel = null,
490
+ measure = null,
322
491
  } = {}) {
323
492
  const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
324
493
  const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
325
494
  const startedAt = Date.now();
326
495
  const base = {
327
496
  ok: false,
497
+ cancelled: false,
328
498
  dir: null,
329
499
  stamp: null,
330
500
  at: null,
331
501
  atLocal: null,
332
502
  durationMs: 0,
333
503
  bytes: 0,
504
+ totalBytes: 0,
505
+ fileCount: 0,
334
506
  files: [],
335
507
  includePlugins: includePlugins === true,
336
508
  pruned: [],
@@ -340,37 +512,131 @@ export function createBackup({
340
512
  if (root === '') return { ...base, error: 'createBackup requires backupsDir' };
341
513
  if (!fs.existsSync(profile)) return { ...base, error: 'the profile directory does not exist' };
342
514
 
515
+ // MEASURE FIRST: the progress report needs a denominator that is not made up.
516
+ const measured = (() => {
517
+ try {
518
+ if (typeof measure === 'function') {
519
+ const answer = measure(profile, includePlugins === true);
520
+ return {
521
+ files: Math.max(0, Number(answer && answer.files) || 0),
522
+ bytes: Math.max(0, Number(answer && answer.bytes) || 0),
523
+ };
524
+ }
525
+ const total = { files: 0, bytes: 0 };
526
+ for (const name of CONFIG_FILES) {
527
+ try {
528
+ total.bytes += fs.statSync(path.join(profile, name)).size;
529
+ total.files += 1;
530
+ } catch {
531
+ /* a missing config file is not a problem */
532
+ }
533
+ }
534
+ if (includePlugins === true) {
535
+ const tree = directorySize(path.join(profile, PLUGINS_DIR));
536
+ total.bytes += tree.bytes;
537
+ total.files += tree.files;
538
+ }
539
+ return total;
540
+ } catch {
541
+ return { files: 0, bytes: 0 };
542
+ }
543
+ })();
544
+
343
545
  const stamp = backupStamp(now);
344
546
  const dir = path.join(root, stamp);
345
547
  const files = [];
346
548
  let bytes = 0;
549
+ let fileCount = 0;
550
+ let cancelled = false;
551
+ const isCancelled = () => {
552
+ if (cancelled === true) return true;
553
+ if (typeof shouldCancel !== 'function') return false;
554
+ try {
555
+ if (shouldCancel() === true) {
556
+ cancelled = true;
557
+ return true;
558
+ }
559
+ } catch {
560
+ // A broken cancel callback must not abort anything.
561
+ }
562
+ return false;
563
+ };
564
+ const report = (approx) => {
565
+ if (typeof onProgress !== 'function') return;
566
+ try {
567
+ onProgress({
568
+ files: fileCount,
569
+ bytes,
570
+ total: measured.bytes,
571
+ filesTotal: measured.files,
572
+ // The estimate is passed along so the caller does not have to redo the
573
+ // arithmetic; it is null until there is a real measurement to divide.
574
+ ...(approx ? { approximate: true } : {}),
575
+ });
576
+ } catch {
577
+ // Progress reporting never fails a backup.
578
+ }
579
+ };
580
+
347
581
  try {
348
582
  fs.mkdirSync(path.join(dir, CONFIG_DIR), { recursive: true });
349
583
  } catch (error) {
350
- return { ...base, stamp, error: 'could not create the backup directory: ' + (error && error.message ? error.message : String(error)) };
584
+ return { ...base, stamp, totalBytes: measured.bytes, error: 'could not create the backup directory: ' + (error && error.message ? error.message : String(error)) };
351
585
  }
352
586
 
587
+ let configBytes = 0;
353
588
  for (const name of CONFIG_FILES) {
589
+ if (isCancelled()) break;
354
590
  const source = path.join(profile, name);
355
591
  if (!fs.existsSync(source)) continue;
356
592
  if (copyEntry(source, path.join(dir, CONFIG_DIR, name))) {
357
593
  files.push(path.join(CONFIG_DIR, name));
594
+ fileCount += 1;
358
595
  try {
359
- bytes += fs.statSync(path.join(dir, CONFIG_DIR, name)).size;
596
+ configBytes += fs.statSync(path.join(dir, CONFIG_DIR, name)).size;
597
+ bytes = configBytes;
360
598
  } catch {
361
599
  // Size reporting is advisory only.
362
600
  }
601
+ report(false);
363
602
  }
364
603
  }
365
604
 
366
- if (includePlugins === true) {
605
+ if (!cancelled && includePlugins === true) {
367
606
  const source = path.join(profile, PLUGINS_DIR);
368
- if (fs.existsSync(source) && copyEntry(source, path.join(dir, PLUGINS_DIR))) {
369
- files.push(PLUGINS_DIR);
370
- bytes += directorySize(path.join(dir, PLUGINS_DIR)).bytes;
607
+ if (fs.existsSync(source)) {
608
+ const copied = await copyTreeWithProgress({
609
+ source,
610
+ destination: path.join(dir, PLUGINS_DIR),
611
+ shouldCancel: isCancelled,
612
+ onProgress: (progress) => {
613
+ fileCount = files.length + progress.files;
614
+ bytes = configBytes + progress.bytes;
615
+ report(false);
616
+ },
617
+ });
618
+ if (copied.cancelled) {
619
+ cancelled = true;
620
+ } else if (copied.ok || copied.files > 0) {
621
+ files.push(PLUGINS_DIR);
622
+ fileCount = files.length + copied.files;
623
+ bytes = configBytes + copied.bytes;
624
+ }
371
625
  }
372
626
  }
373
627
 
628
+ if (cancelled) {
629
+ removeBackupDir(dir);
630
+ return {
631
+ ...base,
632
+ cancelled: true,
633
+ stamp,
634
+ totalBytes: measured.bytes,
635
+ durationMs: Math.max(0, Date.now() - startedAt),
636
+ error: 'the backup was cancelled',
637
+ };
638
+ }
639
+
374
640
  const manifest = {
375
641
  stamp,
376
642
  at: Number(now),
@@ -381,11 +647,13 @@ export function createBackup({
381
647
  includePlugins: includePlugins === true,
382
648
  files,
383
649
  bytes,
650
+ totalBytes: measured.bytes,
651
+ fileCount,
384
652
  };
385
653
  try {
386
654
  fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2), 'utf8');
387
655
  } catch (error) {
388
- return { ...base, stamp, files, bytes, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
656
+ return { ...base, stamp, files, bytes, totalBytes: measured.bytes, fileCount, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
389
657
  }
390
658
 
391
659
  const pruned = pruneBackups(root, maxBackups);
@@ -398,6 +666,8 @@ export function createBackup({
398
666
  atLocal: manifest.atLocal,
399
667
  durationMs: Math.max(0, Date.now() - startedAt),
400
668
  bytes,
669
+ totalBytes: measured.bytes,
670
+ fileCount,
401
671
  files,
402
672
  pruned,
403
673
  };
package/lib/checkpoint.js CHANGED
@@ -30,6 +30,7 @@ import fs from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import { randomBytes, randomUUID } from 'node:crypto';
32
32
  import { PHASES, selectRecoverableSessions } from './lifecycle.js';
33
+ import { RESTART_PHASES } from './restart-phase.js';
33
34
 
34
35
  /** Current on-disk schema version. */
35
36
  export const STATE_VERSION = 1;
@@ -101,6 +102,7 @@ const TOP_LEVEL_KEYS = new Set([
101
102
  'bootStartedAt',
102
103
  'generation',
103
104
  'phase',
105
+ 'restartPhase',
104
106
  'restart',
105
107
  'sessions',
106
108
  'parkedGoals',
@@ -173,6 +175,11 @@ export function createEmptyState(now = Date.now()) {
173
175
  bootStartedAt: null,
174
176
  generation: 0,
175
177
  phase: 'idle',
178
+ // WHICH STAGE THE RESTART IS IN (see lib/restart-phase.js): null when no
179
+ // restart is under way, otherwise `{ phase, startedAt, at, progress }`.
180
+ // It lives in the checkpoint (not in memory) so the interface can read it
181
+ // out of the state route from the very first moment after the click.
182
+ restartPhase: null,
176
183
  restart: null,
177
184
  sessions: {},
178
185
  seenFeatureVersions: {},
@@ -182,6 +189,146 @@ export function createEmptyState(now = Date.now()) {
182
189
  };
183
190
  }
184
191
 
192
+ /**
193
+ * Coerce the recorded restart stage into the documented shape.
194
+ *
195
+ * A record that does not describe one of the four stages is dropped: a broken
196
+ * stage must never leave a spinner on screen for ever.
197
+ *
198
+ * @param {unknown} raw
199
+ * @param {number} now
200
+ * @returns {{ record: object|null, changed: boolean }}
201
+ */
202
+ function normalizeRestartPhase(raw, now) {
203
+ if (raw == null) return { record: null, changed: false };
204
+ if (!isObject(raw)) return { record: null, changed: true };
205
+ if (!RESTART_PHASES.includes(raw.phase)) return { record: null, changed: true };
206
+ const startedAt = isFiniteNumber(raw.startedAt) ? raw.startedAt : now;
207
+ const at = isFiniteNumber(raw.at) ? raw.at : startedAt;
208
+ let progress = null;
209
+ let changed = !isFiniteNumber(raw.startedAt) || !isFiniteNumber(raw.at);
210
+ if (isObject(raw.progress)) {
211
+ progress = {
212
+ files: isFiniteNumber(raw.progress.files) ? Math.max(0, Math.trunc(raw.progress.files)) : 0,
213
+ bytes: isFiniteNumber(raw.progress.bytes) ? Math.max(0, Math.trunc(raw.progress.bytes)) : 0,
214
+ total: isFiniteNumber(raw.progress.total) ? Math.max(0, Math.trunc(raw.progress.total)) : 0,
215
+ filesTotal: isFiniteNumber(raw.progress.filesTotal) ? Math.max(0, Math.trunc(raw.progress.filesTotal)) : 0,
216
+ at: isFiniteNumber(raw.progress.at) ? raw.progress.at : at,
217
+ };
218
+ } else if (raw.progress != null) {
219
+ changed = true;
220
+ }
221
+ const record = {
222
+ phase: raw.phase,
223
+ startedAt,
224
+ at,
225
+ progress,
226
+ // HOW BIG THE PRE-RESTART BACKUP WAS. It is kept across the stage change on
227
+ // purpose: a hosted profile can be 92 KB and the copy takes ~5 ms, so a page
228
+ // that polls every 2 s would otherwise never learn that a backup happened
229
+ // (user report, 2026-10-29). null = nothing was measured.
230
+ snapshotBytes: isFiniteNumber(raw.snapshotBytes) ? Math.max(0, Math.trunc(raw.snapshotBytes)) : null,
231
+ blocked: isFiniteNumber(raw.blocked) ? Math.max(0, Math.trunc(raw.blocked)) : 0,
232
+ };
233
+ return { record, changed };
234
+ }
235
+
236
+ /**
237
+ * Record the stage the restart is in — the ONE thing the interface reads.
238
+ *
239
+ * Writing it is deliberately cheap and synchronous: it is called the moment the
240
+ * user clicks, before any backup starts, so the first stage is on disk in
241
+ * milliseconds rather than after ten seconds of copying.
242
+ *
243
+ * @param {object} state the checkpoint state (mutated in place)
244
+ * @param {{ phase: string, now?: number, blocked?: number, restartStage?: boolean }} options
245
+ * @returns {object|null} the recorded stage
246
+ */
247
+ export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, restartStage = true } = {}) {
248
+ if (!isObject(state)) return null;
249
+ if (!RESTART_PHASES.includes(phase)) return state.restartPhase ?? null;
250
+ const moment = isFiniteNumber(now) ? now : Date.now();
251
+ const previous = isObject(state.restartPhase) ? state.restartPhase : null;
252
+ // A new restart starts its own clock; a stage change inside the same restart
253
+ // keeps the start time, so "elapsed" is the whole restart and not one stage.
254
+ const sameRestart = restartStage && previous !== null && RESTART_PHASES.includes(previous.phase)
255
+ && !(phase === 'waiting' && previous.phase !== 'waiting');
256
+ const startedAt = sameRestart ? previous.startedAt : moment;
257
+ // THE SIZE OF THE SNAPSHOT, measured once and then CARRIED: the live byte
258
+ // counter while the copy runs, the final size afterwards. A 92 KB profile is
259
+ // copied in ~5 ms, so this number is the only thing a polling interface can
260
+ // still say about a backup that really happened (user report, 2026-10-29).
261
+ const measuredBytes = previous !== null && isFiniteNumber(previous.snapshotBytes)
262
+ ? Math.max(0, Math.trunc(previous.snapshotBytes))
263
+ : (previous !== null && isObject(previous.progress) && isFiniteNumber(previous.progress.bytes)
264
+ ? Math.max(0, Math.trunc(previous.progress.bytes))
265
+ : null);
266
+ const record = {
267
+ phase,
268
+ startedAt,
269
+ at: moment,
270
+ // The progress of a previous stage is meaningless in the next one: the
271
+ // snapshot numbers must not be shown while the host is restarting.
272
+ progress: phase === 'snapshotting' && previous !== null && previous.phase === 'snapshotting'
273
+ ? previous.progress
274
+ : null,
275
+ // A NEW restart starts with nothing measured; a stage change inside one
276
+ // restart keeps the size it already has.
277
+ snapshotBytes: sameRestart ? measuredBytes : null,
278
+ blocked: isFiniteNumber(blocked) ? Math.max(0, Math.trunc(blocked)) : 0,
279
+ };
280
+ state.restartPhase = record;
281
+ state.updatedAt = moment;
282
+ return record;
283
+ }
284
+
285
+ /**
286
+ * Update the REAL snapshot progress (files and bytes that were copied).
287
+ *
288
+ * @param {object} state the checkpoint state (mutated in place)
289
+ * @param {{ files?: number, bytes?: number, total?: number, filesTotal?: number, now?: number }} progress
290
+ * @returns {object|null} the recorded stage
291
+ */
292
+ export function setRestartProgress(state, { files = 0, bytes = 0, total = 0, filesTotal = 0, now = Date.now() } = {}) {
293
+ if (!isObject(state)) return null;
294
+ const moment = isFiniteNumber(now) ? now : Date.now();
295
+ const previous = isObject(state.restartPhase) ? state.restartPhase : null;
296
+ const number = (value) => (isFiniteNumber(value) ? Math.max(0, Math.trunc(value)) : 0);
297
+ state.restartPhase = {
298
+ phase: 'snapshotting',
299
+ startedAt: previous !== null && isFiniteNumber(previous.startedAt) ? previous.startedAt : moment,
300
+ at: moment,
301
+ progress: {
302
+ files: number(files),
303
+ bytes: number(bytes),
304
+ total: number(total),
305
+ filesTotal: number(filesTotal),
306
+ at: moment,
307
+ },
308
+ // The live byte counter IS the size of the snapshot so far: keeping it here
309
+ // means the number survives the moment the copy ends.
310
+ snapshotBytes: number(bytes),
311
+ blocked: previous !== null && isFiniteNumber(previous.blocked) ? previous.blocked : 0,
312
+ };
313
+ state.updatedAt = moment;
314
+ return state.restartPhase;
315
+ }
316
+
317
+ /**
318
+ * Forget the stage (the restart was cancelled, or it failed before exiting).
319
+ *
320
+ * @param {object} state
321
+ * @param {{ now?: number }} [options]
322
+ * @returns {boolean} whether a stage was cleared
323
+ */
324
+ export function clearRestartPhase(state, { now = Date.now() } = {}) {
325
+ if (!isObject(state)) return false;
326
+ if (state.restartPhase == null) return false;
327
+ state.restartPhase = null;
328
+ state.updatedAt = isFiniteNumber(now) ? now : Date.now();
329
+ return true;
330
+ }
331
+
185
332
  /**
186
333
  * Normalize the record of the one-time questions.
187
334
  *
@@ -634,6 +781,12 @@ export function normalizeState(raw, now = Date.now()) {
634
781
  if (typeof raw.phase === 'string' && PHASES.includes(raw.phase)) state.phase = raw.phase;
635
782
  else if (raw.phase != null) repaired = true;
636
783
 
784
+ if (raw.restartPhase != null) {
785
+ const recorded = normalizeRestartPhase(raw.restartPhase, now);
786
+ state.restartPhase = recorded.record;
787
+ if (recorded.changed) repaired = true;
788
+ }
789
+
637
790
  if (isFiniteNumber(raw.updatedAt)) state.updatedAt = raw.updatedAt;
638
791
  else if (raw.updatedAt != null) repaired = true;
639
792
 
@@ -181,6 +181,24 @@ export function buildDiagnostics(payload = {}) {
181
181
  lines.push('watchdog available: ' + (field(watchdog, 'available', false) === true ? 'yes' : 'no'))
182
182
  lines.push('watchdog last result: ' + redactSecrets(field(watchdogReport, 'result', 'never ran')))
183
183
  lines.push('watchdog last tier: ' + redactSecrets(field(watchdogReport, 'tier', 'unknown')))
184
+ // THE DISARM: did the host stand the watchdog down? A failed disarm leaves an
185
+ // armed one behind (the 0.8.1 incident), so its outcome and the first error
186
+ // belong in the text the user can hand to us.
187
+ const disarm = field(source, 'watchdogDisarm', null)
188
+ if (disarm && typeof disarm === 'object') {
189
+ lines.push('watchdog disarm: ' + (field(disarm, 'ok', false) === true ? 'ok' : 'FAILED'))
190
+ lines.push('watchdog disarm source: ' + redactSecrets(field(disarm, 'source', 'unknown')))
191
+ lines.push('watchdog disarm attempts: ' + redactSecrets(String(field(disarm, 'attempts', 0))))
192
+ lines.push('watchdog disarm pending left: ' + (field(disarm, 'pending', false) === true ? 'yes' : 'no'))
193
+ const disarmError = field(disarm, 'error', null)
194
+ if (disarmError && typeof disarmError === 'object') {
195
+ lines.push('watchdog disarm error: '
196
+ + redactSecrets(field(disarmError, 'name', 'error'))
197
+ + ': ' + redactSecrets(field(disarmError, 'message', '')))
198
+ }
199
+ } else {
200
+ lines.push('watchdog disarm: never ran')
201
+ }
184
202
  const stateDir = truncatePath(field(source, 'stateDir', ''))
185
203
  if (stateDir !== '') lines.push('dsh home: ' + redactSecrets(stateDir))
186
204
  lines.push('--- end ---')