dsh-autorestart 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +329 -12
- 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 +5911 -3832
- package/cordis.patch.yml +37 -1
- package/docs/README.zh.md +219 -9
- package/index.js +2266 -193
- package/lib/access.js +212 -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/node-runtime.js +144 -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 -64
- package/locale/zh.json +247 -64
- 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
package/lib/blockers.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — ONE task count for every surface.
|
|
3
|
+
*
|
|
4
|
+
* A TASK is one conversation of the left sidebar: the session itself. A
|
|
5
|
+
* subagent belongs to the session that spawned it, so a session and its
|
|
6
|
+
* subagents are ONE task. The sidebar counts them once — so the bubble has
|
|
7
|
+
* to count them the same way.
|
|
8
|
+
*
|
|
9
|
+
* THE DEFECT THIS FIXES
|
|
10
|
+
*
|
|
11
|
+
* The real machine showed this:
|
|
12
|
+
*
|
|
13
|
+
* blockedBy: [1] session (父会话 A)
|
|
14
|
+
* [2] subagent (父 = A)
|
|
15
|
+
* [3] subagent (父 = B)
|
|
16
|
+
*
|
|
17
|
+
* The sidebar showed TWO busy conversations, and the bubble said "waiting for
|
|
18
|
+
* 3 tasks to finish". It counted every BLOCKER, so a parent session and its
|
|
19
|
+
* own subagent were counted twice.
|
|
20
|
+
*
|
|
21
|
+
* THE RULE (one rule, four surfaces)
|
|
22
|
+
*
|
|
23
|
+
* - a session is a task of its own: its key is its own session id;
|
|
24
|
+
* - a subagent belongs to the session that created it: its key is the
|
|
25
|
+
* PARENT's session id, so a parent and its subagents are ONE task;
|
|
26
|
+
* - a subagent whose parent is only known by its TITLE joins the session
|
|
27
|
+
* with that title (the shutdown card reports the title but no id);
|
|
28
|
+
* - a subagent with no known parent at all is a task of its own;
|
|
29
|
+
* - a pending restart is not a task: it is what the shutdown waits for.
|
|
30
|
+
*
|
|
31
|
+
* THE TWO HALVES
|
|
32
|
+
*
|
|
33
|
+
* The browser half of this rule lives in `client.js`: the client is a CLASSIC
|
|
34
|
+
* script and cannot import an ES module. The two implementations MUST agree,
|
|
35
|
+
* and `test/task-count.test.mjs` runs the same cases through both — so a
|
|
36
|
+
* change on one side that is not made on the other fails the suite.
|
|
37
|
+
*
|
|
38
|
+
* ESM only, Node builtins only.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** The prefix of the key of a family that is known by its session id. */
|
|
42
|
+
const SESSION_KEY = 'session:'
|
|
43
|
+
|
|
44
|
+
/** The prefix of the key of a family that is only known by its title. */
|
|
45
|
+
const TITLE_KEY = 'title:'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One trimmed string field of a blocker.
|
|
49
|
+
*
|
|
50
|
+
* @param {any} entry
|
|
51
|
+
* @param {string} key
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function field(entry, key) {
|
|
55
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
56
|
+
const value = entry[key]
|
|
57
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* WHAT a blocker is: a session, a subagent or a pending restart.
|
|
62
|
+
*
|
|
63
|
+
* The kind is NOT the only evidence: an entry that names a parent session
|
|
64
|
+
* (`parentSessionId` / `parentTitle`) IS a subagent, whatever the Host
|
|
65
|
+
* called it.
|
|
66
|
+
*
|
|
67
|
+
* @param {any} entry
|
|
68
|
+
* @returns {'session'|'subagent'|'restart-pending'}
|
|
69
|
+
*/
|
|
70
|
+
export function blockerKind(entry) {
|
|
71
|
+
if (!entry || typeof entry !== 'object') return 'session'
|
|
72
|
+
if (entry.kind === 'restart-pending') return 'restart-pending'
|
|
73
|
+
if (entry.kind === 'subagent') return 'subagent'
|
|
74
|
+
if (entry.kind === 'session') return 'session'
|
|
75
|
+
if (field(entry, 'parentSessionId') !== '' || field(entry, 'parentTitle') !== '') return 'subagent'
|
|
76
|
+
return 'session'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The session id of a blocker.
|
|
81
|
+
*
|
|
82
|
+
* @param {any} entry
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function sessionIdOf(entry) {
|
|
86
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
87
|
+
const value = entry.sessionId
|
|
88
|
+
return value == null ? '' : String(value).trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The title of the session a subagent belongs to.
|
|
93
|
+
*
|
|
94
|
+
* @param {any} entry
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function parentTitleOf(entry) {
|
|
98
|
+
return field(entry, 'parentTitle') || field(entry, 'label')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The title of a blocker (for a session: its own title).
|
|
103
|
+
*
|
|
104
|
+
* @param {any} entry
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function titleOf(entry) {
|
|
108
|
+
return field(entry, 'label') || field(entry, 'title')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The map "title of a session" → "session id" of the sessions in the list.
|
|
113
|
+
*
|
|
114
|
+
* It is the fallback for the surfaces that report the parent by its title
|
|
115
|
+
* only (the shutdown card): without it, the members of a team would become
|
|
116
|
+
* tasks of their own.
|
|
117
|
+
*
|
|
118
|
+
* @param {any[]} entries
|
|
119
|
+
* @returns {Map<string, string>}
|
|
120
|
+
*/
|
|
121
|
+
function sessionTitles(entries) {
|
|
122
|
+
const map = new Map()
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (!entry || typeof entry !== 'object') continue
|
|
125
|
+
if (blockerKind(entry) === 'subagent') continue
|
|
126
|
+
const title = titleOf(entry)
|
|
127
|
+
if (title === '') continue
|
|
128
|
+
const id = sessionIdOf(entry)
|
|
129
|
+
if (id === '') continue
|
|
130
|
+
if (!map.has(title)) map.set(title, id)
|
|
131
|
+
}
|
|
132
|
+
return map
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The key of the family every entry belongs to.
|
|
137
|
+
*
|
|
138
|
+
* One key per entry, in the order of the list ('' for an entry that is not a
|
|
139
|
+
* task at all).
|
|
140
|
+
*
|
|
141
|
+
* @param {any[]} entries the blockers the Host reported
|
|
142
|
+
* @returns {string[]}
|
|
143
|
+
*/
|
|
144
|
+
export function blockerTaskKeys(entries) {
|
|
145
|
+
const list = Array.isArray(entries) ? entries : []
|
|
146
|
+
const titles = sessionTitles(list)
|
|
147
|
+
const keys = []
|
|
148
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
149
|
+
const entry = list[index]
|
|
150
|
+
if (!entry || typeof entry !== 'object') {
|
|
151
|
+
keys.push('')
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
const kind = blockerKind(entry)
|
|
155
|
+
if (kind === 'restart-pending') {
|
|
156
|
+
// A pending restart is not a task: it is what the shutdown waits for.
|
|
157
|
+
keys.push('')
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
if (kind === 'subagent') {
|
|
161
|
+
const parentId = field(entry, 'parentSessionId')
|
|
162
|
+
if (parentId !== '') {
|
|
163
|
+
keys.push(SESSION_KEY + parentId)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
const parentTitle = parentTitleOf(entry)
|
|
167
|
+
if (parentTitle !== '') {
|
|
168
|
+
const known = titles.get(parentTitle)
|
|
169
|
+
keys.push(known ? SESSION_KEY + known : TITLE_KEY + parentTitle)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
const own = sessionIdOf(entry)
|
|
173
|
+
keys.push(own !== '' ? SESSION_KEY + own : 'entry:' + index)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
const own = sessionIdOf(entry)
|
|
177
|
+
keys.push(own !== '' ? SESSION_KEY + own : 'entry:' + index)
|
|
178
|
+
}
|
|
179
|
+
return keys
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* How many TASKS the blockers are.
|
|
184
|
+
*
|
|
185
|
+
* A parent session and its own subagents are ONE task: the same number the
|
|
186
|
+
* left sidebar shows.
|
|
187
|
+
*
|
|
188
|
+
* @param {any[]} entries the blockers the Host reported
|
|
189
|
+
* @returns {number}
|
|
190
|
+
*/
|
|
191
|
+
export function countBlockerTasks(entries) {
|
|
192
|
+
const keys = blockerTaskKeys(entries)
|
|
193
|
+
const families = new Set()
|
|
194
|
+
for (const key of keys) {
|
|
195
|
+
if (key !== '') families.add(key)
|
|
196
|
+
}
|
|
197
|
+
return families.size
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* How many of those tasks are carried by SUBAGENTS ONLY.
|
|
202
|
+
*
|
|
203
|
+
* A task whose session is idle and whose subagent works is a task of the
|
|
204
|
+
* subagent — that is what the card tells the user ("2 of them are
|
|
205
|
+
* subagents"). A task whose session works itself is not one of them, even
|
|
206
|
+
* when it has subagents of its own.
|
|
207
|
+
*
|
|
208
|
+
* @param {any[]} entries the blockers the Host reported
|
|
209
|
+
* @returns {number}
|
|
210
|
+
*/
|
|
211
|
+
export function countSubagentTasks(entries) {
|
|
212
|
+
const list = Array.isArray(entries) ? entries : []
|
|
213
|
+
const keys = blockerTaskKeys(list)
|
|
214
|
+
const families = new Map()
|
|
215
|
+
list.forEach((entry, index) => {
|
|
216
|
+
const key = keys[index]
|
|
217
|
+
if (key === '') return
|
|
218
|
+
const family = families.get(key) || { session: false, subagent: false }
|
|
219
|
+
if (blockerKind(entry) === 'subagent') family.subagent = true
|
|
220
|
+
else family.session = true
|
|
221
|
+
families.set(key, family)
|
|
222
|
+
})
|
|
223
|
+
let count = 0
|
|
224
|
+
for (const family of families.values()) {
|
|
225
|
+
if (family.subagent && !family.session) count += 1
|
|
226
|
+
}
|
|
227
|
+
return count
|
|
228
|
+
}
|
package/lib/checkpoint.js
CHANGED
|
@@ -106,6 +106,7 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
106
106
|
'parkedGoals',
|
|
107
107
|
'disarmedGoals',
|
|
108
108
|
'seenFeatureVersions',
|
|
109
|
+
'onboarding',
|
|
109
110
|
'sleep',
|
|
110
111
|
'updatedAt',
|
|
111
112
|
]);
|
|
@@ -175,11 +176,84 @@ export function createEmptyState(now = Date.now()) {
|
|
|
175
176
|
restart: null,
|
|
176
177
|
sessions: {},
|
|
177
178
|
seenFeatureVersions: {},
|
|
179
|
+
onboarding: null,
|
|
178
180
|
sleep: null,
|
|
179
181
|
updatedAt: now,
|
|
180
182
|
};
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Normalize the record of the one-time questions.
|
|
187
|
+
*
|
|
188
|
+
* The record is `{ feature, version, answeredAt, choice }`: WHICH question was
|
|
189
|
+
* answered (the id of the feature), the version of the question, when it was
|
|
190
|
+
* answered, and WHAT the user chose.
|
|
191
|
+
*
|
|
192
|
+
* A broken record is DROPPED: a question whose answer cannot be read is
|
|
193
|
+
* asked again — that is much better than a card that never comes back
|
|
194
|
+
* because of a half-written file.
|
|
195
|
+
*
|
|
196
|
+
* @param {unknown} raw
|
|
197
|
+
* @returns {{ onboarding: object|null, changed: boolean }}
|
|
198
|
+
*/
|
|
199
|
+
function normalizeOnboarding(raw) {
|
|
200
|
+
if (raw == null) return { onboarding: null, changed: false };
|
|
201
|
+
if (!isObject(raw)) return { onboarding: null, changed: true };
|
|
202
|
+
const feature = nullableString(raw.feature);
|
|
203
|
+
const version = nullableString(raw.version);
|
|
204
|
+
const choice = nullableString(raw.choice);
|
|
205
|
+
const answeredAt = nullableNumber(raw.answeredAt);
|
|
206
|
+
if (feature === null || version === null || choice === null || answeredAt === null) {
|
|
207
|
+
return { onboarding: null, changed: true };
|
|
208
|
+
}
|
|
209
|
+
// The values that were in effect BEFORE the answer: they are what the undo
|
|
210
|
+
// puts back.
|
|
211
|
+
const previous = isObject(raw.previous)
|
|
212
|
+
? {
|
|
213
|
+
restartMode: nullableString(raw.previous.restartMode),
|
|
214
|
+
restartCommand: nullableString(raw.previous.restartCommand),
|
|
215
|
+
}
|
|
216
|
+
: null;
|
|
217
|
+
return { onboarding: { feature, version, choice, answeredAt, previous }, changed: false };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Record the answer of a one-time question.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} state the checkpoint state (mutated in place)
|
|
224
|
+
* @param {{
|
|
225
|
+
* feature?: string,
|
|
226
|
+
* version?: string,
|
|
227
|
+
* choice?: string,
|
|
228
|
+
* previous?: { restartMode?: string, restartCommand?: string }|null,
|
|
229
|
+
* now?: number,
|
|
230
|
+
* }} answer
|
|
231
|
+
* @returns {object} the recorded answer
|
|
232
|
+
*/
|
|
233
|
+
export function recordOnboarding(state, {
|
|
234
|
+
feature = '',
|
|
235
|
+
version = '',
|
|
236
|
+
choice = '',
|
|
237
|
+
previous = null,
|
|
238
|
+
now = Date.now(),
|
|
239
|
+
} = {}) {
|
|
240
|
+
const record = {
|
|
241
|
+
feature: String(feature ?? ''),
|
|
242
|
+
version: String(version ?? ''),
|
|
243
|
+
choice: String(choice ?? ''),
|
|
244
|
+
answeredAt: Number.isFinite(Number(now)) ? Number(now) : Date.now(),
|
|
245
|
+
previous: previous && typeof previous === 'object'
|
|
246
|
+
? {
|
|
247
|
+
restartMode: typeof previous.restartMode === 'string' ? previous.restartMode : null,
|
|
248
|
+
restartCommand: typeof previous.restartCommand === 'string' ? previous.restartCommand : null,
|
|
249
|
+
}
|
|
250
|
+
: null,
|
|
251
|
+
};
|
|
252
|
+
if (!state || typeof state !== 'object') return record;
|
|
253
|
+
state.onboarding = record;
|
|
254
|
+
return record;
|
|
255
|
+
}
|
|
256
|
+
|
|
183
257
|
/**
|
|
184
258
|
* Normalize one persisted goal record. Unknown or wrong-typed fields are
|
|
185
259
|
* dropped; `{ goal: null }` means "no goal recorded".
|
|
@@ -610,6 +684,14 @@ export function normalizeState(raw, now = Date.now()) {
|
|
|
610
684
|
if (sleep.changed) repaired = true;
|
|
611
685
|
}
|
|
612
686
|
|
|
687
|
+
// The answer of the one-time questions: it has to survive a restart, so a
|
|
688
|
+
// question is never asked twice.
|
|
689
|
+
if (raw.onboarding != null) {
|
|
690
|
+
const onboarding = normalizeOnboarding(raw.onboarding);
|
|
691
|
+
state.onboarding = onboarding.onboarding;
|
|
692
|
+
if (onboarding.changed) repaired = true;
|
|
693
|
+
}
|
|
694
|
+
|
|
613
695
|
return { state, repaired };
|
|
614
696
|
}
|
|
615
697
|
|
|
@@ -1271,6 +1353,71 @@ export function cancelRestart(state, { now = Date.now() } = {}) {
|
|
|
1271
1353
|
return state;
|
|
1272
1354
|
}
|
|
1273
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* Withdraw ONE requester from a pending restart — "cancel MY restart".
|
|
1358
|
+
*
|
|
1359
|
+
* A restart request is COALESCED: several sessions may ask for the same
|
|
1360
|
+
* restart cycle, and every one of them is kept in `restart.requesters`. So
|
|
1361
|
+
* "withdraw the restart I asked for" must NOT take the other requesters
|
|
1362
|
+
* with it — that is how one session could cancel everybody's restart.
|
|
1363
|
+
*
|
|
1364
|
+
* The rules:
|
|
1365
|
+
*
|
|
1366
|
+
* - the session must be a requester; if it is not, NOTHING is changed
|
|
1367
|
+
* (`reason: 'not-a-requester'`) — never a silent "cancel everything";
|
|
1368
|
+
* - the session id is required (`reason: 'no-session-id'` when it is
|
|
1369
|
+
* missing) — it is never guessed;
|
|
1370
|
+
* - the last requester leaving really cancels the restart (`cancelled:
|
|
1371
|
+
* true`), with the same cleanup as {@link cancelRestart};
|
|
1372
|
+
* - while other requesters remain, the restart stays and
|
|
1373
|
+
* `restart.bySessionId` is re-pointed at the first of them when it
|
|
1374
|
+
* pointed at the session that just left.
|
|
1375
|
+
*
|
|
1376
|
+
* Mutates `state` in place and returns the outcome.
|
|
1377
|
+
*
|
|
1378
|
+
* @param {object} state
|
|
1379
|
+
* @param {{ sessionId?: string|null, now?: number }} options
|
|
1380
|
+
* @returns {{ removed: boolean, cancelled: boolean, remaining: number, requesters: string[], reason: string }}
|
|
1381
|
+
*/
|
|
1382
|
+
export function cancelOwnRestart(state, { sessionId = null, now = Date.now() } = {}) {
|
|
1383
|
+
const restart = isObject(state.restart) ? state.restart : null;
|
|
1384
|
+
if (!restart) {
|
|
1385
|
+
return { removed: false, cancelled: false, remaining: 0, requesters: [], reason: 'no-restart-pending' };
|
|
1386
|
+
}
|
|
1387
|
+
const requesters = normalizeRequesters(restart.requesters);
|
|
1388
|
+
const ids = requesters.map((entry) => entry.sessionId);
|
|
1389
|
+
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
1390
|
+
if (id === '') {
|
|
1391
|
+
return { removed: false, cancelled: false, remaining: ids.length, requesters: ids, reason: 'no-session-id' };
|
|
1392
|
+
}
|
|
1393
|
+
// The requester list is the truth. The legacy fallback covers a state file
|
|
1394
|
+
// that knows only `bySessionId` (no requesters at all).
|
|
1395
|
+
const inList = ids.includes(id);
|
|
1396
|
+
const legacyOnly = !inList && requesters.length === 0 && restart.bySessionId === id;
|
|
1397
|
+
if (!inList && !legacyOnly) {
|
|
1398
|
+
return { removed: false, cancelled: false, remaining: ids.length, requesters: ids, reason: 'not-a-requester' };
|
|
1399
|
+
}
|
|
1400
|
+
const kept = requesters.filter((entry) => entry.sessionId !== id);
|
|
1401
|
+
if (kept.length === 0) {
|
|
1402
|
+
// The LAST requester left: the restart really goes away.
|
|
1403
|
+
cancelRestart(state, { now });
|
|
1404
|
+
return { removed: true, cancelled: true, remaining: 0, requesters: [], reason: 'cancelled' };
|
|
1405
|
+
}
|
|
1406
|
+
// Other sessions are still waiting for this restart: it stays.
|
|
1407
|
+
restart.requesters = kept;
|
|
1408
|
+
if (restart.bySessionId === id) {
|
|
1409
|
+
restart.bySessionId = kept[0].sessionId;
|
|
1410
|
+
}
|
|
1411
|
+
state.updatedAt = now;
|
|
1412
|
+
return {
|
|
1413
|
+
removed: true,
|
|
1414
|
+
cancelled: false,
|
|
1415
|
+
remaining: kept.length,
|
|
1416
|
+
requesters: kept.map((entry) => entry.sessionId),
|
|
1417
|
+
reason: 'withdrawn',
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1274
1421
|
/**
|
|
1275
1422
|
* Record the shutdown intent: "switch the machine off when the work is done".
|
|
1276
1423
|
*
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — THE DIAGNOSTICS BEHIND "REPORT A PROBLEM".
|
|
3
|
+
*
|
|
4
|
+
* When something goes wrong, the user should be able to hand us the facts in
|
|
5
|
+
* one click — without writing anything down by hand and without leaking
|
|
6
|
+
* anything private.
|
|
7
|
+
*
|
|
8
|
+
* There is exactly ONE generator (`buildDiagnostics`): the host builds the
|
|
9
|
+
* text, the browser copies it. Two generators would drift, and a drifted
|
|
10
|
+
* diagnostic is worse than none.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IS IN IT: the plugin version, the platform, whether this is the
|
|
13
|
+
* desktop client, how the host was started (the mode AND the evidence the
|
|
14
|
+
* verdict rests on), who supervises it, the restart mode, and the verdict of
|
|
15
|
+
* the last restart (with the checks that failed).
|
|
16
|
+
*
|
|
17
|
+
* WHAT IS NEVER IN IT: the access token, a URL that carries a token, or any
|
|
18
|
+
* secret. The generator only copies the fields it knows, so a token cannot
|
|
19
|
+
* leak through a field nobody thought about — and everything that IS copied
|
|
20
|
+
* goes through the redaction again.
|
|
21
|
+
*
|
|
22
|
+
* ESM only, Node builtins only.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The mailbox that works when GitHub does not. */
|
|
26
|
+
export const SUPPORT_EMAIL = '252170229@qq.com'
|
|
27
|
+
|
|
28
|
+
/** Where the "new issue" page lives. */
|
|
29
|
+
export const ISSUE_URL_BASE = 'https://github.com/Eason-22/dsh-autorestart/issues/new'
|
|
30
|
+
|
|
31
|
+
/** The body of a pre-filled issue must stay short: URLs have a length limit. */
|
|
32
|
+
export const DIAGNOSTIC_BODY_LIMIT = 800
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The patterns that must never survive into a diagnostic.
|
|
36
|
+
*
|
|
37
|
+
* They cover the access token of the instance, the query parameters that
|
|
38
|
+
* carry it, and the usual "key: value" secrets.
|
|
39
|
+
*/
|
|
40
|
+
const SECRET_PATTERNS = Object.freeze([
|
|
41
|
+
/([?&](?:token|access[_-]?token|api[_-]?key|apikey|secret|password|auth|authorization)=)[^&\s"'<>]+/gi,
|
|
42
|
+
/("|')?(?:token|access[_-]?token|api[_-]?key|apikey|secret|password|authorization)("|')?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;}\]]+)/gi,
|
|
43
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi,
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Remove anything that looks like a secret from a piece of text.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} value
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function redactSecrets(value) {
|
|
53
|
+
let text = typeof value === 'string' ? value : String(value ?? '')
|
|
54
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
55
|
+
text = text.replace(pattern, '[redacted]')
|
|
56
|
+
}
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Shorten a path: the last four segments are kept (the user name may stay),
|
|
62
|
+
* everything before them is cut.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} value
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function truncatePath(value) {
|
|
68
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
69
|
+
if (text === '') return ''
|
|
70
|
+
const parts = text.split(/[\\/]+/).filter((part) => part !== '')
|
|
71
|
+
if (parts.length <= 4) return text
|
|
72
|
+
return '…/' + parts.slice(-4).join('/')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cut a text to a length limit (the issue body has to stay short).
|
|
77
|
+
*
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @param {number} [limit]
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function truncateBody(text, limit = DIAGNOSTIC_BODY_LIMIT) {
|
|
83
|
+
const source = typeof text === 'string' ? text : ''
|
|
84
|
+
const max = Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.trunc(Number(limit)) : DIAGNOSTIC_BODY_LIMIT
|
|
85
|
+
if (source.length <= max) return source
|
|
86
|
+
return source.slice(0, Math.max(0, max - 1)) + '…'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The pre-filled "new issue" URL.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} base
|
|
93
|
+
* @param {string} title
|
|
94
|
+
* @param {string} body
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function buildIssueUrl(base, title, body) {
|
|
98
|
+
const root = typeof base === 'string' && base.trim() !== '' ? base.trim() : ISSUE_URL_BASE
|
|
99
|
+
const safeTitle = truncateBody(redactSecrets(title), 200)
|
|
100
|
+
const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
|
|
101
|
+
return root + '?title=' + encodeURIComponent(safeTitle) + '&body=' + encodeURIComponent(safeBody)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The `mailto:` link (the fallback when there is no GitHub account).
|
|
106
|
+
*
|
|
107
|
+
* @param {string} email
|
|
108
|
+
* @param {string} subject
|
|
109
|
+
* @param {string} body
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function buildMailtoUrl(email, subject, body) {
|
|
113
|
+
const to = typeof email === 'string' && email.trim() !== '' ? email.trim() : SUPPORT_EMAIL
|
|
114
|
+
const safeSubject = truncateBody(redactSecrets(subject), 200)
|
|
115
|
+
const safeBody = truncateBody(redactSecrets(body), DIAGNOSTIC_BODY_LIMIT)
|
|
116
|
+
return 'mailto:' + to + '?subject=' + encodeURIComponent(safeSubject) + '&body=' + encodeURIComponent(safeBody)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read one field out of a payload, without ever throwing.
|
|
121
|
+
*
|
|
122
|
+
* @param {any} source
|
|
123
|
+
* @param {string} key
|
|
124
|
+
* @param {any} [fallback]
|
|
125
|
+
* @returns {any}
|
|
126
|
+
*/
|
|
127
|
+
function field(source, key, fallback = '') {
|
|
128
|
+
if (!source || typeof source !== 'object') return fallback
|
|
129
|
+
const value = source[key]
|
|
130
|
+
return value === undefined || value === null ? fallback : value
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The diagnostic text of this host.
|
|
135
|
+
*
|
|
136
|
+
* It is a PURE function of the payload: the same facts always produce the
|
|
137
|
+
* same text, and nothing outside the fields listed below can ever end up in
|
|
138
|
+
* it.
|
|
139
|
+
*
|
|
140
|
+
* @param {{
|
|
141
|
+
* version?: string,
|
|
142
|
+
* platform?: string,
|
|
143
|
+
* hostKind?: string,
|
|
144
|
+
* isDesktopApp?: boolean,
|
|
145
|
+
* startup?: { mode?: string, kind?: string, evidence?: string },
|
|
146
|
+
* supervisor?: { mode?: string, kind?: string },
|
|
147
|
+
* restartMode?: string,
|
|
148
|
+
* lastRestart?: { result?: string, failedChecks?: string[] },
|
|
149
|
+
* stateDir?: string,
|
|
150
|
+
* }} [payload]
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export function buildDiagnostics(payload = {}) {
|
|
154
|
+
const source = payload && typeof payload === 'object' ? payload : {}
|
|
155
|
+
const startup = field(source, 'startup', {})
|
|
156
|
+
const supervisor = field(source, 'supervisor', {})
|
|
157
|
+
const lastRestart = field(source, 'lastRestart', {})
|
|
158
|
+
const failedChecks = Array.isArray(field(lastRestart, 'failedChecks', []))
|
|
159
|
+
? field(lastRestart, 'failedChecks', []).map((check) => redactSecrets(String(check)))
|
|
160
|
+
: []
|
|
161
|
+
|
|
162
|
+
const lines = [
|
|
163
|
+
'--- dsh-autorestart diagnostics ---',
|
|
164
|
+
'plugin version: ' + redactSecrets(field(source, 'version', 'unknown')),
|
|
165
|
+
'platform: ' + redactSecrets(field(source, 'platform', 'unknown')),
|
|
166
|
+
'host kind: ' + redactSecrets(field(source, 'hostKind', 'unknown')),
|
|
167
|
+
'desktop client: ' + (field(source, 'isDesktopApp', false) === true ? 'yes' : 'no'),
|
|
168
|
+
'startup mode: ' + redactSecrets(field(startup, 'mode', 'unknown')),
|
|
169
|
+
'startup evidence: ' + redactSecrets(field(startup, 'evidence', 'unknown')),
|
|
170
|
+
'supervisor mode: ' + redactSecrets(field(supervisor, 'mode', 'unknown')),
|
|
171
|
+
'supervisor kind: ' + redactSecrets(field(supervisor, 'kind', 'unknown')),
|
|
172
|
+
'restart mode: ' + redactSecrets(field(source, 'restartMode', 'unknown')),
|
|
173
|
+
'last restart result: ' + redactSecrets(field(lastRestart, 'result', 'unknown')),
|
|
174
|
+
'last restart failed checks: ' + (failedChecks.length > 0 ? failedChecks.join(', ') : 'none'),
|
|
175
|
+
]
|
|
176
|
+
const stateDir = truncatePath(field(source, 'stateDir', ''))
|
|
177
|
+
if (stateDir !== '') lines.push('dsh home: ' + redactSecrets(stateDir))
|
|
178
|
+
lines.push('--- end ---')
|
|
179
|
+
return lines.join('\n')
|
|
180
|
+
}
|