dsh-autorestart 0.8.4 → 0.8.5
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 +43 -0
- package/README.md +43 -7
- package/client.js +581 -9
- package/docs/README.zh.md +12 -4
- package/index.js +727 -47
- package/lib/backup.js +219 -4
- package/lib/checkpoint.js +93 -3
- package/lib/restart-phase.js +5 -0
- package/locale/en.json +21 -1
- package/locale/zh.json +21 -1
- package/package.json +2 -2
package/lib/backup.js
CHANGED
|
@@ -31,8 +31,176 @@ export const PLUGINS_DIR = 'node_modules';
|
|
|
31
31
|
/** The manifest written into every backup. */
|
|
32
32
|
export const MANIFEST_NAME = 'manifest.json';
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* How many backups are kept by default.
|
|
36
|
+
*
|
|
37
|
+
* THREE (user decision, 2026-10-30: "the current one plus the two before it"),
|
|
38
|
+
* so a rollback can step one restart further back than the newest copy. The
|
|
39
|
+
* newest KNOWN-GOOD backup is never pruned whatever this number says — losing
|
|
40
|
+
* it would leave the machine with nothing to roll back to.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_MAX_BACKUPS = 3;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* THE IDENTITY OF A BACKUP (user decision, 2026-10-30).
|
|
46
|
+
*
|
|
47
|
+
* A pre-restart snapshot is taken BEFORE the restart: it holds exactly the state
|
|
48
|
+
* that is about to be tested, i.e. possibly the state that breaks the boot. So a
|
|
49
|
+
* restore may only ever use a backup that HAS BOOTED: the known-good one.
|
|
50
|
+
*
|
|
51
|
+
* Two identities:
|
|
52
|
+
* 'snapshot' taken before a restart, not proven yet;
|
|
53
|
+
* 'known-good' a restart with this content really came up — or the seed taken
|
|
54
|
+
* on first install (nothing to prove yet, nothing to lose either).
|
|
55
|
+
*
|
|
56
|
+
* A directory without a readable meta.json is 'unknown': it was written before
|
|
57
|
+
* this rule existed and is NEVER used for an automatic rollback.
|
|
58
|
+
*/
|
|
59
|
+
export const META_NAME = 'meta.json';
|
|
60
|
+
export const KIND_SNAPSHOT = 'snapshot';
|
|
61
|
+
export const KIND_KNOWN_GOOD = 'known-good';
|
|
62
|
+
export const KIND_UNKNOWN = 'unknown';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Coerce anything into one of the three identities.
|
|
66
|
+
*
|
|
67
|
+
* @param {unknown} raw
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function normalizeKind(raw) {
|
|
71
|
+
if (raw === KIND_KNOWN_GOOD) return KIND_KNOWN_GOOD;
|
|
72
|
+
if (raw === KIND_SNAPSHOT) return KIND_SNAPSHOT;
|
|
73
|
+
return KIND_UNKNOWN;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read the identity of one backup directory.
|
|
78
|
+
*
|
|
79
|
+
* A missing or unreadable meta.json is NOT an error: it means the backup was
|
|
80
|
+
* written before the identity layer existed, and the answer is `'unknown'`.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} backupDir
|
|
83
|
+
* @returns {{ kind: string, at: number|null, version: string|null, bootId: string|null, requestId: number|null, reason: string|null, upgradedAt: number|null }}
|
|
84
|
+
*/
|
|
85
|
+
export function readBackupMeta(backupDir) {
|
|
86
|
+
const empty = {
|
|
87
|
+
kind: KIND_UNKNOWN,
|
|
88
|
+
at: null,
|
|
89
|
+
version: null,
|
|
90
|
+
bootId: null,
|
|
91
|
+
requestId: null,
|
|
92
|
+
reason: null,
|
|
93
|
+
upgradedAt: null,
|
|
94
|
+
};
|
|
95
|
+
let parsed = null;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(fs.readFileSync(path.join(String(backupDir ?? ''), META_NAME), 'utf8'));
|
|
98
|
+
} catch {
|
|
99
|
+
return empty;
|
|
100
|
+
}
|
|
101
|
+
if (!parsed || typeof parsed !== 'object') return empty;
|
|
102
|
+
return {
|
|
103
|
+
kind: normalizeKind(parsed.kind),
|
|
104
|
+
at: Number.isFinite(parsed.at) ? parsed.at : null,
|
|
105
|
+
version: typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : null,
|
|
106
|
+
bootId: typeof parsed.bootId === 'string' && parsed.bootId !== '' ? parsed.bootId : null,
|
|
107
|
+
requestId: Number.isFinite(parsed.requestId) ? parsed.requestId : null,
|
|
108
|
+
reason: typeof parsed.reason === 'string' && parsed.reason !== '' ? parsed.reason : null,
|
|
109
|
+
upgradedAt: Number.isFinite(parsed.upgradedAt) ? parsed.upgradedAt : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write the identity of one backup directory.
|
|
115
|
+
*
|
|
116
|
+
* Best effort: a backup whose meta cannot be written stays a usable `'unknown'`
|
|
117
|
+
* one (it is never used for an AUTOMATIC rollback, but it is still on disk).
|
|
118
|
+
*
|
|
119
|
+
* @param {string} backupDir
|
|
120
|
+
* @param {object} [meta]
|
|
121
|
+
* @returns {boolean} whether it was written
|
|
122
|
+
*/
|
|
123
|
+
export function writeBackupMeta(backupDir, meta = {}) {
|
|
124
|
+
const record = {
|
|
125
|
+
kind: normalizeKind(meta.kind),
|
|
126
|
+
at: Number.isFinite(meta.at) ? meta.at : Date.now(),
|
|
127
|
+
version: typeof meta.version === 'string' && meta.version !== '' ? meta.version : null,
|
|
128
|
+
bootId: typeof meta.bootId === 'string' && meta.bootId !== '' ? meta.bootId : null,
|
|
129
|
+
requestId: Number.isFinite(meta.requestId) ? meta.requestId : null,
|
|
130
|
+
reason: typeof meta.reason === 'string' && meta.reason !== '' ? meta.reason : null,
|
|
131
|
+
upgradedAt: Number.isFinite(meta.upgradedAt) ? meta.upgradedAt : null,
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(path.join(String(backupDir ?? ''), META_NAME), JSON.stringify(record, null, 2), 'utf8');
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* PROMOTE a snapshot to known-good — the restart that used it really came up.
|
|
143
|
+
*
|
|
144
|
+
* The CONTENT does not change: "it booted" IS the proof, and it is written where
|
|
145
|
+
* the next rollback looks for it.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} backupDir
|
|
148
|
+
* @param {{ reason?: string, version?: string|null, bootId?: string|null, now?: number }} [options]
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
export function markKnownGood(backupDir, { reason = 'boot-ok', version = null, bootId = null, now = Date.now() } = {}) {
|
|
152
|
+
const current = readBackupMeta(backupDir);
|
|
153
|
+
const manifest = readManifest(backupDir);
|
|
154
|
+
// Nothing to promote: neither an identity nor a manifest to stand on.
|
|
155
|
+
if (manifest === null && current.kind === KIND_UNKNOWN) return false;
|
|
156
|
+
return writeBackupMeta(backupDir, {
|
|
157
|
+
kind: KIND_KNOWN_GOOD,
|
|
158
|
+
at: current.at !== null ? current.at : (manifest && Number.isFinite(manifest.at) ? manifest.at : now),
|
|
159
|
+
version: version !== null && version !== undefined ? version : current.version,
|
|
160
|
+
bootId: bootId !== null && bootId !== undefined ? bootId : current.bootId,
|
|
161
|
+
requestId: current.requestId,
|
|
162
|
+
reason: current.kind === KIND_KNOWN_GOOD ? (current.reason || reason) : reason,
|
|
163
|
+
upgradedAt: now,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* THE NEWEST BACKUP THAT HAS PROVEN IT CAN BOOT — the only valid target of an
|
|
169
|
+
* automatic rollback.
|
|
170
|
+
*
|
|
171
|
+
* Backups without a meta.json (`'unknown'`) are skipped on purpose: they were
|
|
172
|
+
* written before the identity layer and were never proven.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} backupsDir the backups ROOT (the directory holding the copies)
|
|
175
|
+
* @returns {object|null}
|
|
176
|
+
*/
|
|
177
|
+
export function latestKnownGood(backupsDir) {
|
|
178
|
+
for (const entry of listBackupIdentities(backupsDir)) {
|
|
179
|
+
if (entry.kind === KIND_KNOWN_GOOD) return entry;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Every backup with its identity, newest first (what the settings page and the
|
|
186
|
+
* health route show: identity, time, size, version).
|
|
187
|
+
*
|
|
188
|
+
* @param {string} backupsDir the backups ROOT
|
|
189
|
+
* @returns {Array<object>}
|
|
190
|
+
*/
|
|
191
|
+
export function listBackupIdentities(backupsDir) {
|
|
192
|
+
return listBackups(backupsDir).map((backup) => {
|
|
193
|
+
const meta = readBackupMeta(backup.dir);
|
|
194
|
+
return {
|
|
195
|
+
...backup,
|
|
196
|
+
kind: meta.kind,
|
|
197
|
+
version: meta.version,
|
|
198
|
+
reason: meta.reason,
|
|
199
|
+
requestId: meta.requestId,
|
|
200
|
+
upgradedAt: meta.upgradedAt,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
36
204
|
|
|
37
205
|
/** The report the detached helper writes when it rolls a backup back. */
|
|
38
206
|
export const ROLLBACK_REPORT_NAME = 'last-rollback.json';
|
|
@@ -365,6 +533,10 @@ export function removeBackupDir(dir) {
|
|
|
365
533
|
/**
|
|
366
534
|
* Keep only the newest `maxBackups` backup directories.
|
|
367
535
|
*
|
|
536
|
+
* THE NEWEST KNOWN-GOOD BACKUP IS NEVER REMOVED (user decision, 2026-10-30): it
|
|
537
|
+
* is the only copy a failed boot may be rolled back to, so the "oldest first"
|
|
538
|
+
* order steps around it — even when it is the very last one left.
|
|
539
|
+
*
|
|
368
540
|
* @param {string} backupsDir
|
|
369
541
|
* @param {number} [maxBackups]
|
|
370
542
|
* @returns {string[]} the names that were removed
|
|
@@ -382,8 +554,24 @@ export function pruneBackups(backupsDir, maxBackups = DEFAULT_MAX_BACKUPS) {
|
|
|
382
554
|
} catch {
|
|
383
555
|
return [];
|
|
384
556
|
}
|
|
557
|
+
// THE NEWEST KNOWN-GOOD IS NEVER REMOVED: it is the only thing a failed boot
|
|
558
|
+
// can roll back to, so "the oldest first" has to step around it.
|
|
559
|
+
const protectedName = (() => {
|
|
560
|
+
for (const name of names.slice().reverse()) {
|
|
561
|
+
const meta = readBackupMeta(path.join(backupsDir, name));
|
|
562
|
+
if (meta.kind === KIND_KNOWN_GOOD) return name;
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
})();
|
|
566
|
+
const victims = [];
|
|
567
|
+
const budget = Math.max(0, names.length - limit);
|
|
568
|
+
for (const name of names) {
|
|
569
|
+
if (victims.length >= budget) break;
|
|
570
|
+
if (name === protectedName) continue;
|
|
571
|
+
victims.push(name);
|
|
572
|
+
}
|
|
385
573
|
const removed = [];
|
|
386
|
-
for (const name of
|
|
574
|
+
for (const name of victims) {
|
|
387
575
|
const target = path.join(backupsDir, name);
|
|
388
576
|
try {
|
|
389
577
|
fs.rmSync(target, { recursive: true, force: true });
|
|
@@ -473,11 +661,16 @@ export function readManifest(backupDir) {
|
|
|
473
661
|
* includePlugins?: boolean,
|
|
474
662
|
* maxBackups?: number,
|
|
475
663
|
* now?: number,
|
|
664
|
+
* kind?: string,
|
|
665
|
+
* reason?: string|null,
|
|
666
|
+
* version?: string|null,
|
|
667
|
+
* bootId?: string|null,
|
|
668
|
+
* requestId?: number|null,
|
|
476
669
|
* onProgress?: ((progress: { files: number, bytes: number, total: number, filesTotal: number }) => void)|null,
|
|
477
670
|
* shouldCancel?: (() => boolean)|null,
|
|
478
671
|
* measure?: ((profileDir: string, includePlugins: boolean) => { files: number, bytes: number })|null,
|
|
479
672
|
* }} options
|
|
480
|
-
* @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
673
|
+
* @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, kind: string, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
481
674
|
*/
|
|
482
675
|
export async function createBackup({
|
|
483
676
|
profileDir,
|
|
@@ -485,12 +678,20 @@ export async function createBackup({
|
|
|
485
678
|
includePlugins = true,
|
|
486
679
|
maxBackups = DEFAULT_MAX_BACKUPS,
|
|
487
680
|
now = Date.now(),
|
|
681
|
+
kind = KIND_SNAPSHOT,
|
|
682
|
+
reason = null,
|
|
683
|
+
version = null,
|
|
684
|
+
bootId = null,
|
|
685
|
+
requestId = null,
|
|
488
686
|
onProgress = null,
|
|
489
687
|
shouldCancel = null,
|
|
490
688
|
measure = null,
|
|
491
689
|
} = {}) {
|
|
492
690
|
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
493
691
|
const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
|
|
692
|
+
// A snapshot taken before a restart is NOT proven yet; only an explicit
|
|
693
|
+
// 'known-good' (the first-install seed) starts out proven.
|
|
694
|
+
const metaKind = kind === KIND_KNOWN_GOOD || kind === KIND_UNKNOWN ? kind : KIND_SNAPSHOT;
|
|
494
695
|
const startedAt = Date.now();
|
|
495
696
|
const base = {
|
|
496
697
|
ok: false,
|
|
@@ -499,6 +700,7 @@ export async function createBackup({
|
|
|
499
700
|
stamp: null,
|
|
500
701
|
at: null,
|
|
501
702
|
atLocal: null,
|
|
703
|
+
kind: metaKind,
|
|
502
704
|
durationMs: 0,
|
|
503
705
|
bytes: 0,
|
|
504
706
|
totalBytes: 0,
|
|
@@ -656,6 +858,18 @@ export async function createBackup({
|
|
|
656
858
|
return { ...base, stamp, files, bytes, totalBytes: measured.bytes, fileCount, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
|
|
657
859
|
}
|
|
658
860
|
|
|
861
|
+
// THE IDENTITY, right next to the manifest: what this copy is and where it
|
|
862
|
+
// came from. Best effort — a copy whose meta cannot be written stays an
|
|
863
|
+
// 'unknown' one on disk (never used for an automatic rollback).
|
|
864
|
+
writeBackupMeta(dir, {
|
|
865
|
+
kind: metaKind,
|
|
866
|
+
at: Number(now),
|
|
867
|
+
version,
|
|
868
|
+
bootId,
|
|
869
|
+
requestId,
|
|
870
|
+
reason,
|
|
871
|
+
});
|
|
872
|
+
|
|
659
873
|
const pruned = pruneBackups(root, maxBackups);
|
|
660
874
|
return {
|
|
661
875
|
...base,
|
|
@@ -664,6 +878,7 @@ export async function createBackup({
|
|
|
664
878
|
stamp,
|
|
665
879
|
at: Number(now),
|
|
666
880
|
atLocal: manifest.atLocal,
|
|
881
|
+
kind: metaKind,
|
|
667
882
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
668
883
|
bytes,
|
|
669
884
|
totalBytes: measured.bytes,
|
package/lib/checkpoint.js
CHANGED
|
@@ -59,6 +59,12 @@ const RESTART_KEYS = new Set([
|
|
|
59
59
|
'notBefore',
|
|
60
60
|
'requesters',
|
|
61
61
|
'parkGoals',
|
|
62
|
+
// THE SNAPSHOT THIS REQUEST ALREADY TOOK. It lives on the request, so it is
|
|
63
|
+
// dropped by itself when the request is withdrawn or carried out (the request
|
|
64
|
+
// object is replaced / removed), and it survives a deferral — which is the whole
|
|
65
|
+
// point: a 205 MB copy must not be repeated because a conversation interrupted
|
|
66
|
+
// the restart (user report, 2026-10-30).
|
|
67
|
+
'snapshot',
|
|
62
68
|
]);
|
|
63
69
|
|
|
64
70
|
const ENTRY_KEYS = new Set([
|
|
@@ -110,6 +116,9 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
110
116
|
'seenFeatureVersions',
|
|
111
117
|
'onboarding',
|
|
112
118
|
'sleep',
|
|
119
|
+
// The seed of the FIRST safe copy: what happened when a new host process
|
|
120
|
+
// looked for a known-good backup and found none (user decision, 2026-10-30).
|
|
121
|
+
'firstInstallBackup',
|
|
113
122
|
'updatedAt',
|
|
114
123
|
]);
|
|
115
124
|
|
|
@@ -228,6 +237,10 @@ function normalizeRestartPhase(raw, now) {
|
|
|
228
237
|
// that polls every 2 s would otherwise never learn that a backup happened
|
|
229
238
|
// (user report, 2026-10-29). null = nothing was measured.
|
|
230
239
|
snapshotBytes: isFiniteNumber(raw.snapshotBytes) ? Math.max(0, Math.trunc(raw.snapshotBytes)) : null,
|
|
240
|
+
// WHY the stage is what it is ("deferred: conversation-active", "snapshot
|
|
241
|
+
// done", …). A stage without a reason cannot be diagnosed after the fact —
|
|
242
|
+
// and the hang this fixes was exactly an unexplained leftover stage.
|
|
243
|
+
reason: typeof raw.reason === 'string' && raw.reason !== '' ? raw.reason : null,
|
|
231
244
|
blocked: isFiniteNumber(raw.blocked) ? Math.max(0, Math.trunc(raw.blocked)) : 0,
|
|
232
245
|
};
|
|
233
246
|
return { record, changed };
|
|
@@ -244,7 +257,16 @@ function normalizeRestartPhase(raw, now) {
|
|
|
244
257
|
* @param {{ phase: string, now?: number, blocked?: number, restartStage?: boolean }} options
|
|
245
258
|
* @returns {object|null} the recorded stage
|
|
246
259
|
*/
|
|
247
|
-
export function setRestartPhase(state, {
|
|
260
|
+
export function setRestartPhase(state, {
|
|
261
|
+
phase,
|
|
262
|
+
now = Date.now(),
|
|
263
|
+
blocked = 0,
|
|
264
|
+
restartStage = true,
|
|
265
|
+
reason = null,
|
|
266
|
+
// undefined = carry what the record already measured, null = forget it, a
|
|
267
|
+
// number = this is the size of the snapshot this restart already paid for.
|
|
268
|
+
snapshotBytes,
|
|
269
|
+
} = {}) {
|
|
248
270
|
if (!isObject(state)) return null;
|
|
249
271
|
if (!RESTART_PHASES.includes(phase)) return state.restartPhase ?? null;
|
|
250
272
|
const moment = isFiniteNumber(now) ? now : Date.now();
|
|
@@ -273,8 +295,14 @@ export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, r
|
|
|
273
295
|
? previous.progress
|
|
274
296
|
: null,
|
|
275
297
|
// A NEW restart starts with nothing measured; a stage change inside one
|
|
276
|
-
// restart keeps the size it already has
|
|
277
|
-
|
|
298
|
+
// restart keeps the size it already has — and a caller that knows the size
|
|
299
|
+
// (the request's own snapshot) may state it explicitly.
|
|
300
|
+
snapshotBytes: snapshotBytes === null
|
|
301
|
+
? null
|
|
302
|
+
: (isFiniteNumber(snapshotBytes) ? Math.max(0, Math.trunc(snapshotBytes)) : (sameRestart ? measuredBytes : null)),
|
|
303
|
+
// WHY this stage (see normalizeRestartPhase): "deferred: <reason>" is what
|
|
304
|
+
// tells a reader that the restart went back to waiting on purpose.
|
|
305
|
+
reason: typeof reason === 'string' && reason !== '' ? reason : null,
|
|
278
306
|
blocked: isFiniteNumber(blocked) ? Math.max(0, Math.trunc(blocked)) : 0,
|
|
279
307
|
};
|
|
280
308
|
state.restartPhase = record;
|
|
@@ -308,6 +336,7 @@ export function setRestartProgress(state, { files = 0, bytes = 0, total = 0, fil
|
|
|
308
336
|
// The live byte counter IS the size of the snapshot so far: keeping it here
|
|
309
337
|
// means the number survives the moment the copy ends.
|
|
310
338
|
snapshotBytes: number(bytes),
|
|
339
|
+
reason: previous !== null && typeof previous.reason === 'string' ? previous.reason : null,
|
|
311
340
|
blocked: previous !== null && isFiniteNumber(previous.blocked) ? previous.blocked : 0,
|
|
312
341
|
};
|
|
313
342
|
state.updatedAt = moment;
|
|
@@ -715,6 +744,31 @@ function normalizeRequesters(raw) {
|
|
|
715
744
|
* @param {unknown} raw
|
|
716
745
|
* @returns {{ restart: object|null, changed: boolean }}
|
|
717
746
|
*/
|
|
747
|
+
/**
|
|
748
|
+
* Coerce the "this request already took a snapshot" record.
|
|
749
|
+
*
|
|
750
|
+
* It is a pointer plus the numbers of that copy, and nothing else: the profile
|
|
751
|
+
* itself lives in the backup directory, which is checked against the manifest
|
|
752
|
+
* before it is used again.
|
|
753
|
+
*
|
|
754
|
+
* @param {unknown} raw
|
|
755
|
+
* @returns {{ requestedAt: number, dir: string, profileDir: string|null, files: number, bytes: number, total: number, at: number }|null}
|
|
756
|
+
*/
|
|
757
|
+
function normalizeRestartSnapshot(raw) {
|
|
758
|
+
if (!isObject(raw)) return null;
|
|
759
|
+
if (typeof raw.dir !== 'string' || raw.dir === '') return null;
|
|
760
|
+
const number = (value) => (isFiniteNumber(value) ? Math.max(0, Math.trunc(value)) : 0);
|
|
761
|
+
return {
|
|
762
|
+
requestedAt: number(raw.requestedAt),
|
|
763
|
+
dir: raw.dir,
|
|
764
|
+
profileDir: typeof raw.profileDir === 'string' && raw.profileDir !== '' ? raw.profileDir : null,
|
|
765
|
+
files: number(raw.files),
|
|
766
|
+
bytes: number(raw.bytes),
|
|
767
|
+
total: number(raw.total),
|
|
768
|
+
at: number(raw.at),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
718
772
|
function normalizeRestart(raw) {
|
|
719
773
|
if (!isObject(raw)) return { restart: null, changed: raw !== null && raw !== undefined };
|
|
720
774
|
let changed = false;
|
|
@@ -736,6 +790,11 @@ function normalizeRestart(raw) {
|
|
|
736
790
|
// never deadlock behind a goal that keeps its own agent busy.
|
|
737
791
|
parkGoals: raw.parkGoals !== false,
|
|
738
792
|
};
|
|
793
|
+
// The snapshot is only carried when there IS one: a checkpoint that never took
|
|
794
|
+
// a snapshot must normalize to exactly the object it was.
|
|
795
|
+
const snapshot = normalizeRestartSnapshot(raw.snapshot);
|
|
796
|
+
if (snapshot !== null) restart.snapshot = snapshot;
|
|
797
|
+
else if (raw.snapshot != null) changed = true;
|
|
739
798
|
if (!isFiniteNumber(raw.generation) || raw.generation < 0) changed = true;
|
|
740
799
|
if (!isFiniteNumber(raw.requestedAt)) changed = true;
|
|
741
800
|
if (!isFiniteNumber(raw.attempt) || raw.attempt < 0) changed = true;
|
|
@@ -845,9 +904,40 @@ export function normalizeState(raw, now = Date.now()) {
|
|
|
845
904
|
if (onboarding.changed) repaired = true;
|
|
846
905
|
}
|
|
847
906
|
|
|
907
|
+
// THE FIRST SAFE COPY: what happened when this machine looked for a
|
|
908
|
+
// known-good backup and found none. It survives a restart, so a machine with
|
|
909
|
+
// NO safe copy can say so instead of looking healthy.
|
|
910
|
+
if (raw.firstInstallBackup != null) {
|
|
911
|
+
const seeded = normalizeFirstInstallBackup(raw.firstInstallBackup);
|
|
912
|
+
if (seeded === null) repaired = true;
|
|
913
|
+
else state.firstInstallBackup = seeded;
|
|
914
|
+
}
|
|
915
|
+
|
|
848
916
|
return { state, repaired };
|
|
849
917
|
}
|
|
850
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Normalize the record of the first-install safe copy.
|
|
921
|
+
*
|
|
922
|
+
* A record without a `result` says nothing, so it is dropped (and the caller
|
|
923
|
+
* marks the state as repaired).
|
|
924
|
+
*
|
|
925
|
+
* @param {unknown} raw
|
|
926
|
+
* @returns {{ result: string, at: number|null, dir: string|null, bytes: number|null, error: string|null }|null}
|
|
927
|
+
*/
|
|
928
|
+
function normalizeFirstInstallBackup(raw) {
|
|
929
|
+
if (!isObject(raw)) return null;
|
|
930
|
+
const result = typeof raw.result === 'string' && raw.result !== '' ? raw.result : null;
|
|
931
|
+
if (result === null) return null;
|
|
932
|
+
return {
|
|
933
|
+
result,
|
|
934
|
+
at: isFiniteNumber(raw.at) ? raw.at : null,
|
|
935
|
+
dir: typeof raw.dir === 'string' && raw.dir !== '' ? raw.dir : null,
|
|
936
|
+
bytes: isFiniteNumber(raw.bytes) ? raw.bytes : null,
|
|
937
|
+
error: typeof raw.error === 'string' && raw.error !== '' ? raw.error : null,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
851
941
|
/**
|
|
852
942
|
* Read a checkpoint from disk. Never throws and never creates directories.
|
|
853
943
|
*
|
package/lib/restart-phase.js
CHANGED
|
@@ -163,6 +163,7 @@ export function describeRestartPhase({
|
|
|
163
163
|
progress = null,
|
|
164
164
|
blocking = null,
|
|
165
165
|
snapshotBytes = null,
|
|
166
|
+
reason = null,
|
|
166
167
|
now = Date.now(),
|
|
167
168
|
} = {}) {
|
|
168
169
|
const stage = isRestartPhase(phase) ? phase : 'idle';
|
|
@@ -299,6 +300,10 @@ export function describeRestartPhase({
|
|
|
299
300
|
snapshotBytes: snapshotBytes === null || snapshotBytes === undefined || snapshotBytes === ''
|
|
300
301
|
? null
|
|
301
302
|
: nonNegative(snapshotBytes),
|
|
303
|
+
// WHY the stage is what it is: "deferred: conversation-active" says the
|
|
304
|
+
// restart went back to waiting, which is what a reader needs to tell a
|
|
305
|
+
// postponed restart from a running one.
|
|
306
|
+
reason: typeof reason === 'string' && reason !== '' ? reason : null,
|
|
302
307
|
text,
|
|
303
308
|
};
|
|
304
309
|
}
|
package/locale/en.json
CHANGED
|
@@ -204,7 +204,24 @@
|
|
|
204
204
|
"time": "Backup time",
|
|
205
205
|
"size": "Backup size",
|
|
206
206
|
"withPlugins": "(plugins included)",
|
|
207
|
-
"withoutPlugins": "(plugins excluded)"
|
|
207
|
+
"withoutPlugins": "(plugins excluded)",
|
|
208
|
+
"kindLabel": "Identity",
|
|
209
|
+
"kind": {
|
|
210
|
+
"known-good": "Known good",
|
|
211
|
+
"snapshot": "This restart's snapshot",
|
|
212
|
+
"unknown": "Old backup (unverified)"
|
|
213
|
+
},
|
|
214
|
+
"version": "version {v}",
|
|
215
|
+
"identityHint": "A backup has one of two identities: ① “known good” — the copy a boot has proven; when something breaks, only a known-good copy is rolled back, and the first install creates one automatically. ② “this restart's snapshot” — taken right before every restart and not proven yet; old backups (unverified) and that snapshot are never rolled back automatically. At most {n} copies are kept together (the oldest goes first; a known-good copy is never deleted). A manual rollback asks for confirmation first, then restarts automatically.",
|
|
216
|
+
"rollback": "Roll back to this copy",
|
|
217
|
+
"rollbackConfirmTitle": "Roll back to this copy?",
|
|
218
|
+
"rollbackConfirmBody": "The backup from {time} will be restored over the current state — **what you have now is replaced** (a snapshot of it is taken first, so this rollback can be undone).",
|
|
219
|
+
"rollbackConfirmRestart": "A restart follows automatically; this page disconnects during it and works again afterwards.",
|
|
220
|
+
"rollbackConfirmYes": "Roll back",
|
|
221
|
+
"rollbackConfirmNo": "Cancel",
|
|
222
|
+
"rollbackBusy": "Rolling back…",
|
|
223
|
+
"rollbackDone": "Rolled back — restarting…",
|
|
224
|
+
"rollbackFailed": "The rollback did not work: {message}"
|
|
208
225
|
},
|
|
209
226
|
"backupPlugins": {
|
|
210
227
|
"label": "Include the installed plugins in the backup",
|
|
@@ -339,6 +356,9 @@
|
|
|
339
356
|
"size": "{done} / {total}",
|
|
340
357
|
"sizeDone": "{done}",
|
|
341
358
|
"restarting": "Restarting…",
|
|
359
|
+
"deferred": "A conversation just started — restart postponed",
|
|
360
|
+
"deferredNote": "It will retry automatically when idle · {count} waiting",
|
|
361
|
+
"deferredNoteZero": "It will retry automatically when idle",
|
|
342
362
|
"back": "Back again"
|
|
343
363
|
}
|
|
344
364
|
},
|
package/locale/zh.json
CHANGED
|
@@ -204,7 +204,24 @@
|
|
|
204
204
|
"time": "备份时间",
|
|
205
205
|
"size": "备份大小",
|
|
206
206
|
"withPlugins": "(含已装插件)",
|
|
207
|
-
"withoutPlugins": "(不含已装插件)"
|
|
207
|
+
"withoutPlugins": "(不含已装插件)",
|
|
208
|
+
"kindLabel": "身份",
|
|
209
|
+
"kind": {
|
|
210
|
+
"known-good": "安全份",
|
|
211
|
+
"snapshot": "本次快照",
|
|
212
|
+
"unknown": "旧备份(未验证)"
|
|
213
|
+
},
|
|
214
|
+
"version": "版本 {v}",
|
|
215
|
+
"identityHint": "备份有两种身份:①「安全份」——启动成功后验证过的那一份,出问题只会回滚安全份;第一次安装时会自动生成一份。②「本次快照」——每次重启前刚拍的,还没验证过;旧备份(未验证)和它都不会被自动回滚。两类合计最多保留 {n} 份,超出删最旧的那份(安全份不会被删)。手动回退会先二次确认,然后会自动重启。",
|
|
216
|
+
"rollback": "回退到这一份",
|
|
217
|
+
"rollbackConfirmTitle": "确定回退到这一份?",
|
|
218
|
+
"rollbackConfirmBody": "会把 {time} 的那份备份还原到现在——**当前状态会被它覆盖**(覆盖前会先自动存一份当前快照,所以这次回退还能再退回来)。",
|
|
219
|
+
"rollbackConfirmRestart": "还原完成后会自动重启一次;重启期间这个页面会断开,回来后接着用。",
|
|
220
|
+
"rollbackConfirmYes": "确认回退",
|
|
221
|
+
"rollbackConfirmNo": "取消",
|
|
222
|
+
"rollbackBusy": "正在回退…",
|
|
223
|
+
"rollbackDone": "已回退,正在重启…",
|
|
224
|
+
"rollbackFailed": "回退没成功:{message}"
|
|
208
225
|
},
|
|
209
226
|
"backupPlugins": {
|
|
210
227
|
"label": "备份包含已安装插件",
|
|
@@ -339,6 +356,9 @@
|
|
|
339
356
|
"size": "{done} / {total}",
|
|
340
357
|
"sizeDone": "{done}",
|
|
341
358
|
"restarting": "正在重启…",
|
|
359
|
+
"deferred": "有新会话在跑,重启已暂缓",
|
|
360
|
+
"deferredNote": "等它结束会自动继续 · 已等 {count} 个会话",
|
|
361
|
+
"deferredNoteZero": "等它结束会自动继续",
|
|
342
362
|
"back": "已回来"
|
|
343
363
|
}
|
|
344
364
|
},
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-autorestart",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "DeepSeek Harness plugin: restart
|
|
5
|
+
"description": "DeepSeek Harness plugin: idle-aware restart with session resume, a snapshot before every restart that is restored automatically if the host fails to come back, and a countdown shutdown; works on the Windows desktop client and on hosted dsh web (Windows, WSL, Linux). AI-generated, not human-reviewed: use with caution.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"dsh",
|