dsh-autorestart 0.8.0 → 0.8.2

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,127 @@ 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
+ blocked: isFiniteNumber(raw.blocked) ? Math.max(0, Math.trunc(raw.blocked)) : 0,
227
+ };
228
+ return { record, changed };
229
+ }
230
+
231
+ /**
232
+ * Record the stage the restart is in — the ONE thing the interface reads.
233
+ *
234
+ * Writing it is deliberately cheap and synchronous: it is called the moment the
235
+ * user clicks, before any backup starts, so the first stage is on disk in
236
+ * milliseconds rather than after ten seconds of copying.
237
+ *
238
+ * @param {object} state the checkpoint state (mutated in place)
239
+ * @param {{ phase: string, now?: number, blocked?: number, restartStage?: boolean }} options
240
+ * @returns {object|null} the recorded stage
241
+ */
242
+ export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, restartStage = true } = {}) {
243
+ if (!isObject(state)) return null;
244
+ if (!RESTART_PHASES.includes(phase)) return state.restartPhase ?? null;
245
+ const moment = isFiniteNumber(now) ? now : Date.now();
246
+ const previous = isObject(state.restartPhase) ? state.restartPhase : null;
247
+ // A new restart starts its own clock; a stage change inside the same restart
248
+ // keeps the start time, so "elapsed" is the whole restart and not one stage.
249
+ const startedAt = restartStage && previous !== null && RESTART_PHASES.includes(previous.phase)
250
+ && !(phase === 'waiting' && previous.phase !== 'waiting')
251
+ ? previous.startedAt
252
+ : moment;
253
+ const record = {
254
+ phase,
255
+ startedAt,
256
+ at: moment,
257
+ // The progress of a previous stage is meaningless in the next one: the
258
+ // snapshot numbers must not be shown while the host is restarting.
259
+ progress: phase === 'snapshotting' && previous !== null && previous.phase === 'snapshotting'
260
+ ? previous.progress
261
+ : null,
262
+ blocked: isFiniteNumber(blocked) ? Math.max(0, Math.trunc(blocked)) : 0,
263
+ };
264
+ state.restartPhase = record;
265
+ state.updatedAt = moment;
266
+ return record;
267
+ }
268
+
269
+ /**
270
+ * Update the REAL snapshot progress (files and bytes that were copied).
271
+ *
272
+ * @param {object} state the checkpoint state (mutated in place)
273
+ * @param {{ files?: number, bytes?: number, total?: number, filesTotal?: number, now?: number }} progress
274
+ * @returns {object|null} the recorded stage
275
+ */
276
+ export function setRestartProgress(state, { files = 0, bytes = 0, total = 0, filesTotal = 0, now = Date.now() } = {}) {
277
+ if (!isObject(state)) return null;
278
+ const moment = isFiniteNumber(now) ? now : Date.now();
279
+ const previous = isObject(state.restartPhase) ? state.restartPhase : null;
280
+ const number = (value) => (isFiniteNumber(value) ? Math.max(0, Math.trunc(value)) : 0);
281
+ state.restartPhase = {
282
+ phase: 'snapshotting',
283
+ startedAt: previous !== null && isFiniteNumber(previous.startedAt) ? previous.startedAt : moment,
284
+ at: moment,
285
+ progress: {
286
+ files: number(files),
287
+ bytes: number(bytes),
288
+ total: number(total),
289
+ filesTotal: number(filesTotal),
290
+ at: moment,
291
+ },
292
+ blocked: previous !== null && isFiniteNumber(previous.blocked) ? previous.blocked : 0,
293
+ };
294
+ state.updatedAt = moment;
295
+ return state.restartPhase;
296
+ }
297
+
298
+ /**
299
+ * Forget the stage (the restart was cancelled, or it failed before exiting).
300
+ *
301
+ * @param {object} state
302
+ * @param {{ now?: number }} [options]
303
+ * @returns {boolean} whether a stage was cleared
304
+ */
305
+ export function clearRestartPhase(state, { now = Date.now() } = {}) {
306
+ if (!isObject(state)) return false;
307
+ if (state.restartPhase == null) return false;
308
+ state.restartPhase = null;
309
+ state.updatedAt = isFiniteNumber(now) ? now : Date.now();
310
+ return true;
311
+ }
312
+
185
313
  /**
186
314
  * Normalize the record of the one-time questions.
187
315
  *
@@ -634,6 +762,12 @@ export function normalizeState(raw, now = Date.now()) {
634
762
  if (typeof raw.phase === 'string' && PHASES.includes(raw.phase)) state.phase = raw.phase;
635
763
  else if (raw.phase != null) repaired = true;
636
764
 
765
+ if (raw.restartPhase != null) {
766
+ const recorded = normalizeRestartPhase(raw.restartPhase, now);
767
+ state.restartPhase = recorded.record;
768
+ if (recorded.changed) repaired = true;
769
+ }
770
+
637
771
  if (isFiniteNumber(raw.updatedAt)) state.updatedAt = raw.updatedAt;
638
772
  else if (raw.updatedAt != null) repaired = true;
639
773
 
@@ -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 ---')