dsh-autorestart 0.6.1 → 0.7.0

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * dsh-autorestart — the NODE INTERPRETER the supervisor is started with.
3
+ *
4
+ * WHY THIS FILE EXISTS:
5
+ *
6
+ * The supervisor is a plain node script. The record that starts it at login
7
+ * must therefore name a REAL node interpreter. `process.execPath` is NOT
8
+ * always node:
9
+ *
10
+ * - in the hosted web host it is node (`/usr/bin/node`, `node.exe`);
11
+ * - in the DESKTOP APP it is the Electron client
12
+ * (`…\DeepSeek Harness.exe`).
13
+ *
14
+ * A login record that starts the Electron client would open a SECOND copy of
15
+ * the client at every login (the login record inherits the environment of the
16
+ * user, which does NOT carry `ELECTRON_RUN_AS_NODE=1`, so the exe starts the
17
+ * full application instead of running the script as node).
18
+ *
19
+ * So the interpreter is RESOLVED, never guessed:
20
+ *
21
+ * 1. the process itself IS node (the file name starts with `node`);
22
+ * 2. the runtime that ships with the installation
23
+ * (`resources/runtime/primary-runtime/dependencies/node/bin/node.exe`);
24
+ * 3. the `node` of the PATH.
25
+ *
26
+ * When none of them is there the answer is "no interpreter": the caller
27
+ * refuses the installation and says why. The application executable is NEVER
28
+ * used as a stand-in for node.
29
+ *
30
+ * ESM only, Node builtins only.
31
+ */
32
+
33
+ import fs from 'node:fs'
34
+ import path from 'node:path'
35
+
36
+ /**
37
+ * The node interpreter inside the runtime that ships with the installation.
38
+ *
39
+ * It is relative to the directory of the application executable.
40
+ */
41
+ export const BUNDLED_NODE_RELATIVE_PATH = Object.freeze([
42
+ 'resources',
43
+ 'runtime',
44
+ 'primary-runtime',
45
+ 'dependencies',
46
+ 'node',
47
+ 'bin',
48
+ 'node.exe',
49
+ ])
50
+
51
+ /**
52
+ * Is this file name the name of a node interpreter?
53
+ *
54
+ * `node`, `node.exe`, `node22.exe` and `nodejs` are interpreters; a directory
55
+ * called `node_modules` is not.
56
+ *
57
+ * @param {string} execPath
58
+ * @returns {boolean}
59
+ */
60
+ export function isNodeExecutable(execPath) {
61
+ const text = String(execPath ?? '').trim()
62
+ if (text === '') return false
63
+ const base = path.basename(text).toLowerCase()
64
+ if (!base.startsWith('node')) return false
65
+ return !base.includes('node_modules')
66
+ }
67
+
68
+ /**
69
+ * The directories of the PATH (Windows spells it `Path` as well).
70
+ *
71
+ * @param {object} env
72
+ * @returns {string[]}
73
+ */
74
+ function pathDirectories(env) {
75
+ const raw = typeof env.PATH === 'string' && env.PATH !== ''
76
+ ? env.PATH
77
+ : (typeof env.Path === 'string' ? env.Path : '')
78
+ if (raw === '') return []
79
+ return raw.split(path.delimiter).map((part) => part.trim()).filter((part) => part !== '')
80
+ }
81
+
82
+ /**
83
+ * Find a real node interpreter on this machine.
84
+ *
85
+ * @param {{
86
+ * execPath?: string,
87
+ * platform?: string,
88
+ * env?: object,
89
+ * existsSyncFn?: Function,
90
+ * }} [options]
91
+ * @returns {{
92
+ * ok: boolean,
93
+ * path: string,
94
+ * source: 'process'|'install-dir'|'path'|'',
95
+ * reason: string,
96
+ * tried: string[],
97
+ * }}
98
+ */
99
+ export function resolveNodeInterpreter(options = {}) {
100
+ const platform = typeof options.platform === 'string' ? options.platform : process.platform
101
+ const execPath = String(options.execPath ?? '')
102
+ const env = options.env && typeof options.env === 'object' ? options.env : process.env
103
+ const exists = typeof options.existsSyncFn === 'function' ? options.existsSyncFn : fs.existsSync
104
+ const tried = []
105
+
106
+ // (1) The process IS node.
107
+ if (isNodeExecutable(execPath)) {
108
+ return { ok: true, path: execPath, source: 'process', reason: '', tried }
109
+ }
110
+
111
+ // (2) The runtime that ships with the installation.
112
+ if (execPath !== '') {
113
+ const directory = path.dirname(execPath)
114
+ const candidates = platform === 'win32'
115
+ ? [path.join(directory, ...BUNDLED_NODE_RELATIVE_PATH)]
116
+ : []
117
+ for (const candidate of candidates) {
118
+ tried.push(candidate)
119
+ if (exists(candidate)) {
120
+ return { ok: true, path: candidate, source: 'install-dir', reason: '', tried }
121
+ }
122
+ }
123
+ }
124
+
125
+ // (3) The PATH.
126
+ const names = platform === 'win32' ? ['node.exe'] : ['node']
127
+ for (const directory of pathDirectories(env)) {
128
+ for (const name of names) {
129
+ const candidate = path.join(directory, name)
130
+ tried.push(candidate)
131
+ if (exists(candidate)) {
132
+ return { ok: true, path: candidate, source: 'path', reason: '', tried }
133
+ }
134
+ }
135
+ }
136
+
137
+ return {
138
+ ok: false,
139
+ path: '',
140
+ source: '',
141
+ reason: 'no node interpreter was found: the executable of this process is not node, the installation carries no bundled node, and the PATH has none either — the application executable is never used as a stand-in for node',
142
+ tried,
143
+ }
144
+ }
package/lib/queued.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * dsh-autorestart — the upper bound of a QUEUED inbox message.
3
+ *
4
+ * THE DEFECT THIS MODULE FIXES
5
+ *
6
+ * A queued message (an inbox entry that nobody has claimed yet) used to block
7
+ * the restart FOR EVER. The idle gate counted "this session has a queued
8
+ * message" as "this session is busy", and the hard deadline only WARNED, so a
9
+ * session that never ran its turn (the real case: a fresh, empty conversation
10
+ * in the UI with one unclaimed message in its inbox) kept the pending restart
11
+ * pending until the user gave up.
12
+ *
13
+ * THE POLICY
14
+ *
15
+ * - a message that is claimed inside the grace window keeps blocking the
16
+ * restart (that is the normal case: the agent picks it up in a second);
17
+ * - a message that is still unclaimed after the grace window is STUCK: it
18
+ * stops blocking the restart and is named in the log, so the user can see
19
+ * WHICH session holds the queue;
20
+ * - a message that is being worked on (the session is `running`) always
21
+ * blocks, under EVERY grace setting. This module only ever judges queued
22
+ * work — it never relaxes the rule that a live turn is never interrupted.
23
+ *
24
+ * The tracker knows nothing about the host: it is a map of session id to the
25
+ * moment the session was FIRST seen with a queued message, so it can be unit
26
+ * tested with a fake clock.
27
+ *
28
+ * ESM only, Node builtins only. No DSH / npm imports.
29
+ */
30
+
31
+ /** The default upper bound of a queued message: one minute. */
32
+ export const DEFAULT_QUEUED_GRACE_MS = 60000
33
+
34
+ /** @param {unknown} value */
35
+ function isFiniteNumber(value) {
36
+ return typeof value === 'number' && Number.isFinite(value)
37
+ }
38
+
39
+ /**
40
+ * Normalise a grace window.
41
+ *
42
+ * A negative or non-numeric value falls back to the default; `0` is a LEGAL
43
+ * value — it switches the protection off and restores the old behaviour (a
44
+ * queued message blocks the restart for ever).
45
+ *
46
+ * @param {unknown} value
47
+ * @returns {number} milliseconds, never negative
48
+ */
49
+ export function normalizeQueuedGraceMs(value) {
50
+ if (!isFiniteNumber(value) || value < 0) return DEFAULT_QUEUED_GRACE_MS
51
+ return Math.trunc(value)
52
+ }
53
+
54
+ /**
55
+ * Create the tracker.
56
+ *
57
+ * @param {{ now?: () => number }} [options] the clock (injectable for tests)
58
+ * @returns {{
59
+ * observe: (agentId: string, queued: boolean, graceMs?: number) => { blocking: boolean, firstSeenAt: number|null, ageMs: number, becameStale: boolean },
60
+ * forget: (agentId: string) => boolean,
61
+ * size: () => number,
62
+ * ids: () => string[],
63
+ * }}
64
+ */
65
+ export function createQueuedTracker({ now = () => Date.now() } = {}) {
66
+ /** @type {Map<string, { firstSeenAt: number, stale: boolean }>} */
67
+ const records = new Map()
68
+
69
+ /** The current time of the injected clock (never throws). */
70
+ const clock = () => {
71
+ try {
72
+ const value = now()
73
+ return isFiniteNumber(value) ? value : 0
74
+ } catch {
75
+ return 0
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Observe one session.
81
+ *
82
+ * @param {string} agentId the session id
83
+ * @param {boolean} queued does the session have an unclaimed message?
84
+ * @param {number} [graceMs] the upper bound (0 = no upper bound)
85
+ * @returns {{ blocking: boolean, firstSeenAt: number|null, ageMs: number, becameStale: boolean }}
86
+ */
87
+ const observe = (agentId, queued, graceMs) => {
88
+ const id = agentId == null ? '' : String(agentId)
89
+ if (id === '') return { blocking: false, firstSeenAt: null, ageMs: 0, becameStale: false }
90
+
91
+ // The session has no queued work any more: its record is dropped, so a
92
+ // NEW queue starts counting from zero.
93
+ if (queued !== true) {
94
+ records.delete(id)
95
+ return { blocking: false, firstSeenAt: null, ageMs: 0, becameStale: false }
96
+ }
97
+
98
+ const at = clock()
99
+ let record = records.get(id)
100
+ if (record === undefined) {
101
+ record = { firstSeenAt: at, stale: false }
102
+ records.set(id, record)
103
+ }
104
+ const ageMs = Math.max(0, at - record.firstSeenAt)
105
+ const grace = normalizeQueuedGraceMs(graceMs)
106
+
107
+ // The protection is off (grace 0): a queued message blocks the restart for
108
+ // ever — exactly the behaviour before this module existed.
109
+ if (grace === 0) {
110
+ return { blocking: true, firstSeenAt: record.firstSeenAt, ageMs, becameStale: false }
111
+ }
112
+
113
+ // Still inside the grace window: the agent may claim the message any
114
+ // moment, so the restart keeps waiting for it.
115
+ if (ageMs < grace) {
116
+ return { blocking: true, firstSeenAt: record.firstSeenAt, ageMs, becameStale: false }
117
+ }
118
+
119
+ // The message is STUCK: it stops blocking the restart. `becameStale` is
120
+ // true only on the tick where it turned stale, so the caller can log it
121
+ // exactly once.
122
+ const becameStale = record.stale !== true
123
+ record.stale = true
124
+ return { blocking: false, firstSeenAt: record.firstSeenAt, ageMs, becameStale }
125
+ }
126
+
127
+ /**
128
+ * Drop the record of one session (it is running, or it is gone).
129
+ *
130
+ * @param {string} agentId
131
+ * @returns {boolean} whether a record was dropped
132
+ */
133
+ const forget = (agentId) => {
134
+ const id = agentId == null ? '' : String(agentId)
135
+ if (id === '') return false
136
+ return records.delete(id)
137
+ }
138
+
139
+ /** How many sessions are tracked right now. */
140
+ const size = () => records.size
141
+
142
+ /** The ids of the tracked sessions. */
143
+ const ids = () => Array.from(records.keys())
144
+
145
+ return { observe, forget, size, ids }
146
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * dsh-autorestart — the durable record of ONE restart.
3
+ *
4
+ * A restart has TWO halves that live in two different processes:
5
+ *
6
+ * - the instance that is being replaced takes the pre-restart snapshot and
7
+ * then dies (the restart command kills it — that IS what restarting the
8
+ * host means);
9
+ * - the instance that replaces it has to prove that it really came up.
10
+ *
11
+ * Neither half can write the whole truth on its own, so the record lives in a
12
+ * FILE that both halves touch:
13
+ *
14
+ * 1. the OLD instance writes `result: 'pending'` BEFORE it runs the restart
15
+ * command — together with the snapshot it took (directory name, how long
16
+ * it took, which older snapshots were pruned);
17
+ * 2. the NEW instance reads that record on boot, runs the four boot checks
18
+ * (listener / routes / plugin / recovery) and rewrites it as `ok` or
19
+ * `failed`, with the time it verified itself and how long the whole
20
+ * restart took.
21
+ *
22
+ * If the new instance never comes up, the file stays `pending` — which is
23
+ * exactly what the operator has to see: a restart that was started and never
24
+ * finished. A record that says nothing is worse than no record at all, so the
25
+ * verdict is ALSO exposed by `/__dsh_autorestart/health?state=1` and by the
26
+ * `continuum_status` tool.
27
+ *
28
+ * The time is reported in BOTH zones: the snapshot directory is named in UTC
29
+ * (`2026-10-29T00-25-20-779Z`), while the operator reads the wall clock of
30
+ * the machine (`08:25`). Comparing the two without a conversion is how a
31
+ * perfectly fresh snapshot looks like an eight-hour-old leftover — so the
32
+ * record carries the local time of the snapshot as well.
33
+ *
34
+ * ESM only, Node builtins only. No DSH / npm imports.
35
+ */
36
+
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import { randomBytes } from 'node:crypto';
40
+
41
+ /** The file the verdict lives in (inside the state directory). */
42
+ export const RESTART_VERDICT_FILE = 'last-restart-verdict.json';
43
+
44
+ /** The three results a restart can have. */
45
+ export const RESTART_RESULTS = Object.freeze({
46
+ /** The restart was started; nobody confirmed it yet. */
47
+ PENDING: 'pending',
48
+ /** The new instance came up and every check passed. */
49
+ OK: 'ok',
50
+ /** The new instance came up but something did not work. */
51
+ FAILED: 'failed',
52
+ });
53
+
54
+ /** The four checks the new instance runs on itself. */
55
+ export const RESTART_CHECK_NAMES = Object.freeze(['listener', 'routes', 'plugin', 'recovery']);
56
+
57
+ /** @param {unknown} value */
58
+ function isObject(value) {
59
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
60
+ }
61
+
62
+ /** @param {unknown} value */
63
+ function isFiniteNumber(value) {
64
+ return typeof value === 'number' && Number.isFinite(value);
65
+ }
66
+
67
+ /** @param {unknown} value @param {string|null} fallback */
68
+ function nullableString(value, fallback = null) {
69
+ return typeof value === 'string' && value !== '' ? value : fallback;
70
+ }
71
+
72
+ /** @param {unknown} value */
73
+ function nullableNumber(value) {
74
+ return isFiniteNumber(value) ? value : null;
75
+ }
76
+
77
+ /**
78
+ * The local time of an epoch, with the UTC offset — the time the operator
79
+ * sees on the machine (`2026-10-29T08:25:20+08:00`).
80
+ *
81
+ * @param {number|null|undefined} at
82
+ * @returns {string|null}
83
+ */
84
+ export function localIsoTime(at) {
85
+ if (!isFiniteNumber(at)) return null;
86
+ const date = new Date(Number(at));
87
+ if (Number.isNaN(date.getTime())) return null;
88
+ const offsetMinutes = -date.getTimezoneOffset();
89
+ const sign = offsetMinutes >= 0 ? '+' : '-';
90
+ const absolute = Math.abs(offsetMinutes);
91
+ const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
92
+ const minutes = String(absolute % 60).padStart(2, '0');
93
+ const local = new Date(Number(at) + offsetMinutes * 60000).toISOString();
94
+ return `${local.slice(0, 19)}${sign}${hours}:${minutes}`;
95
+ }
96
+
97
+ /**
98
+ * The snapshot part of the verdict: WHICH snapshot was taken, how long it
99
+ * took and which older snapshots were pruned.
100
+ *
101
+ * @param {{
102
+ * requested?: boolean,
103
+ * backup?: object|null,
104
+ * profileDir?: string|null,
105
+ * durationMs?: number|null,
106
+ * error?: string|null,
107
+ * }} [input]
108
+ * @returns {object}
109
+ */
110
+ export function buildSnapshotRecord({
111
+ requested = false,
112
+ backup = null,
113
+ profileDir = null,
114
+ durationMs = null,
115
+ error = null,
116
+ } = {}) {
117
+ const taken = isObject(backup) && backup.ok === true;
118
+ const stamp = taken ? nullableString(backup.stamp) : null;
119
+ const at = taken ? nullableNumber(backup.at) : null;
120
+ return {
121
+ requested: requested === true,
122
+ taken,
123
+ dir: taken ? nullableString(backup.dir) : null,
124
+ stamp,
125
+ at,
126
+ // The human-readable time of the snapshot: the directory name is UTC.
127
+ atLocal: at === null ? null : localIsoTime(at),
128
+ durationMs: isFiniteNumber(durationMs) ? Math.trunc(durationMs) : null,
129
+ bytes: taken ? nullableNumber(backup.bytes) : null,
130
+ files: taken && Array.isArray(backup.files) ? backup.files.map(String) : [],
131
+ pruned: taken && Array.isArray(backup.pruned) ? backup.pruned.map(String) : [],
132
+ profileDir: nullableString(profileDir),
133
+ error: nullableString(error) ?? (taken ? null : (nullableString(isObject(backup) ? backup.error : null) ?? 'the snapshot was not taken')),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Normalize one boot check.
139
+ *
140
+ * @param {unknown} raw
141
+ * @returns {{ name: string, ok: boolean, detail: string|null }}
142
+ */
143
+ function normalizeCheck(raw) {
144
+ if (!isObject(raw)) return { name: 'unknown', ok: false, detail: null };
145
+ return {
146
+ name: typeof raw.name === 'string' && raw.name !== '' ? raw.name : 'unknown',
147
+ ok: raw.ok === true,
148
+ detail: nullableString(raw.detail),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Build a verdict record from a loose input.
154
+ *
155
+ * @param {{
156
+ * result?: string,
157
+ * mode?: string|null,
158
+ * generation?: number|null,
159
+ * requestedAt?: number|null,
160
+ * startedAt?: number|null,
161
+ * verifiedAt?: number|null,
162
+ * durationMs?: number|null,
163
+ * bootId?: string|null,
164
+ * previousBootId?: string|null,
165
+ * snapshot?: object|null,
166
+ * checks?: any[],
167
+ * reason?: string|null,
168
+ * now?: number,
169
+ * }} [input]
170
+ * @returns {object}
171
+ */
172
+ export function buildRestartVerdict(input = {}) {
173
+ const now = isFiniteNumber(input.now) ? Number(input.now) : Date.now();
174
+ const result = input.result === RESTART_RESULTS.OK
175
+ ? RESTART_RESULTS.OK
176
+ : input.result === RESTART_RESULTS.FAILED
177
+ ? RESTART_RESULTS.FAILED
178
+ : RESTART_RESULTS.PENDING;
179
+ const startedAt = nullableNumber(input.startedAt);
180
+ const verifiedAt = nullableNumber(input.verifiedAt);
181
+ const durationMs = isFiniteNumber(input.durationMs)
182
+ ? Math.trunc(Number(input.durationMs))
183
+ : (startedAt !== null && verifiedAt !== null ? Math.max(0, verifiedAt - startedAt) : null);
184
+ const snapshot = isObject(input.snapshot) ? input.snapshot : buildSnapshotRecord({ requested: false });
185
+ return {
186
+ result,
187
+ mode: nullableString(input.mode),
188
+ generation: nullableNumber(input.generation),
189
+ requestedAt: nullableNumber(input.requestedAt),
190
+ startedAt,
191
+ verifiedAt,
192
+ durationMs,
193
+ bootId: nullableString(input.bootId),
194
+ previousBootId: nullableString(input.previousBootId),
195
+ snapshot: {
196
+ requested: snapshot.requested === true,
197
+ taken: snapshot.taken === true,
198
+ dir: nullableString(snapshot.dir),
199
+ stamp: nullableString(snapshot.stamp),
200
+ at: nullableNumber(snapshot.at),
201
+ atLocal: nullableString(snapshot.atLocal),
202
+ durationMs: nullableNumber(snapshot.durationMs),
203
+ bytes: nullableNumber(snapshot.bytes),
204
+ files: Array.isArray(snapshot.files) ? snapshot.files.map(String) : [],
205
+ pruned: Array.isArray(snapshot.pruned) ? snapshot.pruned.map(String) : [],
206
+ profileDir: nullableString(snapshot.profileDir),
207
+ error: nullableString(snapshot.error),
208
+ },
209
+ checks: Array.isArray(input.checks) ? input.checks.map(normalizeCheck) : [],
210
+ reason: nullableString(input.reason),
211
+ updatedAt: now,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * The verdict of a set of checks: `ok` only when EVERY check passed.
217
+ *
218
+ * @param {any[]} checks
219
+ * @returns {{ ok: boolean, failed: string[], passed: string[] }}
220
+ */
221
+ export function evaluateRestartChecks(checks) {
222
+ const list = Array.isArray(checks) ? checks : [];
223
+ const failed = []
224
+ const passed = []
225
+ for (const check of list) {
226
+ const normalized = normalizeCheck(check)
227
+ if (normalized.ok) passed.push(normalized.name)
228
+ else failed.push(normalized.name)
229
+ }
230
+ return { ok: failed.length === 0 && list.length > 0, failed, passed }
231
+ }
232
+
233
+ /**
234
+ * Write the verdict into the state directory (atomically: a temporary file
235
+ * plus a rename, so a half-written record can never be read).
236
+ *
237
+ * @param {string} stateDir
238
+ * @param {object} verdict
239
+ * @returns {{ ok: boolean, path: string, error: string|null }}
240
+ */
241
+ export function writeRestartVerdict(stateDir, verdict) {
242
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
243
+ if (dir === '') return { ok: false, path: '', error: 'writeRestartVerdict requires stateDir' };
244
+ const target = path.join(dir, RESTART_VERDICT_FILE);
245
+ const record = buildRestartVerdict(verdict)
246
+ try {
247
+ fs.mkdirSync(dir, { recursive: true })
248
+ const temporary = `${target}.tmp-${randomBytes(6).toString('hex')}`
249
+ fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
250
+ fs.renameSync(temporary, target)
251
+ return { ok: true, path: target, error: null }
252
+ } catch (error) {
253
+ return {
254
+ ok: false,
255
+ path: target,
256
+ error: String(error && error.message ? error.message : error),
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Read the verdict of the last restart. Never throws: an unreadable or
263
+ * corrupt record is reported as `null` (there is nothing to show).
264
+ *
265
+ * @param {string} stateDir
266
+ * @returns {object|null}
267
+ */
268
+ export function readRestartVerdict(stateDir) {
269
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
270
+ if (dir === '') return null;
271
+ try {
272
+ let text = fs.readFileSync(path.join(dir, RESTART_VERDICT_FILE), 'utf8')
273
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
274
+ const parsed = JSON.parse(text)
275
+ if (!isObject(parsed)) return null
276
+ return buildRestartVerdict(parsed)
277
+ } catch {
278
+ return null
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Remove the verdict (used when a fresh restart cycle starts).
284
+ *
285
+ * @param {string} stateDir
286
+ * @returns {boolean} whether a record was removed
287
+ */
288
+ export function clearRestartVerdict(stateDir) {
289
+ const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
290
+ if (dir === '') return false;
291
+ try {
292
+ fs.rmSync(path.join(dir, RESTART_VERDICT_FILE), { force: true })
293
+ return true
294
+ } catch {
295
+ return false
296
+ }
297
+ }
298
+
299
+ /**
300
+ * The compact shape of the verdict that the health route and the status tool
301
+ * report. It is deliberately flat: a UI (or a human with `curl`) has to read
302
+ * it without knowing the internals.
303
+ *
304
+ * @param {object|null} verdict
305
+ * @returns {object|null}
306
+ */
307
+ export function summarizeRestartVerdict(verdict) {
308
+ if (!isObject(verdict)) return null
309
+ const record = buildRestartVerdict(verdict)
310
+ const failed = record.checks.filter((check) => !check.ok).map((check) => check.name)
311
+ return {
312
+ result: record.result,
313
+ mode: record.mode,
314
+ generation: record.generation,
315
+ requestedAt: record.requestedAt,
316
+ startedAt: record.startedAt,
317
+ verifiedAt: record.verifiedAt,
318
+ durationMs: record.durationMs,
319
+ bootId: record.bootId,
320
+ previousBootId: record.previousBootId,
321
+ snapshot: record.snapshot,
322
+ checks: record.checks,
323
+ failedChecks: failed,
324
+ reason: record.reason,
325
+ updatedAt: record.updatedAt,
326
+ }
327
+ }