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