dsh-autorestart 0.6.2 → 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,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
+ }