dsh-autorestart 0.8.4 → 0.8.6
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/CHANGELOG.md +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — put a FAILED PLUGIN back to the version that worked.
|
|
3
|
+
*
|
|
4
|
+
* The failure this module answers is the REPORTED one: an entry did not
|
|
5
|
+
* activate, so the host is up but the feature behind the plugin 404s
|
|
6
|
+
* (lib/plugin-failure.js reads that verdict out of the startup log). The
|
|
7
|
+
* process-level failure — the host does not come up at all — is NOT this
|
|
8
|
+
* module's business: that is the pre-restart snapshot and the watchdog.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT DOES, per failed plugin:
|
|
11
|
+
*
|
|
12
|
+
* - the newest KNOWN-GOOD backup holds a real copy (`node_modules/<name>`)
|
|
13
|
+
* -> `overwrite`: the live directory is renamed aside as
|
|
14
|
+
* `<name>.broken-<ts>` (EVIDENCE: nothing is ever deleted) and the
|
|
15
|
+
* backup copy takes its place;
|
|
16
|
+
* - the backup holds the CONTENTS of a `link:` installation
|
|
17
|
+
* (`node_modules-link/<name>`, see lib/backup.js) -> `replace-link`: the
|
|
18
|
+
* live link is moved aside the same way and the copied contents take its
|
|
19
|
+
* place (the same meaning as the existing `force` restore rule);
|
|
20
|
+
* - the backup holds nothing for it -> `no-backup`: nothing is touched, the
|
|
21
|
+
* REASON is written down;
|
|
22
|
+
* - the plugin is not in the profile's enabled list -> `no-backup` as well
|
|
23
|
+
* (we do not write into a plugin tree we cannot account for).
|
|
24
|
+
*
|
|
25
|
+
* TWO LOOPS ARE POSSIBLE AND BOTH ARE STOPPED (user decision, 2026-11-01):
|
|
26
|
+
*
|
|
27
|
+
* 1. THE SAME OLD LOG LINE READ AGAIN. Every boot would "find" the line the
|
|
28
|
+
* previous boot left behind -> restore + restart, forever. Two guards:
|
|
29
|
+
* `findPluginFailures` drops lines older than the boot the log belongs to,
|
|
30
|
+
* and every failure carries a FINGERPRINT (plugin + first error line + log
|
|
31
|
+
* time) that is recorded as `handled` in `plugin-recovery.json`: one
|
|
32
|
+
* fingerprint is acted on ONCE (`duplicate` afterwards).
|
|
33
|
+
* 2. THE CAUSE IS NOT IN THE PLUGIN (a missing peer dependency, a missing host
|
|
34
|
+
* capability). Swapping the version back cannot fix that, so the plugin
|
|
35
|
+
* would fail on every boot. Guard: one plugin is restored ONCE per cycle;
|
|
36
|
+
* a second failure of the same plugin reports `needs-human` and does NOT
|
|
37
|
+
* touch anything and does NOT request a restart. A boot whose log holds no
|
|
38
|
+
* plugin failure resets the cycle.
|
|
39
|
+
*
|
|
40
|
+
* AND IT IS IDEMPOTENT: before copying, the live directory is compared with the
|
|
41
|
+
* backup copy (relative path + size + sha256 of every file). When they already
|
|
42
|
+
* match there is nothing to restore, so nothing is copied and no restart is
|
|
43
|
+
* asked for (`already-consistent`).
|
|
44
|
+
*
|
|
45
|
+
* ESM only, Node builtins only. The file system is injectable so every branch
|
|
46
|
+
* (including the rename that leaves the evidence) is testable without touching
|
|
47
|
+
* a real profile.
|
|
48
|
+
*
|
|
49
|
+
* @module dsh-autorestart/lib/plugin-recover
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import crypto from 'node:crypto';
|
|
53
|
+
import fsDefault from 'node:fs';
|
|
54
|
+
import path from 'node:path';
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
KIND_KNOWN_GOOD,
|
|
58
|
+
LINKS_DIR,
|
|
59
|
+
META_NAME,
|
|
60
|
+
PLUGINS_DIR,
|
|
61
|
+
latestKnownGood,
|
|
62
|
+
readBackupMeta,
|
|
63
|
+
} from './backup.js';
|
|
64
|
+
import { failureFingerprint, findPluginFailures, mergeFailuresByPlugin } from './plugin-failure.js';
|
|
65
|
+
|
|
66
|
+
/** The durable bookkeeping of the recovery cycles (next to the checkpoint). */
|
|
67
|
+
export const RECOVERY_STATE_NAME = 'plugin-recovery.json';
|
|
68
|
+
|
|
69
|
+
/** The readable report of the last recovery (what the interface shows). */
|
|
70
|
+
export const RECOVERY_REPORT_NAME = 'last-plugin-recovery.json';
|
|
71
|
+
|
|
72
|
+
/** The version of the bookkeeping file's shape. */
|
|
73
|
+
export const RECOVERY_STATE_VERSION = 1;
|
|
74
|
+
|
|
75
|
+
/** How many handled fingerprints are remembered (oldest are dropped first). */
|
|
76
|
+
export const HANDLED_LIMIT = 64;
|
|
77
|
+
|
|
78
|
+
/** How many plugins one cycle may hold an attempt record for. */
|
|
79
|
+
export const ENTRY_LIMIT = 32;
|
|
80
|
+
|
|
81
|
+
/** How many restore attempts one plugin gets per cycle (hard requirement). */
|
|
82
|
+
export const DEFAULT_ATTEMPT_LIMIT = 1;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The decisions a plan can carry.
|
|
86
|
+
*
|
|
87
|
+
* The first three are the restore modes of the contract; the rest are the
|
|
88
|
+
* "nothing is touched, so nothing is restarted" answers.
|
|
89
|
+
*/
|
|
90
|
+
export const RECOVERY_MODES = Object.freeze({
|
|
91
|
+
/** A real copy in the backup takes the place of the live directory. */
|
|
92
|
+
OVERWRITE: 'overwrite',
|
|
93
|
+
/** The contents of a `link:` installation take the place of the live link. */
|
|
94
|
+
REPLACE_LINK: 'replace-link',
|
|
95
|
+
/** The backup holds nothing for this plugin: nothing is touched. */
|
|
96
|
+
NO_BACKUP: 'no-backup',
|
|
97
|
+
/** The live directory already equals the backup copy: nothing to do. */
|
|
98
|
+
ALREADY_CONSISTENT: 'already-consistent',
|
|
99
|
+
/** This exact failure was already handled once: not a new incident. */
|
|
100
|
+
DUPLICATE: 'duplicate',
|
|
101
|
+
/** This plugin already used its one attempt: a human has to look. */
|
|
102
|
+
NEEDS_HUMAN: 'needs-human',
|
|
103
|
+
/**
|
|
104
|
+
* THE BACKUP DELIBERATELY DOES NOT HOLD THIS PLUGIN (2026-11-01).
|
|
105
|
+
*
|
|
106
|
+
* A slim backup keeps only what cannot be fetched again: the configuration and
|
|
107
|
+
* the plugins installed as links. A third-party plugin is NOT in it, so there is
|
|
108
|
+
* nothing to copy back — the honest answer is "reinstall it", not "no backup".
|
|
109
|
+
*/
|
|
110
|
+
NEEDS_ONLINE_REINSTALL: 'needs-online-reinstall',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** The modes that really change files (and are therefore the only ones that restart). */
|
|
114
|
+
export const RESTORE_MODES = Object.freeze([
|
|
115
|
+
RECOVERY_MODES.OVERWRITE,
|
|
116
|
+
RECOVERY_MODES.REPLACE_LINK,
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** How many files one idempotency comparison may hash. */
|
|
120
|
+
export const DIGEST_MAX_FILES = 400;
|
|
121
|
+
|
|
122
|
+
/** How large one file may be to be hashed for the comparison. */
|
|
123
|
+
export const DIGEST_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A compact local stamp for the evidence directory: `20261101T073000`.
|
|
127
|
+
*
|
|
128
|
+
* The rename target is `<name>.broken-<ts>`, so the stamp must be file-system
|
|
129
|
+
* safe on every platform: no colons (Windows refuses them), no spaces.
|
|
130
|
+
*
|
|
131
|
+
* @param {number} [now]
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function recoveryStamp(now = Date.now()) {
|
|
135
|
+
const date = new Date(Number.isFinite(Number(now)) ? Number(now) : Date.now());
|
|
136
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
137
|
+
return [
|
|
138
|
+
date.getUTCFullYear(),
|
|
139
|
+
pad(date.getUTCMonth() + 1),
|
|
140
|
+
pad(date.getUTCDate()),
|
|
141
|
+
'T',
|
|
142
|
+
pad(date.getUTCHours()),
|
|
143
|
+
pad(date.getUTCMinutes()),
|
|
144
|
+
pad(date.getUTCSeconds()),
|
|
145
|
+
].join('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The name the live directory is moved to when it is replaced.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} plugin
|
|
152
|
+
* @param {number} [now]
|
|
153
|
+
* @returns {string} a plain directory name (never a path)
|
|
154
|
+
*/
|
|
155
|
+
export function brokenNameFor(plugin, now = Date.now()) {
|
|
156
|
+
const name = typeof plugin === 'string' ? plugin.trim() : '';
|
|
157
|
+
const safe = name.replace(/[\\/:*?"<>|]/g, '_');
|
|
158
|
+
return `${safe}.broken-${recoveryStamp(now)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// The bookkeeping file (`<stateDir>/plugin-recovery.json`)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** An empty bookkeeping record. */
|
|
166
|
+
export function createRecoveryState() {
|
|
167
|
+
return { version: RECOVERY_STATE_VERSION, entries: [], handled: [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Coerce anything read from disk into the bookkeeping shape.
|
|
172
|
+
*
|
|
173
|
+
* A file written by a future build, a hand-edited one or a half-written one must
|
|
174
|
+
* never take the boot down: unknown fields are dropped and the rest is repaired.
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} raw
|
|
177
|
+
* @returns {{ version: number, entries: Array<object>, handled: Array<object> }}
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeRecoveryState(raw) {
|
|
180
|
+
const state = createRecoveryState();
|
|
181
|
+
if (!raw || typeof raw !== 'object') return state;
|
|
182
|
+
const entries = Array.isArray(raw.entries) ? raw.entries : [];
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
185
|
+
const plugin = typeof entry.plugin === 'string' ? entry.plugin.trim() : '';
|
|
186
|
+
if (plugin === '') continue;
|
|
187
|
+
if (state.entries.some((known) => known.plugin === plugin)) continue;
|
|
188
|
+
if (state.entries.length >= ENTRY_LIMIT) break;
|
|
189
|
+
state.entries.push({
|
|
190
|
+
plugin,
|
|
191
|
+
attempts: Number.isFinite(Number(entry.attempts)) && Number(entry.attempts) > 0
|
|
192
|
+
? Math.trunc(Number(entry.attempts))
|
|
193
|
+
: 0,
|
|
194
|
+
firstAt: Number.isFinite(Number(entry.firstAt)) ? Math.trunc(Number(entry.firstAt)) : null,
|
|
195
|
+
lastAt: Number.isFinite(Number(entry.lastAt)) ? Math.trunc(Number(entry.lastAt)) : null,
|
|
196
|
+
restartedAt: Number.isFinite(Number(entry.restartedAt)) ? Math.trunc(Number(entry.restartedAt)) : null,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const handled = Array.isArray(raw.handled) ? raw.handled : [];
|
|
200
|
+
for (const record of handled) {
|
|
201
|
+
if (!record || typeof record !== 'object') continue;
|
|
202
|
+
const fingerprint = typeof record.fingerprint === 'string' ? record.fingerprint.trim() : '';
|
|
203
|
+
if (fingerprint === '') continue;
|
|
204
|
+
if (state.handled.some((known) => known.fingerprint === fingerprint)) continue;
|
|
205
|
+
state.handled.push({
|
|
206
|
+
fingerprint,
|
|
207
|
+
plugin: typeof record.plugin === 'string' && record.plugin !== '' ? record.plugin : null,
|
|
208
|
+
at: Number.isFinite(Number(record.at)) ? Math.trunc(Number(record.at)) : null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Only the newest records are kept: an unbounded list would grow with every
|
|
212
|
+
// incident this machine ever had.
|
|
213
|
+
if (state.handled.length > HANDLED_LIMIT) {
|
|
214
|
+
state.handled = state.handled.slice(state.handled.length - HANDLED_LIMIT);
|
|
215
|
+
}
|
|
216
|
+
return state;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read the bookkeeping of the recovery cycles.
|
|
221
|
+
*
|
|
222
|
+
* A missing or unreadable file is NOT an error: it means "no cycle has run yet".
|
|
223
|
+
*
|
|
224
|
+
* @param {string} stateDir
|
|
225
|
+
* @param {{ fs?: object }} [options]
|
|
226
|
+
* @returns {{ version: number, entries: Array<object>, handled: Array<object>, corrupt: boolean }}
|
|
227
|
+
*/
|
|
228
|
+
export function readRecoveryState(stateDir, { fs = fsDefault } = {}) {
|
|
229
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
230
|
+
const empty = { ...createRecoveryState(), corrupt: false };
|
|
231
|
+
if (dir === '') return empty;
|
|
232
|
+
try {
|
|
233
|
+
const text = fs.readFileSync(path.join(dir, RECOVERY_STATE_NAME), 'utf8');
|
|
234
|
+
const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
235
|
+
return { ...normalizeRecoveryState(parsed), corrupt: false };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const missing = error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
238
|
+
return { ...empty, corrupt: missing !== true };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Write the bookkeeping back. Never throws: a failed write only costs the loop
|
|
244
|
+
* guard, and a boot must not die for it.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} stateDir
|
|
247
|
+
* @param {object} state
|
|
248
|
+
* @param {{ fs?: object }} [options]
|
|
249
|
+
* @returns {boolean} whether it was written
|
|
250
|
+
*/
|
|
251
|
+
export function writeRecoveryState(stateDir, state, { fs = fsDefault } = {}) {
|
|
252
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
253
|
+
if (dir === '') return false;
|
|
254
|
+
const normalized = normalizeRecoveryState(state);
|
|
255
|
+
try {
|
|
256
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
257
|
+
fs.writeFileSync(path.join(dir, RECOVERY_STATE_NAME), JSON.stringify(normalized, null, 2), 'utf8');
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* How many restore attempts one plugin has already used in this cycle.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} state
|
|
268
|
+
* @param {string} plugin
|
|
269
|
+
* @returns {number}
|
|
270
|
+
*/
|
|
271
|
+
export function attemptsFor(state, plugin) {
|
|
272
|
+
const name = typeof plugin === 'string' ? plugin : '';
|
|
273
|
+
if (name === '' || !state || !Array.isArray(state.entries)) return 0;
|
|
274
|
+
const entry = state.entries.find((candidate) => candidate.plugin === name);
|
|
275
|
+
return entry && Number.isFinite(Number(entry.attempts)) ? Number(entry.attempts) : 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* May this plugin be restored again in this cycle?
|
|
280
|
+
*
|
|
281
|
+
* ATTEMPT 1 IS ALLOWED, ATTEMPT 2 IS REFUSED — that is the whole point: a plugin
|
|
282
|
+
* that fails twice with a restored-good version has a cause outside the plugin,
|
|
283
|
+
* and another restart would only cost the user another boot.
|
|
284
|
+
*
|
|
285
|
+
* @param {object} state
|
|
286
|
+
* @param {string} plugin
|
|
287
|
+
* @param {{ limit?: number }} [options]
|
|
288
|
+
* @returns {{ allowed: boolean, attempts: number, limit: number, reason: string }}
|
|
289
|
+
*/
|
|
290
|
+
export function decideAttempt(state, plugin, { limit = DEFAULT_ATTEMPT_LIMIT } = {}) {
|
|
291
|
+
const budget = Number.isFinite(Number(limit)) && Number(limit) >= 1
|
|
292
|
+
? Math.trunc(Number(limit))
|
|
293
|
+
: DEFAULT_ATTEMPT_LIMIT;
|
|
294
|
+
const attempts = attemptsFor(state, plugin);
|
|
295
|
+
if (attempts >= budget) {
|
|
296
|
+
return {
|
|
297
|
+
allowed: false,
|
|
298
|
+
attempts,
|
|
299
|
+
limit: budget,
|
|
300
|
+
reason: `this plugin already used its ${budget} restore attempt(s) in this cycle: `
|
|
301
|
+
+ 'the cause is probably not the plugin version (a missing peer dependency or a missing host '
|
|
302
|
+
+ 'capability is not fixed by swapping the copy back), so nothing is touched and a human has to look',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
allowed: true,
|
|
307
|
+
attempts,
|
|
308
|
+
limit: budget,
|
|
309
|
+
reason: attempts === 0
|
|
310
|
+
? 'no restore attempt has been made for this plugin in this cycle'
|
|
311
|
+
: `attempt ${attempts + 1} of ${budget}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Record that one plugin was restored (the attempt is spent NOW).
|
|
317
|
+
*
|
|
318
|
+
* @param {object} state
|
|
319
|
+
* @param {string} plugin
|
|
320
|
+
* @param {{ now?: number, restarted?: boolean }} [options]
|
|
321
|
+
* @returns {object|null} the entry that was written
|
|
322
|
+
*/
|
|
323
|
+
export function recordAttempt(state, plugin, { now = Date.now(), restarted = false } = {}) {
|
|
324
|
+
const target = state && typeof state === 'object' ? state : null;
|
|
325
|
+
const name = typeof plugin === 'string' ? plugin.trim() : '';
|
|
326
|
+
if (target === null || name === '') return null;
|
|
327
|
+
if (!Array.isArray(target.entries)) target.entries = [];
|
|
328
|
+
const at = Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now();
|
|
329
|
+
let entry = target.entries.find((candidate) => candidate.plugin === name);
|
|
330
|
+
if (!entry) {
|
|
331
|
+
entry = { plugin: name, attempts: 0, firstAt: at, lastAt: at, restartedAt: null };
|
|
332
|
+
target.entries.push(entry);
|
|
333
|
+
}
|
|
334
|
+
entry.attempts = Number(entry.attempts || 0) + 1;
|
|
335
|
+
entry.lastAt = at;
|
|
336
|
+
if (entry.firstAt === null || entry.firstAt === undefined) entry.firstAt = at;
|
|
337
|
+
if (restarted === true) entry.restartedAt = at;
|
|
338
|
+
if (target.entries.length > ENTRY_LIMIT) target.entries = target.entries.slice(target.entries.length - ENTRY_LIMIT);
|
|
339
|
+
return entry;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Was THIS exact failure already handled once?
|
|
344
|
+
*
|
|
345
|
+
* @param {object} state
|
|
346
|
+
* @param {string} fingerprint
|
|
347
|
+
* @returns {object|null} the record, or null
|
|
348
|
+
*/
|
|
349
|
+
export function handledRecord(state, fingerprint) {
|
|
350
|
+
const wanted = typeof fingerprint === 'string' ? fingerprint.trim() : '';
|
|
351
|
+
if (wanted === '' || !state || !Array.isArray(state.handled)) return null;
|
|
352
|
+
return state.handled.find((record) => record.fingerprint === wanted) || null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Remember that one failure was handled (it is never acted on again).
|
|
357
|
+
*
|
|
358
|
+
* @param {object} state
|
|
359
|
+
* @param {{ fingerprint?: string, plugin?: string|null, now?: number }} [options]
|
|
360
|
+
* @returns {object|null} the record that was written
|
|
361
|
+
*/
|
|
362
|
+
export function markHandled(state, { fingerprint = '', plugin = null, now = Date.now() } = {}) {
|
|
363
|
+
const target = state && typeof state === 'object' ? state : null;
|
|
364
|
+
const value = typeof fingerprint === 'string' ? fingerprint.trim() : '';
|
|
365
|
+
if (target === null || value === '') return null;
|
|
366
|
+
if (!Array.isArray(target.handled)) target.handled = [];
|
|
367
|
+
const existing = target.handled.find((record) => record.fingerprint === value);
|
|
368
|
+
if (existing) return existing;
|
|
369
|
+
const record = {
|
|
370
|
+
fingerprint: value,
|
|
371
|
+
plugin: typeof plugin === 'string' && plugin !== '' ? plugin : null,
|
|
372
|
+
at: Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now(),
|
|
373
|
+
};
|
|
374
|
+
target.handled.push(record);
|
|
375
|
+
if (target.handled.length > HANDLED_LIMIT) {
|
|
376
|
+
target.handled = target.handled.slice(target.handled.length - HANDLED_LIMIT);
|
|
377
|
+
}
|
|
378
|
+
return record;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* End the cycle: one boot without any plugin failure proves the machine is fine.
|
|
383
|
+
*
|
|
384
|
+
* @param {object} state
|
|
385
|
+
* @param {{ now?: number }} [options]
|
|
386
|
+
* @returns {{ reset: boolean, entries: number, handled: number, at: number }}
|
|
387
|
+
*/
|
|
388
|
+
export function resetRecoveryCycle(state, { now = Date.now() } = {}) {
|
|
389
|
+
const target = state && typeof state === 'object' ? state : createRecoveryState();
|
|
390
|
+
const entries = Array.isArray(target.entries) ? target.entries.length : 0;
|
|
391
|
+
const handled = Array.isArray(target.handled) ? target.handled.length : 0;
|
|
392
|
+
target.version = RECOVERY_STATE_VERSION;
|
|
393
|
+
target.entries = [];
|
|
394
|
+
target.handled = [];
|
|
395
|
+
return {
|
|
396
|
+
reset: entries > 0 || handled > 0,
|
|
397
|
+
entries,
|
|
398
|
+
handled,
|
|
399
|
+
at: Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// The startup logs (`<DSH_HOME>/logs/startup-*.log`)
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The boot time a startup log file name carries: `startup-<ISO>-<id>.log`.
|
|
409
|
+
*
|
|
410
|
+
* The name is the ONLY place a log dates itself without opening it, and it is
|
|
411
|
+
* what makes "the lines of this log belong to THAT boot" decidable.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} name
|
|
414
|
+
* @returns {number|null}
|
|
415
|
+
*/
|
|
416
|
+
export function parseStartupLogName(name) {
|
|
417
|
+
const text = typeof name === 'string' ? name : '';
|
|
418
|
+
const match = /^startup-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})(?:\.(\d{1,3}))?Z/i.exec(text);
|
|
419
|
+
if (!match) return null;
|
|
420
|
+
const [, year, month, day, hour, minute, second, fraction = '000'] = match;
|
|
421
|
+
const millis = (fraction + '000').slice(0, 3);
|
|
422
|
+
const stamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`);
|
|
423
|
+
return Number.isFinite(stamp) ? stamp : null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Every startup log of this machine, newest first.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} logsDir
|
|
430
|
+
* @param {{ fs?: object }} [options]
|
|
431
|
+
* @returns {Array<{ name: string, path: string, at: number|null, mtime: number, size: number }>}
|
|
432
|
+
*/
|
|
433
|
+
export function listStartupLogs(logsDir, { fs = fsDefault } = {}) {
|
|
434
|
+
const dir = typeof logsDir === 'string' ? logsDir.trim() : '';
|
|
435
|
+
if (dir === '') return [];
|
|
436
|
+
let names = [];
|
|
437
|
+
try {
|
|
438
|
+
names = fs.readdirSync(dir);
|
|
439
|
+
} catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
const logs = [];
|
|
443
|
+
for (const name of Array.isArray(names) ? names : []) {
|
|
444
|
+
const text = typeof name === 'string' ? name : String(name);
|
|
445
|
+
if (!/^startup-.*\.log$/i.test(text)) continue;
|
|
446
|
+
const full = path.join(dir, text);
|
|
447
|
+
let mtime = 0;
|
|
448
|
+
let size = 0;
|
|
449
|
+
try {
|
|
450
|
+
const stat = fs.statSync(full);
|
|
451
|
+
mtime = Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : new Date(stat.mtime).getTime();
|
|
452
|
+
size = Number.isFinite(stat.size) ? stat.size : 0;
|
|
453
|
+
} catch {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
logs.push({ name: text, path: full, at: parseStartupLogName(text), mtime, size });
|
|
457
|
+
}
|
|
458
|
+
logs.sort((left, right) => (right.mtime - left.mtime) || (right.name < left.name ? -1 : 1));
|
|
459
|
+
return logs;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The log of the PREVIOUS boot — the one that can hold a plugin failure.
|
|
464
|
+
*
|
|
465
|
+
* WHY `before`: the host writes its own startup log from the first second of the
|
|
466
|
+
* boot. Reading "the newest log" would therefore read the log of the boot that
|
|
467
|
+
* is looking for failures — a log which cannot yet contain its own audit
|
|
468
|
+
* verdict. Only a log written BEFORE this boot started describes a finished
|
|
469
|
+
* boot, so that is the one that is read. It also keeps an older, already
|
|
470
|
+
* handled incident from being dug up again.
|
|
471
|
+
*
|
|
472
|
+
* @param {string} logsDir
|
|
473
|
+
* @param {{ before?: number|null, fs?: object }} [options]
|
|
474
|
+
* @returns {{ name: string, path: string, at: number|null, mtime: number, size: number }|null}
|
|
475
|
+
*/
|
|
476
|
+
export function findLatestStartupLog(logsDir, { before = null, fs = fsDefault } = {}) {
|
|
477
|
+
const logs = listStartupLogs(logsDir, { fs });
|
|
478
|
+
if (logs.length === 0) return null;
|
|
479
|
+
const cutoff = Number.isFinite(Number(before)) ? Number(before) : null;
|
|
480
|
+
if (cutoff === null) return logs[0];
|
|
481
|
+
const older = logs.find((log) => log.mtime <= cutoff);
|
|
482
|
+
// No older log: this is the very first boot that logs at all, so there is no
|
|
483
|
+
// finished boot to look at and nothing to recover.
|
|
484
|
+
return older || null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Idempotency: is the live directory already the backup copy?
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* A stable digest of a directory tree: relative path + size + sha256 per file.
|
|
493
|
+
*
|
|
494
|
+
* Files that cannot be read, a tree that is too large or one that is missing all
|
|
495
|
+
* answer `ok: false` — "unknown", never "identical". A comparison that cannot be
|
|
496
|
+
* made must not be reported as "nothing to do".
|
|
497
|
+
*
|
|
498
|
+
* @param {string} dir
|
|
499
|
+
* @param {{ fs?: object, maxFiles?: number, maxFileBytes?: number }} [options]
|
|
500
|
+
* @returns {{ ok: boolean, exists: boolean, files: number, bytes: number, digest: string|null, error: string|null }}
|
|
501
|
+
*/
|
|
502
|
+
export function treeDigest(dir, {
|
|
503
|
+
fs = fsDefault,
|
|
504
|
+
maxFiles = DIGEST_MAX_FILES,
|
|
505
|
+
maxFileBytes = DIGEST_MAX_FILE_BYTES,
|
|
506
|
+
} = {}) {
|
|
507
|
+
const root = typeof dir === 'string' ? dir.trim() : '';
|
|
508
|
+
const empty = { ok: false, exists: false, files: 0, bytes: 0, digest: null, error: null };
|
|
509
|
+
if (root === '') return { ...empty, error: 'treeDigest requires a directory' };
|
|
510
|
+
let stat = null;
|
|
511
|
+
try {
|
|
512
|
+
stat = fs.lstatSync(root);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
const missing = error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
515
|
+
return missing ? { ...empty, error: null } : { ...empty, error: String(error) };
|
|
516
|
+
}
|
|
517
|
+
// A LINK is never "the same" as a copied tree: the whole point of the
|
|
518
|
+
// replace-link mode is that the contents must take its place.
|
|
519
|
+
if (typeof stat.isSymbolicLink === 'function' && stat.isSymbolicLink() === true) {
|
|
520
|
+
return { ...empty, exists: true, error: 'the live entry is a link' };
|
|
521
|
+
}
|
|
522
|
+
if (typeof stat.isDirectory === 'function' && stat.isDirectory() !== true) {
|
|
523
|
+
return { ...empty, exists: true, error: 'the live entry is not a directory' };
|
|
524
|
+
}
|
|
525
|
+
const rows = [];
|
|
526
|
+
let files = 0;
|
|
527
|
+
let bytes = 0;
|
|
528
|
+
const walk = (current, relative) => {
|
|
529
|
+
let names = [];
|
|
530
|
+
try {
|
|
531
|
+
names = fs.readdirSync(current);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
throw new Error(`cannot read ${current}: ${error && error.message ? error.message : String(error)}`);
|
|
534
|
+
}
|
|
535
|
+
for (const name of names) {
|
|
536
|
+
const entryName = typeof name === 'string' ? name : String(name);
|
|
537
|
+
const full = path.join(current, entryName);
|
|
538
|
+
const rel = relative === '' ? entryName : `${relative}/${entryName}`;
|
|
539
|
+
const info = fs.lstatSync(full);
|
|
540
|
+
if (typeof info.isSymbolicLink === 'function' && info.isSymbolicLink() === true) {
|
|
541
|
+
rows.push(`${rel}\u0000link`);
|
|
542
|
+
files += 1;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if (typeof info.isDirectory === 'function' && info.isDirectory() === true) {
|
|
546
|
+
walk(full, rel);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const size = Number.isFinite(info.size) ? Number(info.size) : 0;
|
|
550
|
+
if (size > maxFileBytes) {
|
|
551
|
+
throw new Error(`${rel} is larger than the comparison limit`);
|
|
552
|
+
}
|
|
553
|
+
const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
|
|
554
|
+
rows.push(`${rel}\u0000${size}\u0000${hash}`);
|
|
555
|
+
files += 1;
|
|
556
|
+
bytes += size;
|
|
557
|
+
if (files > maxFiles) throw new Error(`more than ${maxFiles} files`);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
try {
|
|
561
|
+
walk(root, '');
|
|
562
|
+
} catch (error) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
exists: true,
|
|
566
|
+
files,
|
|
567
|
+
bytes,
|
|
568
|
+
digest: null,
|
|
569
|
+
error: error && error.message ? error.message : String(error),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
rows.sort();
|
|
573
|
+
const digest = crypto.createHash('sha256').update(rows.join('\n')).digest('hex');
|
|
574
|
+
return { ok: true, exists: true, files, bytes, digest, error: null };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Is the live plugin directory already the backup copy?
|
|
579
|
+
*
|
|
580
|
+
* @param {string} liveDir
|
|
581
|
+
* @param {string} backupDir
|
|
582
|
+
* @param {{ fs?: object }} [options]
|
|
583
|
+
* @returns {{ same: boolean, known: boolean, live: object, backup: object, reason: string }}
|
|
584
|
+
*/
|
|
585
|
+
export function sameAsBackup(liveDir, backupDir, { fs = fsDefault } = {}) {
|
|
586
|
+
const live = treeDigest(liveDir, { fs });
|
|
587
|
+
const backup = treeDigest(backupDir, { fs });
|
|
588
|
+
if (live.ok !== true || backup.ok !== true) {
|
|
589
|
+
return {
|
|
590
|
+
same: false,
|
|
591
|
+
known: false,
|
|
592
|
+
live,
|
|
593
|
+
backup,
|
|
594
|
+
reason: 'the two trees could not be compared, so the copy is taken',
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (live.digest === backup.digest) {
|
|
598
|
+
return {
|
|
599
|
+
same: true,
|
|
600
|
+
known: true,
|
|
601
|
+
live,
|
|
602
|
+
backup,
|
|
603
|
+
reason: `the live directory already equals the backup (${live.files} files, ${live.bytes} bytes)`,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
same: false,
|
|
608
|
+
known: true,
|
|
609
|
+
live,
|
|
610
|
+
backup,
|
|
611
|
+
reason: `the live directory differs from the backup (${live.files} files / ${live.bytes} bytes against ${backup.files} files / ${backup.bytes} bytes)`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// The plan
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Where the backup keeps one plugin, and in which of the two shapes.
|
|
621
|
+
*
|
|
622
|
+
* @param {{ backupDir: string, plugin: string, fs?: object }} options
|
|
623
|
+
* @returns {{ source: string|null, mode: string, reason: string }}
|
|
624
|
+
*/
|
|
625
|
+
export function locateBackupCopy({ backupDir, plugin, fs = fsDefault, slim = false, isLinkInstall = false } = {}) {
|
|
626
|
+
const root = typeof backupDir === 'string' ? backupDir.trim() : '';
|
|
627
|
+
const name = typeof plugin === 'string' ? plugin.trim() : '';
|
|
628
|
+
if (root === '' || name === '') {
|
|
629
|
+
return { source: null, mode: RECOVERY_MODES.NO_BACKUP, reason: 'no backup directory or no plugin name was given' };
|
|
630
|
+
}
|
|
631
|
+
const copied = path.join(root, PLUGINS_DIR, name);
|
|
632
|
+
try {
|
|
633
|
+
const info = fs.lstatSync(copied);
|
|
634
|
+
const isLink = typeof info.isSymbolicLink === 'function' && info.isSymbolicLink() === true;
|
|
635
|
+
if (isLink !== true && info.isDirectory() === true) {
|
|
636
|
+
return {
|
|
637
|
+
source: copied,
|
|
638
|
+
mode: RECOVERY_MODES.OVERWRITE,
|
|
639
|
+
reason: 'the backup holds a real copy of this plugin: it overwrites the live directory',
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
/* nothing at the copied path: try the linked contents */
|
|
644
|
+
}
|
|
645
|
+
const linked = path.join(root, LINKS_DIR, name);
|
|
646
|
+
try {
|
|
647
|
+
const info = fs.lstatSync(linked);
|
|
648
|
+
if (info.isDirectory() === true) {
|
|
649
|
+
return {
|
|
650
|
+
source: linked,
|
|
651
|
+
mode: RECOVERY_MODES.REPLACE_LINK,
|
|
652
|
+
reason: 'the backup holds the CONTENTS of a link: installation, so they take the place of the live link',
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
} catch {
|
|
656
|
+
/* nothing there either */
|
|
657
|
+
}
|
|
658
|
+
// A SLIM BACKUP DOES NOT HOLD THIRD-PARTY PLUGINS AT ALL. That is not a broken
|
|
659
|
+
// backup and not a missing one — it is a backup that expects the plugin to be
|
|
660
|
+
// reinstalled from package.json + pnpm-lock.yaml. Saying "no backup" would send
|
|
661
|
+
// the user looking for a bug; this says what to do instead.
|
|
662
|
+
//
|
|
663
|
+
// Only reachable when the caller says the backup is slim, so every existing
|
|
664
|
+
// recovery path answers exactly as it did before.
|
|
665
|
+
if (slim === true && isLinkInstall !== true) {
|
|
666
|
+
return {
|
|
667
|
+
source: null,
|
|
668
|
+
mode: RECOVERY_MODES.NEEDS_ONLINE_REINSTALL,
|
|
669
|
+
reason: 'this backup keeps only the configuration and the hand-written link plugins; '
|
|
670
|
+
+ 'the plugin ' + name + ' has to be reinstalled (package.json + pnpm-lock.yaml)',
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
source: null,
|
|
675
|
+
mode: RECOVERY_MODES.NO_BACKUP,
|
|
676
|
+
reason: 'the backup holds neither a copy nor the contents of this plugin',
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Is a plugin part of what this profile enables?
|
|
682
|
+
*
|
|
683
|
+
* The names in a log are entry ids (`webserver`) while the profile lists package
|
|
684
|
+
* names (`@deepseek-ai/dsh-host-webserver`); the package name's last segment is
|
|
685
|
+
* therefore accepted as well, so a real entry is not dismissed as "unknown".
|
|
686
|
+
*
|
|
687
|
+
* @param {string[]|null} enabledPlugins
|
|
688
|
+
* @param {string} plugin
|
|
689
|
+
* @returns {boolean}
|
|
690
|
+
*/
|
|
691
|
+
export function isEnabledPlugin(enabledPlugins, plugin) {
|
|
692
|
+
if (enabledPlugins === null || enabledPlugins === undefined) return true;
|
|
693
|
+
if (!Array.isArray(enabledPlugins)) return true;
|
|
694
|
+
if (enabledPlugins.length === 0) return true;
|
|
695
|
+
const name = typeof plugin === 'string' ? plugin.trim() : '';
|
|
696
|
+
if (name === '') return false;
|
|
697
|
+
const short = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
|
|
698
|
+
for (const candidate of enabledPlugins) {
|
|
699
|
+
const value = typeof candidate === 'string' ? candidate.trim() : '';
|
|
700
|
+
if (value === '') continue;
|
|
701
|
+
if (value === name) return true;
|
|
702
|
+
if (value === short) return true;
|
|
703
|
+
const candidateShort = value.includes('/') ? value.slice(value.lastIndexOf('/') + 1) : value;
|
|
704
|
+
if (candidateShort === short) return true;
|
|
705
|
+
}
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Decide, per failed plugin, what should happen.
|
|
711
|
+
*
|
|
712
|
+
* NOTHING IS TOUCHED HERE. The plan only says what the restore would do; the
|
|
713
|
+
* caller applies it (and the plan is what the tests drive).
|
|
714
|
+
*
|
|
715
|
+
* @param {{
|
|
716
|
+
* failures?: Array<object>,
|
|
717
|
+
* backupDir?: string|null,
|
|
718
|
+
* profileDir?: string,
|
|
719
|
+
* backupMeta?: object|null,
|
|
720
|
+
* enabledPlugins?: string[]|null,
|
|
721
|
+
* recovery?: object|null,
|
|
722
|
+
* attemptLimit?: number,
|
|
723
|
+
* fs?: object,
|
|
724
|
+
* }} [options]
|
|
725
|
+
* @returns {{
|
|
726
|
+
* backupDir: string|null,
|
|
727
|
+
* backupKind: string|null,
|
|
728
|
+
* restoredAt: number|null,
|
|
729
|
+
* actions: Array<object>,
|
|
730
|
+
* needsHuman: boolean,
|
|
731
|
+
* restorable: number,
|
|
732
|
+
* }}
|
|
733
|
+
*/
|
|
734
|
+
export function planPluginRecovery({
|
|
735
|
+
failures = [],
|
|
736
|
+
backupDir = null,
|
|
737
|
+
profileDir = '',
|
|
738
|
+
backupMeta = null,
|
|
739
|
+
enabledPlugins = null,
|
|
740
|
+
recovery = null,
|
|
741
|
+
attemptLimit = DEFAULT_ATTEMPT_LIMIT,
|
|
742
|
+
fs = fsDefault,
|
|
743
|
+
} = {}) {
|
|
744
|
+
const root = typeof backupDir === 'string' && backupDir.trim() !== '' ? backupDir.trim() : null;
|
|
745
|
+
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
746
|
+
// The identity of the backup: it is what the report says the plugin was
|
|
747
|
+
// restored FROM ("restored to which point in time").
|
|
748
|
+
const meta = backupMeta && typeof backupMeta === 'object'
|
|
749
|
+
? backupMeta
|
|
750
|
+
: (root === null ? null : readBackupMeta(root));
|
|
751
|
+
const backupKind = meta && typeof meta.kind === 'string' ? meta.kind : null;
|
|
752
|
+
const restoredAt = meta && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null;
|
|
753
|
+
const state = recovery && typeof recovery === 'object' ? recovery : createRecoveryState();
|
|
754
|
+
const actions = [];
|
|
755
|
+
let needsHuman = false;
|
|
756
|
+
let restorable = 0;
|
|
757
|
+
|
|
758
|
+
for (const failure of Array.isArray(failures) ? failures : []) {
|
|
759
|
+
if (!failure || typeof failure !== 'object') continue;
|
|
760
|
+
const plugin = typeof failure.plugin === 'string' ? failure.plugin.trim() : '';
|
|
761
|
+
if (plugin === '') continue;
|
|
762
|
+
const fingerprint = typeof failure.fingerprint === 'string' && failure.fingerprint !== ''
|
|
763
|
+
? failure.fingerprint
|
|
764
|
+
: failureFingerprint(failure);
|
|
765
|
+
const base = {
|
|
766
|
+
plugin,
|
|
767
|
+
fingerprint,
|
|
768
|
+
error: typeof failure.error === 'string' ? failure.error : '',
|
|
769
|
+
failedAt: Number.isFinite(Number(failure.at)) ? Math.trunc(Number(failure.at)) : null,
|
|
770
|
+
backupKind,
|
|
771
|
+
restoredAt,
|
|
772
|
+
restoredFrom: null,
|
|
773
|
+
source: null,
|
|
774
|
+
applied: false,
|
|
775
|
+
changed: false,
|
|
776
|
+
evidence: null,
|
|
777
|
+
needsHuman: false,
|
|
778
|
+
reason: '',
|
|
779
|
+
mode: RECOVERY_MODES.NO_BACKUP,
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// (1) THE SAME FAILURE, ALREADY HANDLED: the loop guard of the log line.
|
|
783
|
+
if (handledRecord(state, fingerprint) !== null) {
|
|
784
|
+
actions.push({
|
|
785
|
+
...base,
|
|
786
|
+
mode: RECOVERY_MODES.DUPLICATE,
|
|
787
|
+
reason: 'this exact failure was already handled in an earlier cycle: it is not a new incident, '
|
|
788
|
+
+ 'so nothing is restored and no restart is asked for',
|
|
789
|
+
});
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// (2) THIS PLUGIN ALREADY USED ITS ONE ATTEMPT: report, never act.
|
|
794
|
+
const decision = decideAttempt(state, plugin, { limit: attemptLimit });
|
|
795
|
+
if (decision.allowed !== true) {
|
|
796
|
+
needsHuman = true;
|
|
797
|
+
actions.push({
|
|
798
|
+
...base,
|
|
799
|
+
mode: RECOVERY_MODES.NEEDS_HUMAN,
|
|
800
|
+
needsHuman: true,
|
|
801
|
+
reason: decision.reason,
|
|
802
|
+
});
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// (3) A PLUGIN THIS PROFILE DOES NOT ENABLE is not ours to write into.
|
|
807
|
+
if (isEnabledPlugin(enabledPlugins, plugin) !== true) {
|
|
808
|
+
actions.push({
|
|
809
|
+
...base,
|
|
810
|
+
mode: RECOVERY_MODES.NO_BACKUP,
|
|
811
|
+
reason: 'the plugin is not in the enabled plugin list of this profile: it is left exactly as it is',
|
|
812
|
+
});
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// (4) NO BACKUP AT ALL: nothing to restore from.
|
|
817
|
+
if (root === null) {
|
|
818
|
+
actions.push({
|
|
819
|
+
...base,
|
|
820
|
+
mode: RECOVERY_MODES.NO_BACKUP,
|
|
821
|
+
reason: 'no copy on this machine has a restart record saying it came up with matching content, so there is nothing to restore from',
|
|
822
|
+
});
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// (5) WHERE THE COPY IS (real copy, link contents, or nowhere).
|
|
827
|
+
// A slim backup deliberately holds no node_modules. Ask the BACKUP which kind
|
|
828
|
+
// it is instead of assuming: old full backups must keep working.
|
|
829
|
+
const slim = !fs.existsSync(path.join(root, PLUGINS_DIR));
|
|
830
|
+
const located = locateBackupCopy({ backupDir: root, plugin, fs, slim });
|
|
831
|
+
if (located.source === null) {
|
|
832
|
+
actions.push({ ...base, mode: RECOVERY_MODES.NO_BACKUP, reason: located.reason });
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
restorable += 1;
|
|
837
|
+
actions.push({
|
|
838
|
+
...base,
|
|
839
|
+
mode: located.mode,
|
|
840
|
+
// Where the copy comes from, and the identity of the backup it belongs to.
|
|
841
|
+
source: located.source,
|
|
842
|
+
restoredFrom: located.source,
|
|
843
|
+
reason: located.reason,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// A plan is only "human needed" when a plugin really ran out of attempts; a
|
|
848
|
+
// duplicate or a missing backup is a normal outcome.
|
|
849
|
+
return {
|
|
850
|
+
backupDir: root,
|
|
851
|
+
backupKind,
|
|
852
|
+
restoredAt,
|
|
853
|
+
actions,
|
|
854
|
+
needsHuman,
|
|
855
|
+
restorable,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// Applying the plan
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Copy a directory tree. The default is the platform's own recursive copy.
|
|
865
|
+
*
|
|
866
|
+
* @param {string} source
|
|
867
|
+
* @param {string} destination
|
|
868
|
+
* @param {{ fs?: object }} [options]
|
|
869
|
+
* @returns {{ ok: boolean, error: string|null }}
|
|
870
|
+
*/
|
|
871
|
+
export function copyTree(source, destination, { fs = fsDefault } = {}) {
|
|
872
|
+
try {
|
|
873
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
874
|
+
fs.cpSync(source, destination, { recursive: true, force: true });
|
|
875
|
+
return { ok: true, error: null };
|
|
876
|
+
} catch (error) {
|
|
877
|
+
return { ok: false, error: error && error.message ? error.message : String(error) };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Apply a plan: move the live directory aside (EVIDENCE, never a delete) and let
|
|
883
|
+
* the backup copy take its place.
|
|
884
|
+
*
|
|
885
|
+
* The plan is NOT mutated: the returned actions are the record of what happened,
|
|
886
|
+
* which is what the report and the tests read.
|
|
887
|
+
*
|
|
888
|
+
* @param {{
|
|
889
|
+
* plan: object,
|
|
890
|
+
* profileDir?: string,
|
|
891
|
+
* now?: number,
|
|
892
|
+
* fs?: object,
|
|
893
|
+
* copy?: Function|null,
|
|
894
|
+
* compare?: boolean,
|
|
895
|
+
* }} [options]
|
|
896
|
+
* @returns {{ changed: boolean, actions: Array<object>, needsHuman: boolean, restorable: number }}
|
|
897
|
+
*/
|
|
898
|
+
export function applyPluginRecovery({
|
|
899
|
+
plan = null,
|
|
900
|
+
profileDir = '',
|
|
901
|
+
now = Date.now(),
|
|
902
|
+
fs = fsDefault,
|
|
903
|
+
copy = null,
|
|
904
|
+
compare = true,
|
|
905
|
+
} = {}) {
|
|
906
|
+
const source = plan && typeof plan === 'object' ? plan : {};
|
|
907
|
+
const actions = Array.isArray(source.actions) ? source.actions : [];
|
|
908
|
+
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
909
|
+
const copyFn = typeof copy === 'function' ? copy : ((from, to) => copyTree(from, to, { fs }));
|
|
910
|
+
const out = [];
|
|
911
|
+
let changed = false;
|
|
912
|
+
let needsHuman = source.needsHuman === true;
|
|
913
|
+
|
|
914
|
+
for (const action of actions) {
|
|
915
|
+
const record = { ...action, applied: false, changed: false, evidence: null };
|
|
916
|
+
if (action.needsHuman === true) needsHuman = true;
|
|
917
|
+
// Only the two restore modes touch the disk; everything else is a decision
|
|
918
|
+
// that says "nothing happens" and is reported as it is.
|
|
919
|
+
if (!RESTORE_MODES.includes(action.mode) || typeof action.source !== 'string') {
|
|
920
|
+
out.push(record);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
if (profile === '') {
|
|
924
|
+
out.push({ ...record, mode: RECOVERY_MODES.NO_BACKUP, reason: 'no profile directory was resolved, so nothing could be restored' });
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const livePath = path.join(profile, PLUGINS_DIR, action.plugin);
|
|
928
|
+
// IDEMPOTENCY: an already restored plugin is not copied again, and — the
|
|
929
|
+
// point of the whole check — no restart is asked for it.
|
|
930
|
+
if (compare !== false) {
|
|
931
|
+
const comparison = sameAsBackup(livePath, action.source, { fs });
|
|
932
|
+
if (comparison.same === true) {
|
|
933
|
+
out.push({
|
|
934
|
+
...record,
|
|
935
|
+
mode: RECOVERY_MODES.ALREADY_CONSISTENT,
|
|
936
|
+
reason: comparison.reason,
|
|
937
|
+
});
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
record.reason = comparison.reason;
|
|
941
|
+
}
|
|
942
|
+
let evidence = null;
|
|
943
|
+
let livePresent = false;
|
|
944
|
+
try {
|
|
945
|
+
fs.lstatSync(livePath);
|
|
946
|
+
livePresent = true;
|
|
947
|
+
} catch {
|
|
948
|
+
livePresent = false;
|
|
949
|
+
}
|
|
950
|
+
if (livePresent) {
|
|
951
|
+
const brokenPath = path.join(profile, PLUGINS_DIR, brokenNameFor(action.plugin, now));
|
|
952
|
+
try {
|
|
953
|
+
// THE EVIDENCE. The broken tree is renamed, never deleted: a user (or a
|
|
954
|
+
// colleague) must be able to look at what was really there.
|
|
955
|
+
fs.renameSync(livePath, brokenPath);
|
|
956
|
+
evidence = { renamedFrom: livePath, renamedTo: brokenPath };
|
|
957
|
+
} catch (error) {
|
|
958
|
+
out.push({
|
|
959
|
+
...record,
|
|
960
|
+
applied: false,
|
|
961
|
+
error: error && error.message ? error.message : String(error),
|
|
962
|
+
reason: 'the live directory could not be moved aside, so it was left alone (nothing was deleted)',
|
|
963
|
+
});
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const copied = copyFn(action.source, livePath);
|
|
968
|
+
if (copied && copied.ok === false) {
|
|
969
|
+
out.push({
|
|
970
|
+
...record,
|
|
971
|
+
applied: false,
|
|
972
|
+
evidence,
|
|
973
|
+
error: copied.error || 'the copy failed',
|
|
974
|
+
reason: 'the backup copy could not be written: the live directory was left as it was',
|
|
975
|
+
});
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
changed = true;
|
|
979
|
+
out.push({
|
|
980
|
+
...record,
|
|
981
|
+
applied: true,
|
|
982
|
+
changed: true,
|
|
983
|
+
evidence,
|
|
984
|
+
restoredFrom: action.source,
|
|
985
|
+
reason: 'the backup copy now takes the place of the failed plugin',
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return { changed, actions: out, needsHuman, restorable: Number(source.restorable) || 0 };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// The report (`<stateDir>/last-plugin-recovery.json`)
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Build the readable report of one recovery pass.
|
|
998
|
+
*
|
|
999
|
+
* The shape is the contract with the interface: `at` / `atIso` (when), `failures`
|
|
1000
|
+
* (what the log said), `actions` (what was done, with `restoredAt` / `backupKind`
|
|
1001
|
+
* from the BACKUP'S OWN meta), `restartRequested` (whether a restart was asked
|
|
1002
|
+
* for) and `needsHuman` (whether somebody has to look).
|
|
1003
|
+
*
|
|
1004
|
+
* @param {{
|
|
1005
|
+
* at?: number,
|
|
1006
|
+
* atIso?: string|null,
|
|
1007
|
+
* log?: string|null,
|
|
1008
|
+
* failures?: Array<object>,
|
|
1009
|
+
* actions?: Array<object>,
|
|
1010
|
+
* restartRequested?: boolean,
|
|
1011
|
+
* needsHuman?: boolean,
|
|
1012
|
+
* backupDir?: string|null,
|
|
1013
|
+
* backupKind?: string|null,
|
|
1014
|
+
* restoredAt?: number|null,
|
|
1015
|
+
* }} [options]
|
|
1016
|
+
* @returns {object}
|
|
1017
|
+
*/
|
|
1018
|
+
export function buildRecoveryReport({
|
|
1019
|
+
at = Date.now(),
|
|
1020
|
+
atIso = null,
|
|
1021
|
+
log = null,
|
|
1022
|
+
failures = [],
|
|
1023
|
+
actions = [],
|
|
1024
|
+
restartRequested = false,
|
|
1025
|
+
needsHuman = false,
|
|
1026
|
+
backupDir = null,
|
|
1027
|
+
backupKind = null,
|
|
1028
|
+
restoredAt = null,
|
|
1029
|
+
} = {}) {
|
|
1030
|
+
const when = Number.isFinite(Number(at)) ? Math.trunc(Number(at)) : Date.now();
|
|
1031
|
+
const failureList = (Array.isArray(failures) ? failures : []).map((failure) => ({
|
|
1032
|
+
plugin: typeof failure.plugin === 'string' ? failure.plugin : null,
|
|
1033
|
+
error: typeof failure.error === 'string' ? failure.error : '',
|
|
1034
|
+
at: Number.isFinite(Number(failure.at)) ? Math.trunc(Number(failure.at)) : null,
|
|
1035
|
+
kind: typeof failure.kind === 'string' ? failure.kind : null,
|
|
1036
|
+
fingerprint: typeof failure.fingerprint === 'string' ? failure.fingerprint : null,
|
|
1037
|
+
}));
|
|
1038
|
+
const actionList = (Array.isArray(actions) ? actions : []).map((action) => ({
|
|
1039
|
+
plugin: typeof action.plugin === 'string' ? action.plugin : '',
|
|
1040
|
+
mode: typeof action.mode === 'string' ? action.mode : RECOVERY_MODES.NO_BACKUP,
|
|
1041
|
+
restoredFrom: typeof action.restoredFrom === 'string' ? action.restoredFrom : null,
|
|
1042
|
+
restoredAt: Number.isFinite(Number(action.restoredAt)) ? Math.trunc(Number(action.restoredAt)) : restoredAt,
|
|
1043
|
+
backupKind: typeof action.backupKind === 'string' ? action.backupKind : backupKind,
|
|
1044
|
+
evidence: action.evidence && typeof action.evidence === 'object'
|
|
1045
|
+
? {
|
|
1046
|
+
renamedFrom: typeof action.evidence.renamedFrom === 'string' ? action.evidence.renamedFrom : null,
|
|
1047
|
+
renamedTo: typeof action.evidence.renamedTo === 'string' ? action.evidence.renamedTo : null,
|
|
1048
|
+
}
|
|
1049
|
+
: null,
|
|
1050
|
+
applied: action.applied === true,
|
|
1051
|
+
changed: action.changed === true,
|
|
1052
|
+
needsHuman: action.needsHuman === true,
|
|
1053
|
+
reason: typeof action.reason === 'string' ? action.reason : '',
|
|
1054
|
+
}));
|
|
1055
|
+
return {
|
|
1056
|
+
at: when,
|
|
1057
|
+
atIso: typeof atIso === 'string' && atIso !== '' ? atIso : new Date(when).toISOString(),
|
|
1058
|
+
log: typeof log === 'string' && log !== '' ? log : null,
|
|
1059
|
+
backupDir: typeof backupDir === 'string' && backupDir !== '' ? backupDir : null,
|
|
1060
|
+
backupKind: typeof backupKind === 'string' ? backupKind : null,
|
|
1061
|
+
restoredAt: Number.isFinite(Number(restoredAt)) ? Math.trunc(Number(restoredAt)) : null,
|
|
1062
|
+
failures: failureList,
|
|
1063
|
+
actions: actionList,
|
|
1064
|
+
restartRequested: restartRequested === true,
|
|
1065
|
+
needsHuman: needsHuman === true,
|
|
1066
|
+
changed: actionList.some((action) => action.changed === true),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Write the report. Never throws: a report that cannot be written must not take
|
|
1072
|
+
* the boot down (the log line is the caller's job).
|
|
1073
|
+
*
|
|
1074
|
+
* @param {string} stateDir
|
|
1075
|
+
* @param {object} report
|
|
1076
|
+
* @param {{ fs?: object }} [options]
|
|
1077
|
+
* @returns {boolean} whether it was written
|
|
1078
|
+
*/
|
|
1079
|
+
export function writeRecoveryReport(stateDir, report, { fs = fsDefault } = {}) {
|
|
1080
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
1081
|
+
if (dir === '' || !report || typeof report !== 'object') return false;
|
|
1082
|
+
try {
|
|
1083
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1084
|
+
fs.writeFileSync(path.join(dir, RECOVERY_REPORT_NAME), JSON.stringify(report, null, 2), 'utf8');
|
|
1085
|
+
return true;
|
|
1086
|
+
} catch {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Read the report of the last recovery (null when there is none).
|
|
1093
|
+
*
|
|
1094
|
+
* @param {string} stateDir
|
|
1095
|
+
* @param {{ fs?: object }} [options]
|
|
1096
|
+
* @returns {object|null}
|
|
1097
|
+
*/
|
|
1098
|
+
export function readRecoveryReport(stateDir, { fs = fsDefault } = {}) {
|
|
1099
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
1100
|
+
if (dir === '') return null;
|
|
1101
|
+
try {
|
|
1102
|
+
const text = fs.readFileSync(path.join(dir, RECOVERY_REPORT_NAME), 'utf8');
|
|
1103
|
+
const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
1104
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
1105
|
+
} catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* The report, cut down to what a health answer carries.
|
|
1112
|
+
*
|
|
1113
|
+
* The health route is polled every couple of seconds, so the answer stays small:
|
|
1114
|
+
* the plugin names, the mode of each action and the two timestamps that answer
|
|
1115
|
+
* "what broke" and "restored to when".
|
|
1116
|
+
*
|
|
1117
|
+
* @param {object|null} report
|
|
1118
|
+
* @returns {object|null}
|
|
1119
|
+
*/
|
|
1120
|
+
export function summarizeRecoveryReport(report) {
|
|
1121
|
+
if (!report || typeof report !== 'object') return null;
|
|
1122
|
+
const failures = Array.isArray(report.failures) ? report.failures : [];
|
|
1123
|
+
const actions = Array.isArray(report.actions) ? report.actions : [];
|
|
1124
|
+
return {
|
|
1125
|
+
at: Number.isFinite(Number(report.at)) ? Math.trunc(Number(report.at)) : null,
|
|
1126
|
+
atIso: typeof report.atIso === 'string' ? report.atIso : null,
|
|
1127
|
+
backupKind: typeof report.backupKind === 'string' ? report.backupKind : null,
|
|
1128
|
+
restoredAt: Number.isFinite(Number(report.restoredAt)) ? Math.trunc(Number(report.restoredAt)) : null,
|
|
1129
|
+
restartRequested: report.restartRequested === true,
|
|
1130
|
+
needsHuman: report.needsHuman === true,
|
|
1131
|
+
failures: failures.slice(0, 10).map((failure) => ({
|
|
1132
|
+
plugin: typeof failure.plugin === 'string' ? failure.plugin : null,
|
|
1133
|
+
error: typeof failure.error === 'string' ? failure.error : '',
|
|
1134
|
+
})),
|
|
1135
|
+
actions: actions.slice(0, 10).map((action) => ({
|
|
1136
|
+
plugin: typeof action.plugin === 'string' ? action.plugin : '',
|
|
1137
|
+
mode: typeof action.mode === 'string' ? action.mode : RECOVERY_MODES.NO_BACKUP,
|
|
1138
|
+
restoredAt: Number.isFinite(Number(action.restoredAt)) ? Math.trunc(Number(action.restoredAt)) : null,
|
|
1139
|
+
backupKind: typeof action.backupKind === 'string' ? action.backupKind : null,
|
|
1140
|
+
evidence: action.evidence && typeof action.evidence === 'object' && typeof action.evidence.renamedTo === 'string'
|
|
1141
|
+
? { renamedTo: action.evidence.renamedTo }
|
|
1142
|
+
: null,
|
|
1143
|
+
})),
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ---------------------------------------------------------------------------
|
|
1148
|
+
// AT BOOT: LOOK ONLY (user decision, 2026-11-01 — "只提示,不改文件")
|
|
1149
|
+
// ---------------------------------------------------------------------------
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Read the previous boot's log, recognise the failed plugins and WRITE THE
|
|
1153
|
+
* REPORT. Nothing else.
|
|
1154
|
+
*
|
|
1155
|
+
* WHY THIS REPLACED THE AUTOMATIC RECOVERY: a plugin that fails to import never
|
|
1156
|
+
* stops the host from coming up — measured on the desktop (22 s) and on the
|
|
1157
|
+
* hosted `dsh web` instance (2.0–2.6 s), with the profile's `package.json`
|
|
1158
|
+
* byte-identical and no repeated restarts under `supervise-web.sh`. The host
|
|
1159
|
+
* already isolates a broken entry, so an automatic restore only added a second
|
|
1160
|
+
* writer into the profile at boot and an automatic restart on top of it.
|
|
1161
|
+
*
|
|
1162
|
+
* WHAT IT DOES NOT DO, ON PURPOSE (both are asserted by the tests):
|
|
1163
|
+
*
|
|
1164
|
+
* - it never calls {@link applyPluginRecovery} (no file inside the profile is
|
|
1165
|
+
* touched, no `<plugin>.broken-<ts>` evidence is created);
|
|
1166
|
+
* - it never calls `requestRestart` (a boot never asks for another boot).
|
|
1167
|
+
*
|
|
1168
|
+
* The only file it writes is the plugin's OWN report under `stateDir`
|
|
1169
|
+
* (`last-plugin-recovery.json`), which is what the health route and the popup
|
|
1170
|
+
* read.
|
|
1171
|
+
*
|
|
1172
|
+
* The two pure functions {@link planPluginRecovery} and
|
|
1173
|
+
* {@link applyPluginRecovery} stay public: {@link restoreOnePlugin} uses them
|
|
1174
|
+
* when the USER asks for one plugin to be put back.
|
|
1175
|
+
*
|
|
1176
|
+
* @param {{
|
|
1177
|
+
* stateDir?: string,
|
|
1178
|
+
* backupsDir?: string,
|
|
1179
|
+
* logsDir?: string,
|
|
1180
|
+
* bootAt?: number,
|
|
1181
|
+
* now?: number,
|
|
1182
|
+
* fs?: object,
|
|
1183
|
+
* logPath?: string|null,
|
|
1184
|
+
* write?: boolean,
|
|
1185
|
+
* }} [options]
|
|
1186
|
+
* @returns {object} the outcome (never null; `ran:false` carries a `reason`)
|
|
1187
|
+
*/
|
|
1188
|
+
export function detectPluginRecovery({
|
|
1189
|
+
stateDir = '',
|
|
1190
|
+
backupsDir = '',
|
|
1191
|
+
logsDir = '',
|
|
1192
|
+
bootAt = Date.now(),
|
|
1193
|
+
now = Date.now(),
|
|
1194
|
+
fs = fsDefault,
|
|
1195
|
+
logPath = null,
|
|
1196
|
+
write = true,
|
|
1197
|
+
} = {}) {
|
|
1198
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
1199
|
+
const at = Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now();
|
|
1200
|
+
const boot = Number.isFinite(Number(bootAt)) ? Math.trunc(Number(bootAt)) : at;
|
|
1201
|
+
const empty = {
|
|
1202
|
+
ran: false,
|
|
1203
|
+
found: false,
|
|
1204
|
+
changed: false,
|
|
1205
|
+
restartRequested: false,
|
|
1206
|
+
needsHuman: false,
|
|
1207
|
+
report: null,
|
|
1208
|
+
log: null,
|
|
1209
|
+
failures: [],
|
|
1210
|
+
backupDir: null,
|
|
1211
|
+
backupKind: null,
|
|
1212
|
+
restoredAt: null,
|
|
1213
|
+
};
|
|
1214
|
+
if (dir === '') return { ...empty, reason: 'no state directory was resolved' };
|
|
1215
|
+
|
|
1216
|
+
// (1) WHICH LOG: the PREVIOUS boot's own log (never this one — its audit
|
|
1217
|
+
// verdict does not exist yet).
|
|
1218
|
+
const log = typeof logPath === 'string' && logPath !== ''
|
|
1219
|
+
? { name: path.basename(logPath), path: logPath, at: null, mtime: 0, size: 0 }
|
|
1220
|
+
: findLatestStartupLog(logsDir, { before: boot, fs });
|
|
1221
|
+
if (log === null) {
|
|
1222
|
+
return { ...empty, reason: 'no finished boot log was found, so there is nothing to judge' };
|
|
1223
|
+
}
|
|
1224
|
+
let text = ''
|
|
1225
|
+
try {
|
|
1226
|
+
text = String(fs.readFileSync(log.path, 'utf8'));
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
return {
|
|
1229
|
+
...empty,
|
|
1230
|
+
log: log.path,
|
|
1231
|
+
reason: 'the startup log could not be read: ' + (error && error.message ? error.message : String(error)),
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// (2) DETECT.
|
|
1236
|
+
const sinceMs = Number.isFinite(Number(log.at)) ? Number(log.at) : null;
|
|
1237
|
+
const failures = findPluginFailures(text, {
|
|
1238
|
+
sinceMs,
|
|
1239
|
+
logAt: Number.isFinite(Number(log.mtime)) && log.mtime > 0 ? Math.trunc(log.mtime) : null,
|
|
1240
|
+
now: at,
|
|
1241
|
+
});
|
|
1242
|
+
const named = mergeFailuresByPlugin(failures);
|
|
1243
|
+
if (named.length === 0) {
|
|
1244
|
+
return {
|
|
1245
|
+
...empty,
|
|
1246
|
+
ran: true,
|
|
1247
|
+
log: log.path,
|
|
1248
|
+
reason: 'the previous boot reported no plugin failure',
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// (3) WHICH BACKUP THE USER COULD RESTORE FROM. Two cheap `lstat`s per plugin
|
|
1253
|
+
// less than a `sameAsBackup` digest, so the boot pays next to nothing for
|
|
1254
|
+
// saying "a backup exists / does not exist" in the popup.
|
|
1255
|
+
const knownGood = typeof backupsDir === 'string' && backupsDir.trim() !== ''
|
|
1256
|
+
? latestKnownGood(backupsDir.trim())
|
|
1257
|
+
: null;
|
|
1258
|
+
const backupDir = knownGood && typeof knownGood.dir === 'string' ? knownGood.dir : null;
|
|
1259
|
+
const meta = backupDir === null ? null : readBackupMeta(backupDir);
|
|
1260
|
+
const backupKind = meta && typeof meta.kind === 'string' ? meta.kind : null;
|
|
1261
|
+
const restoredAt = meta && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null;
|
|
1262
|
+
|
|
1263
|
+
// (4) THE REPORT. `actions` stays EMPTY: nothing was done, and saying
|
|
1264
|
+
// "overwrite" here would claim a restore that never happened.
|
|
1265
|
+
const report = buildRecoveryReport({
|
|
1266
|
+
at,
|
|
1267
|
+
log: log.path,
|
|
1268
|
+
failures: named,
|
|
1269
|
+
actions: [],
|
|
1270
|
+
restartRequested: false,
|
|
1271
|
+
needsHuman: false,
|
|
1272
|
+
backupDir,
|
|
1273
|
+
backupKind,
|
|
1274
|
+
restoredAt,
|
|
1275
|
+
});
|
|
1276
|
+
if (write === true) writeRecoveryReport(dir, report, { fs });
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
ran: true,
|
|
1280
|
+
found: true,
|
|
1281
|
+
changed: false,
|
|
1282
|
+
restartRequested: false,
|
|
1283
|
+
needsHuman: false,
|
|
1284
|
+
report,
|
|
1285
|
+
log: log.path,
|
|
1286
|
+
failures: named,
|
|
1287
|
+
backupDir,
|
|
1288
|
+
backupKind,
|
|
1289
|
+
restoredAt,
|
|
1290
|
+
reason: 'the previous boot reported a plugin failure: it is reported, nothing was changed',
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ---------------------------------------------------------------------------
|
|
1295
|
+
// ON DEMAND: restore exactly ONE plugin (the popup's own button)
|
|
1296
|
+
// ---------------------------------------------------------------------------
|
|
1297
|
+
|
|
1298
|
+
/** The record of the restores a human asked for. */
|
|
1299
|
+
export const RESTORE_HISTORY_NAME = 'plugin-restore-history.json';
|
|
1300
|
+
|
|
1301
|
+
/** How many of them are kept (newest first). */
|
|
1302
|
+
export const RESTORE_HISTORY_LIMIT = 20;
|
|
1303
|
+
|
|
1304
|
+
/** An empty history. */
|
|
1305
|
+
export function createRestoreHistory() {
|
|
1306
|
+
return { version: 1, entries: [] };
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Read the restore history. Never throws: a corrupt file is an empty history of
|
|
1311
|
+
* its own, not a broken popup.
|
|
1312
|
+
*
|
|
1313
|
+
* @param {string} stateDir
|
|
1314
|
+
* @param {{ fs?: object }} [options]
|
|
1315
|
+
* @returns {{ version: number, entries: Array<object> }}
|
|
1316
|
+
*/
|
|
1317
|
+
export function readRestoreHistory(stateDir, { fs = fsDefault } = {}) {
|
|
1318
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
1319
|
+
if (dir === '') return createRestoreHistory();
|
|
1320
|
+
try {
|
|
1321
|
+
const text = fs.readFileSync(path.join(dir, RESTORE_HISTORY_NAME), 'utf8');
|
|
1322
|
+
const raw = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
1323
|
+
const parsed = JSON.parse(raw);
|
|
1324
|
+
const entries = Array.isArray(parsed && parsed.entries) ? parsed.entries : [];
|
|
1325
|
+
return { version: 1, entries: entries.filter((entry) => entry && typeof entry === 'object') };
|
|
1326
|
+
} catch {
|
|
1327
|
+
return createRestoreHistory();
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Append one restore to the history, newest first, capped.
|
|
1333
|
+
*
|
|
1334
|
+
* @param {string} stateDir
|
|
1335
|
+
* @param {object} entry
|
|
1336
|
+
* @param {{ fs?: object, limit?: number }} [options]
|
|
1337
|
+
* @returns {{ ok: boolean, entries: Array<object>, error: string|null }}
|
|
1338
|
+
*/
|
|
1339
|
+
export function appendRestoreHistory(stateDir, entry, { fs = fsDefault, limit = RESTORE_HISTORY_LIMIT } = {}) {
|
|
1340
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
1341
|
+
const cap = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.trunc(Number(limit)) : RESTORE_HISTORY_LIMIT;
|
|
1342
|
+
if (dir === '') return { ok: false, entries: [], error: 'no state directory was resolved' };
|
|
1343
|
+
const history = readRestoreHistory(dir, { fs });
|
|
1344
|
+
const entries = [entry, ...history.entries].filter((item) => item && typeof item === 'object').slice(0, cap);
|
|
1345
|
+
try {
|
|
1346
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1347
|
+
fs.writeFileSync(path.join(dir, RESTORE_HISTORY_NAME), JSON.stringify({ version: 1, entries }, null, 2), 'utf8');
|
|
1348
|
+
return { ok: true, entries, error: null };
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
return { ok: false, entries, error: error && error.message ? error.message : String(error) };
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* The newest restore, cut down to what the health answer carries.
|
|
1356
|
+
*
|
|
1357
|
+
* @param {{ entries?: Array<object> }|Array<object>|null} history
|
|
1358
|
+
* @returns {object|null}
|
|
1359
|
+
*/
|
|
1360
|
+
export function summarizeRestoreHistory(history) {
|
|
1361
|
+
const entries = Array.isArray(history)
|
|
1362
|
+
? history
|
|
1363
|
+
: (history && Array.isArray(history.entries) ? history.entries : []);
|
|
1364
|
+
const first = entries.find((entry) => entry && typeof entry === 'object');
|
|
1365
|
+
if (!first) return null;
|
|
1366
|
+
return {
|
|
1367
|
+
at: Number.isFinite(Number(first.at)) ? Math.trunc(Number(first.at)) : null,
|
|
1368
|
+
atIso: typeof first.atIso === 'string' ? first.atIso : null,
|
|
1369
|
+
plugin: typeof first.plugin === 'string' ? first.plugin : null,
|
|
1370
|
+
mode: typeof first.mode === 'string' ? first.mode : null,
|
|
1371
|
+
ok: first.ok === true,
|
|
1372
|
+
changed: first.changed === true,
|
|
1373
|
+
needsRestart: first.needsRestart === true,
|
|
1374
|
+
backupKind: typeof first.backupKind === 'string' ? first.backupKind : null,
|
|
1375
|
+
restoredAt: Number.isFinite(Number(first.restoredAt)) ? Math.trunc(Number(first.restoredAt)) : null,
|
|
1376
|
+
reason: typeof first.reason === 'string' ? first.reason : '',
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* PUT ONE PLUGIN BACK — the only path that still writes into the profile, and
|
|
1382
|
+
* only because a human pressed the button.
|
|
1383
|
+
*
|
|
1384
|
+
* The difference to the removed automatic pass is the WHO and the ONE:
|
|
1385
|
+
*
|
|
1386
|
+
* - it restores the plugin it was ASKED for, never the list a log suggests;
|
|
1387
|
+
* - it skips the loop guards of the automatic pass on purpose (`duplicate` /
|
|
1388
|
+
* `needs-human` are about "should the machine act on its own"); a person
|
|
1389
|
+
* asking again is not a loop, and the idempotency check still protects the
|
|
1390
|
+
* disk: a plugin that already equals the backup answers
|
|
1391
|
+
* `already-consistent`, writes nothing and asks for no restart;
|
|
1392
|
+
* - it writes `<stateDir>/plugin-restore-history.json` (newest 20) so the
|
|
1393
|
+
* user can see afterwards what was put back and from when.
|
|
1394
|
+
*
|
|
1395
|
+
* @param {{
|
|
1396
|
+
* stateDir?: string,
|
|
1397
|
+
* profileDir?: string,
|
|
1398
|
+
* backupsDir?: string,
|
|
1399
|
+
* plugin?: string,
|
|
1400
|
+
* now?: number,
|
|
1401
|
+
* fs?: object,
|
|
1402
|
+
* copy?: Function|null,
|
|
1403
|
+
* historyLimit?: number,
|
|
1404
|
+
* }} [options]
|
|
1405
|
+
* @returns {{
|
|
1406
|
+
* ok: boolean,
|
|
1407
|
+
* plugin: string,
|
|
1408
|
+
* mode: string|null,
|
|
1409
|
+
* restoredFrom: string|null,
|
|
1410
|
+
* restoredAt: number|null,
|
|
1411
|
+
* backupKind: string|null,
|
|
1412
|
+
* needsRestart: boolean,
|
|
1413
|
+
* changed: boolean,
|
|
1414
|
+
* evidence: object|null,
|
|
1415
|
+
* at: number,
|
|
1416
|
+
* atIso: string,
|
|
1417
|
+
* reason: string,
|
|
1418
|
+
* }}
|
|
1419
|
+
*/
|
|
1420
|
+
export function restoreOnePlugin({
|
|
1421
|
+
stateDir = '',
|
|
1422
|
+
profileDir = '',
|
|
1423
|
+
backupsDir = '',
|
|
1424
|
+
plugin = '',
|
|
1425
|
+
now = Date.now(),
|
|
1426
|
+
fs = fsDefault,
|
|
1427
|
+
copy = null,
|
|
1428
|
+
historyLimit = RESTORE_HISTORY_LIMIT,
|
|
1429
|
+
} = {}) {
|
|
1430
|
+
const at = Number.isFinite(Number(now)) ? Math.trunc(Number(now)) : Date.now();
|
|
1431
|
+
const name = typeof plugin === 'string' ? plugin.trim() : '';
|
|
1432
|
+
const state = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
1433
|
+
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
1434
|
+
const base = {
|
|
1435
|
+
ok: false,
|
|
1436
|
+
plugin: name,
|
|
1437
|
+
mode: null,
|
|
1438
|
+
restoredFrom: null,
|
|
1439
|
+
restoredAt: null,
|
|
1440
|
+
backupKind: null,
|
|
1441
|
+
needsRestart: false,
|
|
1442
|
+
changed: false,
|
|
1443
|
+
evidence: null,
|
|
1444
|
+
at,
|
|
1445
|
+
atIso: new Date(at).toISOString(),
|
|
1446
|
+
reason: '',
|
|
1447
|
+
};
|
|
1448
|
+
const finish = (outcome) => {
|
|
1449
|
+
const entry = { ...base, ...outcome };
|
|
1450
|
+
// The history is a convenience, never a reason to fail the restore.
|
|
1451
|
+
const written = appendRestoreHistory(state, entry, { fs, limit: historyLimit });
|
|
1452
|
+
return { ...entry, history: { ok: written.ok === true, count: written.entries.length, error: written.error } };
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
if (name === '') return finish({ reason: 'no plugin name was given', mode: RECOVERY_MODES.NO_BACKUP });
|
|
1456
|
+
if (profile === '') return finish({ reason: 'no profile directory was resolved', mode: RECOVERY_MODES.NO_BACKUP });
|
|
1457
|
+
|
|
1458
|
+
const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
|
|
1459
|
+
const knownGood = root === '' ? null : latestKnownGood(root);
|
|
1460
|
+
if (knownGood === null || typeof knownGood.dir !== 'string') {
|
|
1461
|
+
return finish({
|
|
1462
|
+
mode: RECOVERY_MODES.NO_BACKUP,
|
|
1463
|
+
reason: 'no copy on this machine has a restart record saying it came up with matching content, so there is nothing to restore from',
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
const meta = readBackupMeta(knownGood.dir);
|
|
1467
|
+
const backupKind = meta && typeof meta.kind === 'string' ? meta.kind : null;
|
|
1468
|
+
const restoredAt = meta && Number.isFinite(Number(meta.at)) ? Math.trunc(Number(meta.at)) : null;
|
|
1469
|
+
|
|
1470
|
+
// Same detection as above: old full backups keep working.
|
|
1471
|
+
const slim = !fs.existsSync(path.join(knownGood.dir, PLUGINS_DIR));
|
|
1472
|
+
const located = locateBackupCopy({ backupDir: knownGood.dir, plugin: name, fs, slim });
|
|
1473
|
+
if (located.source === null) {
|
|
1474
|
+
return finish({
|
|
1475
|
+
mode: RECOVERY_MODES.NO_BACKUP,
|
|
1476
|
+
reason: located.reason,
|
|
1477
|
+
backupKind,
|
|
1478
|
+
restoredAt,
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// ONE action, built by hand: no `handled` fingerprint and no attempt budget,
|
|
1483
|
+
// so a person can ask twice. The idempotency check inside the applier is the
|
|
1484
|
+
// only thing that decides "nothing to do".
|
|
1485
|
+
const plan = {
|
|
1486
|
+
backupDir: knownGood.dir,
|
|
1487
|
+
backupKind,
|
|
1488
|
+
restoredAt,
|
|
1489
|
+
needsHuman: false,
|
|
1490
|
+
restorable: 1,
|
|
1491
|
+
actions: [{
|
|
1492
|
+
plugin: name,
|
|
1493
|
+
fingerprint: '',
|
|
1494
|
+
error: '',
|
|
1495
|
+
failedAt: null,
|
|
1496
|
+
backupKind,
|
|
1497
|
+
restoredAt,
|
|
1498
|
+
restoredFrom: located.source,
|
|
1499
|
+
source: located.source,
|
|
1500
|
+
mode: located.mode,
|
|
1501
|
+
applied: false,
|
|
1502
|
+
changed: false,
|
|
1503
|
+
evidence: null,
|
|
1504
|
+
needsHuman: false,
|
|
1505
|
+
reason: located.reason,
|
|
1506
|
+
}],
|
|
1507
|
+
};
|
|
1508
|
+
const applied = applyPluginRecovery({ plan, profileDir: profile, now: at, fs, copy });
|
|
1509
|
+
const action = Array.isArray(applied.actions) && applied.actions.length > 0 ? applied.actions[0] : null;
|
|
1510
|
+
if (action === null) {
|
|
1511
|
+
return finish({ mode: located.mode, backupKind, restoredAt, reason: 'the restore produced no action' });
|
|
1512
|
+
}
|
|
1513
|
+
if (action.mode === RECOVERY_MODES.ALREADY_CONSISTENT) {
|
|
1514
|
+
return finish({
|
|
1515
|
+
ok: true,
|
|
1516
|
+
plugin: name,
|
|
1517
|
+
mode: RECOVERY_MODES.ALREADY_CONSISTENT,
|
|
1518
|
+
restoredFrom: located.source,
|
|
1519
|
+
restoredAt,
|
|
1520
|
+
backupKind,
|
|
1521
|
+
needsRestart: false,
|
|
1522
|
+
changed: false,
|
|
1523
|
+
reason: action.reason,
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
if (action.changed !== true) {
|
|
1527
|
+
// A failed copy or a directory that could not be moved aside.
|
|
1528
|
+
return finish({
|
|
1529
|
+
mode: action.mode,
|
|
1530
|
+
restoredFrom: located.source,
|
|
1531
|
+
restoredAt,
|
|
1532
|
+
backupKind,
|
|
1533
|
+
evidence: action.evidence && typeof action.evidence === 'object' ? action.evidence : null,
|
|
1534
|
+
reason: action.error
|
|
1535
|
+
? 'the restore failed: ' + String(action.error)
|
|
1536
|
+
: (action.reason || 'nothing was changed'),
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
return finish({
|
|
1540
|
+
ok: true,
|
|
1541
|
+
plugin: name,
|
|
1542
|
+
mode: action.mode,
|
|
1543
|
+
restoredFrom: action.restoredFrom === undefined ? located.source : action.restoredFrom,
|
|
1544
|
+
restoredAt,
|
|
1545
|
+
backupKind,
|
|
1546
|
+
needsRestart: true,
|
|
1547
|
+
changed: true,
|
|
1548
|
+
evidence: action.evidence && typeof action.evidence === 'object' ? action.evidence : null,
|
|
1549
|
+
reason: 'the backup copy now takes the place of the failed plugin: restart so the next boot loads it',
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* The default backup directory name of a state directory — re-exported so the
|
|
1555
|
+
* caller does not have to know the layout (`lib/backup.js` owns it).
|
|
1556
|
+
*/
|
|
1557
|
+
export { META_NAME, KIND_KNOWN_GOOD, LINKS_DIR, PLUGINS_DIR };
|