dsh-autorestart 0.3.5 → 0.5.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 +208 -4
- package/client.js +2764 -82
- package/cordis.patch.yml +19 -0
- package/docs/README.zh.md +122 -7
- package/index.js +1318 -72
- package/lib/checkpoint.js +38 -0
- package/lib/sleep.js +828 -0
- package/locale/en.json +113 -0
- package/locale/zh.json +113 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -73,6 +73,71 @@ import {
|
|
|
73
73
|
runCommandNow,
|
|
74
74
|
scheduleDetachedRestart,
|
|
75
75
|
} from './lib/restart.js'
|
|
76
|
+
import {
|
|
77
|
+
createSleepController,
|
|
78
|
+
COUNTDOWN_CHOICES,
|
|
79
|
+
DEFAULT_COUNTDOWN_SECONDS,
|
|
80
|
+
detectShutdownPlan,
|
|
81
|
+
planInvocation,
|
|
82
|
+
runShutdown,
|
|
83
|
+
snapCountdownChoice,
|
|
84
|
+
} from './lib/sleep.js'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The version of this plugin, as a fallback.
|
|
88
|
+
*
|
|
89
|
+
* The real version is read from the plugin's own package.json (see
|
|
90
|
+
* `pluginVersion()`), but a bundled installation may not ship that file — so
|
|
91
|
+
* the constant below is the last resort. A test keeps the two in sync.
|
|
92
|
+
*/
|
|
93
|
+
const PLUGIN_VERSION_FALLBACK = '0.5.0'
|
|
94
|
+
|
|
95
|
+
/** The cached version of this plugin (read once per process). */
|
|
96
|
+
let pluginVersionCache = null
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The version of this plugin.
|
|
100
|
+
*
|
|
101
|
+
* It is the version of the package itself: the feature announcements are
|
|
102
|
+
* bound to it, so a new version of the plugin can announce a new feature (and
|
|
103
|
+
* the announcement of an old version never comes back).
|
|
104
|
+
*
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function pluginVersion() {
|
|
108
|
+
if (pluginVersionCache !== null) return pluginVersionCache
|
|
109
|
+
let version = PLUGIN_VERSION_FALLBACK
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')
|
|
112
|
+
const parsed = JSON.parse(raw)
|
|
113
|
+
if (parsed && typeof parsed.version === 'string' && parsed.version.trim() !== '') {
|
|
114
|
+
version = parsed.version.trim()
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// The package.json is not readable (a bundled build): the fallback is the
|
|
118
|
+
// version that was shipped with this build.
|
|
119
|
+
}
|
|
120
|
+
pluginVersionCache = version
|
|
121
|
+
return version
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The features that are announced ONCE, after an upgrade.
|
|
126
|
+
*
|
|
127
|
+
* The install prompt only ever reaches a fresh installation, so an
|
|
128
|
+
* installation that already existed would never learn about a new feature.
|
|
129
|
+
* Every entry of this list is therefore announced once per version: the
|
|
130
|
+
* announcement is shown until the user presses "知道了", and the Host records
|
|
131
|
+
* the version it has shown, so it never comes back.
|
|
132
|
+
*
|
|
133
|
+
* @returns {{ feature: string, version: string }[]}
|
|
134
|
+
*/
|
|
135
|
+
function featureAnnouncements() {
|
|
136
|
+
return [
|
|
137
|
+
// The graceful shutdown ("收工关机") and its moon button.
|
|
138
|
+
{ feature: 'sleep', version: pluginVersion() },
|
|
139
|
+
]
|
|
140
|
+
}
|
|
76
141
|
|
|
77
142
|
/**
|
|
78
143
|
* `@deepseek-ai/schemastery` resolves inside modules that live in the dsh installation,
|
|
@@ -173,6 +238,9 @@ export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt', 'ses
|
|
|
173
238
|
* the real schema and project defaults; `normalizeConfig` still re-applies the same
|
|
174
239
|
* defaults for direct `apply()` calls in tests.
|
|
175
240
|
*
|
|
241
|
+
* Every key carries a one-sentence description, so the settings page explains what
|
|
242
|
+
* each knob does instead of showing a bare name.
|
|
243
|
+
*
|
|
176
244
|
* Construction is guarded: a wrong/foreign schemastery fallback must never turn a
|
|
177
245
|
* missing schema into a failed plugin activation, and the coarsened `schemasterySource`
|
|
178
246
|
* is reported by the health route instead of an absolute install path.
|
|
@@ -181,38 +249,77 @@ function buildConfigSchema() {
|
|
|
181
249
|
if (schemastery === undefined) return undefined
|
|
182
250
|
try {
|
|
183
251
|
return schemastery.object({
|
|
184
|
-
enabled: schemastery.boolean().default(true)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
252
|
+
enabled: schemastery.boolean().default(true)
|
|
253
|
+
.description('Master switch: when false the plugin loads but does nothing.'),
|
|
254
|
+
stateFile: schemastery.string().default('')
|
|
255
|
+
.description('Absolute path of the durable checkpoint file; empty means <DSH_HOME>/storages/dsh-autorestart/state.json.'),
|
|
256
|
+
resumeOnBoot: schemastery.boolean().default(true)
|
|
257
|
+
.description('Resume interrupted sessions automatically after the host starts again.'),
|
|
258
|
+
recoveryDelayMs: schemastery.number().min(0).default(6000)
|
|
259
|
+
.description('Milliseconds the plugin waits after boot before it recovers interrupted sessions.'),
|
|
260
|
+
maxSessions: schemastery.number().step(1).min(1).default(4)
|
|
261
|
+
.description('How many interrupted sessions one recovery pass resumes.'),
|
|
262
|
+
maxResumeAttempts: schemastery.number().step(1).min(1).default(2)
|
|
263
|
+
.description('How often a single session is retried before the recovery gives up on it.'),
|
|
264
|
+
deferPollMs: schemastery.number().min(250).default(3000)
|
|
265
|
+
.description('Milliseconds between two restart-gate checks.'),
|
|
266
|
+
deferSoftMs: schemastery.number().min(0).default(300000)
|
|
267
|
+
.description('Milliseconds after which a pending restart is announced as waiting for a long time.'),
|
|
268
|
+
deferHardMs: schemastery.number().min(0).default(900000)
|
|
269
|
+
.description('Milliseconds after which a pending restart gives up, unless forceRestartWhenBusy is enabled.'),
|
|
270
|
+
deferPolicy: schemastery.union(['auto', 'wait']).default('auto')
|
|
271
|
+
.description('How a pending restart treats a busy conversation: auto waits for it, wait blocks until every conversation is idle.'),
|
|
194
272
|
// hard deadline only forces a restart over a running conversation when enabled
|
|
195
|
-
forceRestartWhenBusy: schemastery.boolean().default(false)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
273
|
+
forceRestartWhenBusy: schemastery.boolean().default(false)
|
|
274
|
+
.description('Let the hard deadline restart the host even while a conversation is still running.'),
|
|
275
|
+
restartMode: schemastery.union(['app', 'command']).default('app')
|
|
276
|
+
.description('How the host is restarted: app relaunches the desktop app, command runs restartCommand.'),
|
|
277
|
+
restartCommand: schemastery.string().default('')
|
|
278
|
+
.description('Shell command used when restartMode is "command"; empty means there is no custom command.'),
|
|
279
|
+
relaunchDelaySeconds: schemastery.number().min(0).default(2)
|
|
280
|
+
.description('Seconds between the kill of the old host and the relaunch of the new one.'),
|
|
199
281
|
// quiet window that must pass after the last conversation activity
|
|
200
|
-
settleMs: schemastery.number().min(0).default(2000)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
282
|
+
settleMs: schemastery.number().min(0).default(2000)
|
|
283
|
+
.description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
|
|
284
|
+
autoReloadPage: schemastery.boolean().default(true)
|
|
285
|
+
.description('Reload the web page automatically when the host boot token changes.'),
|
|
286
|
+
blockManualRestart: schemastery.boolean().default(true)
|
|
287
|
+
.description('Block shell commands that would kill or relaunch DeepSeek Harness by hand.'),
|
|
288
|
+
healthPath: schemastery.string().default('/__dsh_autorestart/health')
|
|
289
|
+
.description('HTTP path of the read-only health route used by the client UI and by verification scripts.'),
|
|
290
|
+
continuationText: schemastery.string().default('')
|
|
291
|
+
.description('Custom text injected as the continuation message; empty means the built-in notice.'),
|
|
205
292
|
// pause between two recovery batches (each batch resumes maxSessions sessions)
|
|
206
|
-
recoveryBatchDelayMs: schemastery.number().min(0).default(1200)
|
|
293
|
+
recoveryBatchDelayMs: schemastery.number().min(0).default(1200)
|
|
294
|
+
.description('Milliseconds between two recovery batches.'),
|
|
207
295
|
// pause between two resolveAgent attempts inside one boot
|
|
208
|
-
resumeRetryDelayMs: schemastery.number().min(0).default(1000)
|
|
296
|
+
resumeRetryDelayMs: schemastery.number().min(0).default(1000)
|
|
297
|
+
.description('Milliseconds between two resolveAgent attempts inside one boot.'),
|
|
209
298
|
// pre-restart snapshot + automatic rollback when the host never comes back
|
|
210
|
-
backupOnRestart: schemastery.boolean().default(true)
|
|
299
|
+
backupOnRestart: schemastery.boolean().default(true)
|
|
300
|
+
.description('Take a snapshot of the profile before every restart, so a host that cannot boot again is rolled back automatically.'),
|
|
211
301
|
// include the installed plugin tree in the snapshot (bigger, but it makes a
|
|
212
302
|
// rollback of "a plugin upgrade broke the boot" possible)
|
|
213
|
-
backupIncludePlugins: schemastery.boolean().default(true)
|
|
214
|
-
|
|
215
|
-
|
|
303
|
+
backupIncludePlugins: schemastery.boolean().default(true)
|
|
304
|
+
.description('Include the installed plugin tree in that snapshot: about 117 MB and roughly 3 extra seconds, but it is what makes a rollback of a broken plugin upgrade possible.'),
|
|
305
|
+
maxBackups: schemastery.number().step(1).min(1).default(2)
|
|
306
|
+
.description('How many snapshots are kept; older ones are deleted automatically.'),
|
|
307
|
+
rollbackTimeoutMs: schemastery.number().min(1000).default(90000)
|
|
308
|
+
.description('Milliseconds the detached watcher waits for the relaunched host before it restores the snapshot.'),
|
|
309
|
+
// graceful shutdown ("收工关机"): wait for every conversation, count down, then
|
|
310
|
+
// switch the REAL host machine off (a WSL host shuts Windows down, not the distro)
|
|
311
|
+
showSleepButton: schemastery.boolean().default(true)
|
|
312
|
+
.description('是否在左侧栏最底部、设置按钮旁边显示「收工关机」按钮:关掉之后界面上不再有这个按钮,关机接口也不再被界面调用。'),
|
|
313
|
+
// The countdown length is a DROPDOWN: a union of the offered choices
|
|
314
|
+
// projects into an enum (the settings page renders a select, not a
|
|
315
|
+
// number field). `loose()` keeps an old profile value from breaking
|
|
316
|
+
// the boot — the value is snapped onto a choice in normalizeConfig.
|
|
317
|
+
sleepCountdownSeconds: schemastery.union(COUNTDOWN_CHOICES.map((value) => schemastery.const(value)))
|
|
318
|
+
.default(DEFAULT_COUNTDOWN_SECONDS)
|
|
319
|
+
.loose()
|
|
320
|
+
.description('收工关机的倒计时秒数(下拉框:0 / 10 / 20 / 30 / 60 秒,默认 20;0 表示不倒数、立即关机,但仍会先等所有会话收工)。'),
|
|
321
|
+
sleepCommand: schemastery.string().default('')
|
|
322
|
+
.description('自定义关机命令:留空表示按系统自动识别(Windows / WSL / Linux / macOS),填了就用这条命令代替自动识别。'),
|
|
216
323
|
})
|
|
217
324
|
} catch (error) {
|
|
218
325
|
schemasterySource = `construction-failed: ${String(error)}`
|
|
@@ -252,6 +359,12 @@ const DEFAULTS = {
|
|
|
252
359
|
backupIncludePlugins: true,
|
|
253
360
|
maxBackups: 2,
|
|
254
361
|
rollbackTimeoutMs: 90000,
|
|
362
|
+
// Graceful shutdown ("收工关机"): the button at the foot of the left sidebar,
|
|
363
|
+
// the countdown before the machine is switched off and the optional custom
|
|
364
|
+
// shutdown command (empty = the host is detected automatically).
|
|
365
|
+
showSleepButton: true,
|
|
366
|
+
sleepCountdownSeconds: DEFAULT_COUNTDOWN_SECONDS,
|
|
367
|
+
sleepCommand: '',
|
|
255
368
|
}
|
|
256
369
|
|
|
257
370
|
/** Merge a loader row config over {@link DEFAULTS}, dropping wrong-typed values. */
|
|
@@ -276,6 +389,12 @@ function normalizeConfig(raw) {
|
|
|
276
389
|
const rollbackTimeout = Number(config.rollbackTimeoutMs)
|
|
277
390
|
config.rollbackTimeoutMs =
|
|
278
391
|
Number.isFinite(rollbackTimeout) && rollbackTimeout >= 1000 ? Math.trunc(rollbackTimeout) : 90000
|
|
392
|
+
// The countdown of the graceful shutdown is one of the offered choices: a
|
|
393
|
+
// profile that still carries an old value (15, 45, …) is snapped onto the
|
|
394
|
+
// nearest one instead of breaking, and 0 means "no countdown at all".
|
|
395
|
+
config.sleepCountdownSeconds = snapCountdownChoice(config.sleepCountdownSeconds)
|
|
396
|
+
if (typeof config.sleepCommand !== 'string') config.sleepCommand = ''
|
|
397
|
+
if (typeof config.showSleepButton !== 'boolean') config.showSleepButton = true
|
|
279
398
|
return config
|
|
280
399
|
}
|
|
281
400
|
|
|
@@ -328,6 +447,50 @@ function resolveHandoffPath(config) {
|
|
|
328
447
|
return `${prefix}/handoff`
|
|
329
448
|
}
|
|
330
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Resolve the choice-route path: a sibling of the health route, under the same
|
|
452
|
+
* prefix (`POST /choice` persists the one-time install choice of the user).
|
|
453
|
+
*
|
|
454
|
+
* @param {object} config
|
|
455
|
+
* @returns {string}
|
|
456
|
+
*/
|
|
457
|
+
function resolveChoicePath(config) {
|
|
458
|
+
const healthPath = resolveHealthPath(config)
|
|
459
|
+
const index = healthPath.lastIndexOf('/')
|
|
460
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
461
|
+
return `${prefix}/choice`
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Resolve the sleep-route path: a sibling of the health route, under the same
|
|
466
|
+
* prefix (`GET /sleep` reports the state of the graceful sleep, `POST /sleep`
|
|
467
|
+
* arms or cancels it).
|
|
468
|
+
*
|
|
469
|
+
* @param {object} config
|
|
470
|
+
* @returns {string}
|
|
471
|
+
*/
|
|
472
|
+
function resolveSleepPath(config) {
|
|
473
|
+
const healthPath = resolveHealthPath(config)
|
|
474
|
+
const index = healthPath.lastIndexOf('/')
|
|
475
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
476
|
+
return `${prefix}/sleep`
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Resolve the announcements-route path: a sibling of the health route, under
|
|
481
|
+
* the same prefix (`GET /announcements` reports the feature announcements the
|
|
482
|
+
* user has not seen yet, `POST /announcements` records that it saw one).
|
|
483
|
+
*
|
|
484
|
+
* @param {object} config
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
function resolveAnnouncementsPath(config) {
|
|
488
|
+
const healthPath = resolveHealthPath(config)
|
|
489
|
+
const index = healthPath.lastIndexOf('/')
|
|
490
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
491
|
+
return `${prefix}/announcements`
|
|
492
|
+
}
|
|
493
|
+
|
|
331
494
|
/**
|
|
332
495
|
* The blocking sessions with the reason each one blocks: a session that is
|
|
333
496
|
* held by an armed goal reports `goal-active` (that goal can be disarmed), a
|
|
@@ -484,6 +647,157 @@ function resolveStateFile(config) {
|
|
|
484
647
|
return path.join(home, 'storages', 'dsh-autorestart', 'state.json')
|
|
485
648
|
}
|
|
486
649
|
|
|
650
|
+
/**
|
|
651
|
+
* The directory the plugin owns its durable state in.
|
|
652
|
+
*
|
|
653
|
+
* It is the directory of the checkpoint file, so a custom `stateFile` moves the
|
|
654
|
+
* whole state directory with it (the backups live next to it as well).
|
|
655
|
+
*
|
|
656
|
+
* @param {object} config
|
|
657
|
+
* @returns {string}
|
|
658
|
+
*/
|
|
659
|
+
function resolveStateDir(config) {
|
|
660
|
+
return path.dirname(resolveStateFile(config))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The file the DEGRADED choice path writes to.
|
|
665
|
+
*
|
|
666
|
+
* It is only used when the official `configEditor` service is unavailable: the
|
|
667
|
+
* profile configuration stays the single source of truth, and this file is the
|
|
668
|
+
* last-resort record of a choice the Host could not persist yet.
|
|
669
|
+
*/
|
|
670
|
+
const INSTALL_CHOICE_FILE = 'install-choice.json'
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Read the recorded install choice from the fallback file.
|
|
674
|
+
*
|
|
675
|
+
* @param {string} stateDir
|
|
676
|
+
* @returns {{ includePlugins: boolean|null, backupOnRestart: boolean|null, showSleepButton: boolean|null, sleepCountdownSeconds: number|null }|null}
|
|
677
|
+
*/
|
|
678
|
+
function readInstallChoice(stateDir) {
|
|
679
|
+
try {
|
|
680
|
+
const raw = fs.readFileSync(path.join(stateDir, INSTALL_CHOICE_FILE), 'utf8')
|
|
681
|
+
const parsed = JSON.parse(raw)
|
|
682
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
683
|
+
return {
|
|
684
|
+
includePlugins: typeof parsed.includePlugins === 'boolean' ? parsed.includePlugins : null,
|
|
685
|
+
backupOnRestart: typeof parsed.backupOnRestart === 'boolean' ? parsed.backupOnRestart : null,
|
|
686
|
+
showSleepButton: typeof parsed.showSleepButton === 'boolean' ? parsed.showSleepButton : null,
|
|
687
|
+
sleepCountdownSeconds: typeof parsed.sleepCountdownSeconds === 'number'
|
|
688
|
+
? parsed.sleepCountdownSeconds
|
|
689
|
+
: null,
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
return null
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Write the install choice into the fallback file.
|
|
698
|
+
*
|
|
699
|
+
* The file is written ADDITIVELY: a choice about the sleep button must not
|
|
700
|
+
* erase the recorded backup scope (and the other way round).
|
|
701
|
+
*
|
|
702
|
+
* @param {string} stateDir
|
|
703
|
+
* @param {{ includePlugins?: boolean, backupOnRestart?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} values
|
|
704
|
+
* @returns {string} the file that was written
|
|
705
|
+
*/
|
|
706
|
+
function writeInstallChoice(stateDir, values) {
|
|
707
|
+
const file = path.join(stateDir, INSTALL_CHOICE_FILE)
|
|
708
|
+
const current = readInstallChoice(stateDir) || {
|
|
709
|
+
includePlugins: null,
|
|
710
|
+
backupOnRestart: null,
|
|
711
|
+
showSleepButton: null,
|
|
712
|
+
sleepCountdownSeconds: null,
|
|
713
|
+
}
|
|
714
|
+
const payload = {
|
|
715
|
+
includePlugins: typeof values.includePlugins === 'boolean' ? values.includePlugins : current.includePlugins,
|
|
716
|
+
backupOnRestart: typeof values.backupOnRestart === 'boolean' ? values.backupOnRestart : current.backupOnRestart,
|
|
717
|
+
showSleepButton: typeof values.showSleepButton === 'boolean' ? values.showSleepButton : current.showSleepButton,
|
|
718
|
+
sleepCountdownSeconds: typeof values.sleepCountdownSeconds === 'number'
|
|
719
|
+
? values.sleepCountdownSeconds
|
|
720
|
+
: current.sleepCountdownSeconds,
|
|
721
|
+
at: new Date().toISOString(),
|
|
722
|
+
}
|
|
723
|
+
fs.mkdirSync(stateDir, { recursive: true })
|
|
724
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
|
|
725
|
+
return file
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Resolve the effective `backupIncludePlugins` value and where it came from.
|
|
730
|
+
*
|
|
731
|
+
* The profile row config always wins: it is the single source of truth (the
|
|
732
|
+
* settings page and the one-time install prompt write the same key). Only when
|
|
733
|
+
* the row config does not set the key at all does the degraded fallback file
|
|
734
|
+
* get a say, and only when it does not exist does the built-in default apply.
|
|
735
|
+
*
|
|
736
|
+
* @param {object|null|undefined} rawConfig the loader row config, as received
|
|
737
|
+
* @param {string} stateDir
|
|
738
|
+
* @returns {{ includePlugins: boolean, source: 'config'|'fallback-file'|'default' }}
|
|
739
|
+
*/
|
|
740
|
+
export function resolveBackupIncludePlugins(rawConfig, stateDir) {
|
|
741
|
+
if (rawConfig && typeof rawConfig === 'object'
|
|
742
|
+
&& Object.prototype.hasOwnProperty.call(rawConfig, 'backupIncludePlugins')
|
|
743
|
+
&& typeof rawConfig.backupIncludePlugins === 'boolean') {
|
|
744
|
+
return { includePlugins: rawConfig.backupIncludePlugins, source: 'config' }
|
|
745
|
+
}
|
|
746
|
+
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
747
|
+
if (fallback !== null && fallback.includePlugins !== null) {
|
|
748
|
+
return { includePlugins: fallback.includePlugins, source: 'fallback-file' }
|
|
749
|
+
}
|
|
750
|
+
return { includePlugins: DEFAULTS.backupIncludePlugins, source: 'default' }
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Resolve the effective `backupOnRestart` value and where it came from.
|
|
755
|
+
*
|
|
756
|
+
* The same rule as the backup scope: the profile row config wins, the degraded
|
|
757
|
+
* fallback file fills the gap, and the built-in default applies when neither
|
|
758
|
+
* says anything.
|
|
759
|
+
*
|
|
760
|
+
* @param {object|null|undefined} rawConfig the loader row config, as received
|
|
761
|
+
* @param {string} stateDir
|
|
762
|
+
* @returns {{ backupOnRestart: boolean, source: 'config'|'fallback-file'|'default' }}
|
|
763
|
+
*/
|
|
764
|
+
export function resolveBackupOnRestart(rawConfig, stateDir) {
|
|
765
|
+
if (rawConfig && typeof rawConfig === 'object'
|
|
766
|
+
&& Object.prototype.hasOwnProperty.call(rawConfig, 'backupOnRestart')
|
|
767
|
+
&& typeof rawConfig.backupOnRestart === 'boolean') {
|
|
768
|
+
return { backupOnRestart: rawConfig.backupOnRestart, source: 'config' }
|
|
769
|
+
}
|
|
770
|
+
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
771
|
+
if (fallback !== null && fallback.backupOnRestart !== null) {
|
|
772
|
+
return { backupOnRestart: fallback.backupOnRestart, source: 'fallback-file' }
|
|
773
|
+
}
|
|
774
|
+
return { backupOnRestart: DEFAULTS.backupOnRestart, source: 'default' }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Resolve the effective `showSleepButton` value and where it came from.
|
|
779
|
+
*
|
|
780
|
+
* The same rule as the backup scope: the profile row config wins, the degraded
|
|
781
|
+
* fallback file fills the gap, and the built-in default applies when neither
|
|
782
|
+
* says anything.
|
|
783
|
+
*
|
|
784
|
+
* @param {object|null|undefined} rawConfig the loader row config, as received
|
|
785
|
+
* @param {string} stateDir
|
|
786
|
+
* @returns {{ showSleepButton: boolean, source: 'config'|'fallback-file'|'default' }}
|
|
787
|
+
*/
|
|
788
|
+
export function resolveShowSleepButton(rawConfig, stateDir) {
|
|
789
|
+
if (rawConfig && typeof rawConfig === 'object'
|
|
790
|
+
&& Object.prototype.hasOwnProperty.call(rawConfig, 'showSleepButton')
|
|
791
|
+
&& typeof rawConfig.showSleepButton === 'boolean') {
|
|
792
|
+
return { showSleepButton: rawConfig.showSleepButton, source: 'config' }
|
|
793
|
+
}
|
|
794
|
+
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
795
|
+
if (fallback !== null && fallback.showSleepButton !== null) {
|
|
796
|
+
return { showSleepButton: fallback.showSleepButton, source: 'fallback-file' }
|
|
797
|
+
}
|
|
798
|
+
return { showSleepButton: DEFAULTS.showSleepButton, source: 'default' }
|
|
799
|
+
}
|
|
800
|
+
|
|
487
801
|
/**
|
|
488
802
|
* Resolve the profile directory the running host was started from.
|
|
489
803
|
*
|
|
@@ -553,6 +867,76 @@ function resolveProfileDir() {
|
|
|
553
867
|
return resolveProfileDirDetailed().dir
|
|
554
868
|
}
|
|
555
869
|
|
|
870
|
+
/**
|
|
871
|
+
* The pre-restart snapshot: copy the profile into `<stateDir>/backups/<stamp>/`
|
|
872
|
+
* and clear the rollback report an older boot may have left behind.
|
|
873
|
+
*
|
|
874
|
+
* This is the ONE backup entry of the plugin. EVERY restart mode takes its
|
|
875
|
+
* snapshot through this function — the desktop app relaunch AND the custom
|
|
876
|
+
* restart command — so a hosted host that is restarted by a command is just as
|
|
877
|
+
* recoverable as the desktop app. The configuration files decide whether every
|
|
878
|
+
* bundle loads, so a snapshot is what makes a "the host does not come back"
|
|
879
|
+
* rollback possible at all.
|
|
880
|
+
*
|
|
881
|
+
* A failing backup never blocks the restart: the reason is logged and the
|
|
882
|
+
* caller continues without a rollback point.
|
|
883
|
+
*
|
|
884
|
+
* @param {{
|
|
885
|
+
* config?: object,
|
|
886
|
+
* stateDir?: string,
|
|
887
|
+
* log?: { warn: Function },
|
|
888
|
+
* profile?: { dir?: string|null, source?: string },
|
|
889
|
+
* createBackup?: Function,
|
|
890
|
+
* }} [options]
|
|
891
|
+
* @returns {{ backup: object|null, profileDir: string|null, profile: { dir: string|null, source: string } }}
|
|
892
|
+
*/
|
|
893
|
+
export function takePreRestartSnapshot({
|
|
894
|
+
config = {},
|
|
895
|
+
stateDir = '',
|
|
896
|
+
log = console,
|
|
897
|
+
profile = null,
|
|
898
|
+
createBackup: createBackupFn = createBackup,
|
|
899
|
+
} = {}) {
|
|
900
|
+
const info = profile && typeof profile === 'object'
|
|
901
|
+
? { dir: profile.dir ?? null, source: profile.source ?? 'unknown' }
|
|
902
|
+
: { dir: null, source: 'unknown' }
|
|
903
|
+
const profileDir = typeof info.dir === 'string' && info.dir.trim() !== '' ? info.dir : null
|
|
904
|
+
// The snapshot is optional: the operator can turn it off entirely.
|
|
905
|
+
if (config.backupOnRestart !== true) return { backup: null, profileDir, profile: info }
|
|
906
|
+
if (profileDir === null) {
|
|
907
|
+
log.warn('no profile directory is known; skipping the pre-restart backup', { source: info.source })
|
|
908
|
+
return { backup: null, profileDir: null, profile: info }
|
|
909
|
+
}
|
|
910
|
+
let backup = null
|
|
911
|
+
try {
|
|
912
|
+
backup = createBackupFn({
|
|
913
|
+
profileDir,
|
|
914
|
+
backupsDir: backupsRoot(stateDir),
|
|
915
|
+
includePlugins: config.backupIncludePlugins !== false,
|
|
916
|
+
maxBackups: config.maxBackups,
|
|
917
|
+
})
|
|
918
|
+
} catch (error) {
|
|
919
|
+
// A backup that throws must not take the restart down with it.
|
|
920
|
+
backup = { ok: false, error: String(error) }
|
|
921
|
+
}
|
|
922
|
+
if (backup && backup.ok === true) {
|
|
923
|
+
log.warn('pre-restart backup written', {
|
|
924
|
+
dir: backup.dir,
|
|
925
|
+
bytes: backup.bytes,
|
|
926
|
+
includePlugins: backup.includePlugins,
|
|
927
|
+
pruned: backup.pruned,
|
|
928
|
+
profileDirSource: info.source,
|
|
929
|
+
})
|
|
930
|
+
// A report from an older boot must not be mistaken for this one.
|
|
931
|
+
clearRollbackReport(stateDir)
|
|
932
|
+
} else {
|
|
933
|
+
log.warn('the pre-restart backup failed; continuing without a rollback point', {
|
|
934
|
+
error: backup && backup.error ? backup.error : 'unknown error',
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
return { backup, profileDir, profile: info }
|
|
938
|
+
}
|
|
939
|
+
|
|
556
940
|
/** A logger that never throws and never requires a particular Cordis logger shape. */
|
|
557
941
|
function createLogger(ctx) {
|
|
558
942
|
const logger = ctx.logger
|
|
@@ -596,10 +980,91 @@ function isRootAgent(ctx, agent) {
|
|
|
596
980
|
}
|
|
597
981
|
}
|
|
598
982
|
|
|
599
|
-
/**
|
|
600
|
-
|
|
983
|
+
/**
|
|
984
|
+
* Resolve the official `configEditor` service, or null when it is unavailable.
|
|
985
|
+
*
|
|
986
|
+
* The service is optional: the plugin must load in a composition that does not
|
|
987
|
+
* provide it, and the one-time install choice then falls back to a file.
|
|
988
|
+
*
|
|
989
|
+
* @param {object} ctx
|
|
990
|
+
* @returns {any|null}
|
|
991
|
+
*/
|
|
992
|
+
function resolveConfigEditor(ctx) {
|
|
993
|
+
try {
|
|
994
|
+
if (ctx && ctx.configEditor && typeof ctx.configEditor.edit === 'function') return ctx.configEditor
|
|
995
|
+
} catch {}
|
|
996
|
+
try {
|
|
997
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
998
|
+
const service = ctx.get('configEditor')
|
|
999
|
+
if (service && typeof service.edit === 'function') return service
|
|
1000
|
+
}
|
|
1001
|
+
} catch {}
|
|
1002
|
+
return null
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Find this plugin's own row among the profile entries.
|
|
1007
|
+
*
|
|
1008
|
+
* The service hands out the LOADER's own entries, so the row id lives in
|
|
1009
|
+
* `entry.options.id` (the id the bundle layer inserts: `dsh-autorestart`).
|
|
1010
|
+
* A projected entry (the shape the settings UI shows) carries the same id as
|
|
1011
|
+
* `patchId` or `id`, so both are accepted as well.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {any} editor the configEditor service
|
|
1014
|
+
* @returns {any|null} the entry, or null when this plugin has no row
|
|
1015
|
+
*/
|
|
1016
|
+
function findOwnEntry(editor) {
|
|
1017
|
+
let entries = []
|
|
1018
|
+
try {
|
|
1019
|
+
entries = typeof editor.entries === 'function' ? editor.entries() : []
|
|
1020
|
+
} catch {
|
|
1021
|
+
return null
|
|
1022
|
+
}
|
|
1023
|
+
if (!Array.isArray(entries)) return null
|
|
1024
|
+
for (const entry of entries) {
|
|
1025
|
+
if (!entry || typeof entry !== 'object') continue
|
|
1026
|
+
const options = entry.options && typeof entry.options === 'object' ? entry.options : null
|
|
1027
|
+
const candidates = [
|
|
1028
|
+
options ? options.id : undefined,
|
|
1029
|
+
entry.patchId,
|
|
1030
|
+
entry.id,
|
|
1031
|
+
]
|
|
1032
|
+
for (const candidate of candidates) {
|
|
1033
|
+
if (candidate === undefined || candidate === null) continue
|
|
1034
|
+
if (String(candidate) === name) return entry
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return null
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* The plugin's whole runtime state.
|
|
1042
|
+
*
|
|
1043
|
+
* @param {object} ctx
|
|
1044
|
+
* @param {object} config the normalized config
|
|
1045
|
+
* @param {{
|
|
1046
|
+
* backupChoiceSource?: string,
|
|
1047
|
+
* env?: object,
|
|
1048
|
+
* snapshot?: Function,
|
|
1049
|
+
* runCommand?: Function,
|
|
1050
|
+
* scheduleDetached?: Function,
|
|
1051
|
+
* }} [options] `env` replaces parts of the detected environment and the three
|
|
1052
|
+
* functions replace the side-effecting primitives of the restart path. They
|
|
1053
|
+
* exist so a restart can be exercised without spawning a process or touching
|
|
1054
|
+
* the real profile; production never passes them.
|
|
1055
|
+
*/
|
|
1056
|
+
export function createRuntime(ctx, config, options = {}) {
|
|
601
1057
|
const log = createLogger(ctx)
|
|
602
|
-
const env =
|
|
1058
|
+
const env = {
|
|
1059
|
+
...detectEnvironment(),
|
|
1060
|
+
...(options.env && typeof options.env === 'object' ? options.env : {}),
|
|
1061
|
+
}
|
|
1062
|
+
// The ONE pre-restart snapshot entry: every restart mode uses it.
|
|
1063
|
+
const snapshotFn = typeof options.snapshot === 'function' ? options.snapshot : takePreRestartSnapshot
|
|
1064
|
+
const runCommandFn = typeof options.runCommand === 'function' ? options.runCommand : runCommandNow
|
|
1065
|
+
const scheduleDetachedFn = typeof options.scheduleDetached === 'function'
|
|
1066
|
+
? options.scheduleDetached
|
|
1067
|
+
: scheduleDetachedRestart
|
|
603
1068
|
const stateFile = resolveStateFile(config)
|
|
604
1069
|
|
|
605
1070
|
const loaded = readState(stateFile, Date.now())
|
|
@@ -1246,7 +1711,32 @@ function createRuntime(ctx, config) {
|
|
|
1246
1711
|
persist()
|
|
1247
1712
|
log.warn('executing graceful restart', { generation: state.generation, pid: process.pid })
|
|
1248
1713
|
try {
|
|
1249
|
-
|
|
1714
|
+
// How the host is restarted: the custom command, or the desktop app.
|
|
1715
|
+
const useCustomCommand = config.restartMode === 'command' && config.restartCommand.trim() !== ''
|
|
1716
|
+
const canRelaunchDesktop = Boolean(env.isDesktopApp && env.appExePath)
|
|
1717
|
+
if (!useCustomCommand && !canRelaunchDesktop) {
|
|
1718
|
+
// No restart method at all (the host is not the desktop app and there is
|
|
1719
|
+
// no custom command): nothing is restarted, so nothing is snapshotted
|
|
1720
|
+
// either — there is no rollback point for a restart that never happens.
|
|
1721
|
+
log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
|
|
1722
|
+
rollbackRestart('no restart method available')
|
|
1723
|
+
return
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// ONE snapshot for EVERY restart mode: the profile is copied BEFORE
|
|
1727
|
+
// anything is killed, no matter how the host is restarted. The desktop
|
|
1728
|
+
// app relaunch and the custom restart command share this single entry, so
|
|
1729
|
+
// a hosted host that is restarted by a command is just as recoverable as
|
|
1730
|
+
// the desktop app.
|
|
1731
|
+
const stateDir = path.dirname(stateFile)
|
|
1732
|
+
const snapshot = snapshotFn({
|
|
1733
|
+
config,
|
|
1734
|
+
stateDir,
|
|
1735
|
+
log,
|
|
1736
|
+
profile: resolveProfileDirDetailed(),
|
|
1737
|
+
})
|
|
1738
|
+
|
|
1739
|
+
if (useCustomCommand) {
|
|
1250
1740
|
const command = buildCustomCommand({
|
|
1251
1741
|
template: config.restartCommand,
|
|
1252
1742
|
pid: process.pid,
|
|
@@ -1254,9 +1744,10 @@ function createRuntime(ctx, config) {
|
|
|
1254
1744
|
appExePath: env.appExePath || '',
|
|
1255
1745
|
})
|
|
1256
1746
|
log.warn('running custom restart command:', command)
|
|
1257
|
-
|
|
1747
|
+
// A failed snapshot never blocks the restart: the command runs anyway.
|
|
1748
|
+
const outcome = runCommandFn({ command })
|
|
1258
1749
|
if (outcome && outcome.ok === false) rollbackRestart(outcome.error || 'custom command failed')
|
|
1259
|
-
} else
|
|
1750
|
+
} else {
|
|
1260
1751
|
const aborts = Number(state.restart.gateAborts) || 0
|
|
1261
1752
|
const url = aborts > 20 ? null : restartGateUrl()
|
|
1262
1753
|
if (aborts > 20) {
|
|
@@ -1264,37 +1755,9 @@ function createRuntime(ctx, config) {
|
|
|
1264
1755
|
} else if (url === null) {
|
|
1265
1756
|
log.warn('the web server port is unknown; scheduling an UNGATED restart (the helper cannot re-check the idle gate)')
|
|
1266
1757
|
}
|
|
1267
|
-
//
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
const stateDir = path.dirname(stateFile)
|
|
1271
|
-
const profileInfo = resolveProfileDirDetailed()
|
|
1272
|
-
const profileDir = profileInfo.dir
|
|
1273
|
-
let backup = null
|
|
1274
|
-
if (config.backupOnRestart === true && profileDir !== null) {
|
|
1275
|
-
backup = createBackup({
|
|
1276
|
-
profileDir,
|
|
1277
|
-
backupsDir: backupsRoot(stateDir),
|
|
1278
|
-
includePlugins: config.backupIncludePlugins !== false,
|
|
1279
|
-
maxBackups: config.maxBackups,
|
|
1280
|
-
})
|
|
1281
|
-
if (backup.ok === true) {
|
|
1282
|
-
log.warn('pre-restart backup written', {
|
|
1283
|
-
dir: backup.dir,
|
|
1284
|
-
bytes: backup.bytes,
|
|
1285
|
-
includePlugins: backup.includePlugins,
|
|
1286
|
-
pruned: backup.pruned,
|
|
1287
|
-
profileDirSource: profileInfo.source,
|
|
1288
|
-
})
|
|
1289
|
-
// A report from an older boot must not be mistaken for this one.
|
|
1290
|
-
clearRollbackReport(stateDir)
|
|
1291
|
-
} else {
|
|
1292
|
-
log.warn('the pre-restart backup failed; continuing without a rollback point', { error: backup.error })
|
|
1293
|
-
}
|
|
1294
|
-
} else if (config.backupOnRestart === true) {
|
|
1295
|
-
log.warn('no profile directory is known; skipping the pre-restart backup', { source: profileInfo.source })
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1758
|
+
// The snapshot was already taken above (the shared entry).
|
|
1759
|
+
const backup = snapshot && snapshot.backup ? snapshot.backup : null
|
|
1760
|
+
const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
|
|
1298
1761
|
const healthUrl = restartHealthUrl()
|
|
1299
1762
|
const watchScriptPath = backup && backup.ok === true && healthUrl !== null && profileDir !== null
|
|
1300
1763
|
? path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
|
|
@@ -1311,7 +1774,7 @@ function createRuntime(ctx, config) {
|
|
|
1311
1774
|
// Only a crash log written after this point belongs to this restart.
|
|
1312
1775
|
since: new Date().toISOString(),
|
|
1313
1776
|
})
|
|
1314
|
-
const result =
|
|
1777
|
+
const result = scheduleDetachedFn({
|
|
1315
1778
|
appExePath: env.appExePath,
|
|
1316
1779
|
relaunchDelaySeconds: config.relaunchDelaySeconds,
|
|
1317
1780
|
gateUrl: url,
|
|
@@ -1322,9 +1785,6 @@ function createRuntime(ctx, config) {
|
|
|
1322
1785
|
log.warn('scheduled detached restart:', result)
|
|
1323
1786
|
if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
|
|
1324
1787
|
else armGateWatchdog()
|
|
1325
|
-
} else {
|
|
1326
|
-
log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
|
|
1327
|
-
rollbackRestart('no restart method available')
|
|
1328
1788
|
}
|
|
1329
1789
|
} catch (error) {
|
|
1330
1790
|
rollbackRestart(String(error))
|
|
@@ -1389,6 +1849,130 @@ function createRuntime(ctx, config) {
|
|
|
1389
1849
|
}
|
|
1390
1850
|
}
|
|
1391
1851
|
|
|
1852
|
+
/**
|
|
1853
|
+
* Persist one or more user choices into the profile row of this plugin.
|
|
1854
|
+
*
|
|
1855
|
+
* The official `configEditor` service is the primary path: the choice goes
|
|
1856
|
+
* into the profile row of this plugin, so the settings page and the one-time
|
|
1857
|
+
* prompts read and write the same key (one source of truth). When the service
|
|
1858
|
+
* is unavailable the choice degrades to a file in the plugin's state
|
|
1859
|
+
* directory and a warning is logged, so the choice is never silently lost.
|
|
1860
|
+
*
|
|
1861
|
+
* @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
|
|
1862
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file' }>}
|
|
1863
|
+
*/
|
|
1864
|
+
const persistConfigChoice = async (changes) => {
|
|
1865
|
+
// The profile row and the degraded fallback file use different key names for
|
|
1866
|
+
// the backup scope: the row carries the plugin's own key
|
|
1867
|
+
// (`backupIncludePlugins`), the file the short one (`includePlugins`).
|
|
1868
|
+
const wanted = {}
|
|
1869
|
+
const fallback = {}
|
|
1870
|
+
if (typeof changes.backupIncludePlugins === 'boolean') {
|
|
1871
|
+
wanted.backupIncludePlugins = changes.backupIncludePlugins
|
|
1872
|
+
fallback.includePlugins = changes.backupIncludePlugins
|
|
1873
|
+
}
|
|
1874
|
+
if (typeof changes.backupOnRestart === 'boolean') {
|
|
1875
|
+
wanted.backupOnRestart = changes.backupOnRestart
|
|
1876
|
+
fallback.backupOnRestart = changes.backupOnRestart
|
|
1877
|
+
}
|
|
1878
|
+
if (typeof changes.showSleepButton === 'boolean') {
|
|
1879
|
+
wanted.showSleepButton = changes.showSleepButton
|
|
1880
|
+
fallback.showSleepButton = changes.showSleepButton
|
|
1881
|
+
}
|
|
1882
|
+
if (typeof changes.sleepCountdownSeconds === 'number') {
|
|
1883
|
+
wanted.sleepCountdownSeconds = changes.sleepCountdownSeconds
|
|
1884
|
+
fallback.sleepCountdownSeconds = changes.sleepCountdownSeconds
|
|
1885
|
+
}
|
|
1886
|
+
const editor = resolveConfigEditor(ctx)
|
|
1887
|
+
if (editor !== null) {
|
|
1888
|
+
try {
|
|
1889
|
+
const entry = findOwnEntry(editor)
|
|
1890
|
+
if (entry !== null) {
|
|
1891
|
+
await editor.edit(entry, (current) => {
|
|
1892
|
+
const base = current && typeof current === 'object' ? { ...current } : {}
|
|
1893
|
+
return { ...base, ...wanted }
|
|
1894
|
+
})
|
|
1895
|
+
// The Loader reconciles the row, but the running instance must not
|
|
1896
|
+
// keep the old value until that happens.
|
|
1897
|
+
for (const [key, value] of Object.entries(wanted)) config[key] = value
|
|
1898
|
+
log.info(`the choice was written into the profile configuration: ${JSON.stringify(wanted)}`)
|
|
1899
|
+
return { applied: 'config' }
|
|
1900
|
+
}
|
|
1901
|
+
log.warn(`the configEditor service has no profile row for ${name}; the choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
log.warn(`could not write the choice into the profile configuration: ${describeError(error)}`)
|
|
1904
|
+
}
|
|
1905
|
+
} else {
|
|
1906
|
+
log.warn(`the configEditor service is unavailable; the choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1907
|
+
}
|
|
1908
|
+
const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
|
|
1909
|
+
try {
|
|
1910
|
+
writeInstallChoice(path.dirname(stateFile), fallback)
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
throw new Error(`could not write the choice to ${file}: ${describeError(error)}`)
|
|
1913
|
+
}
|
|
1914
|
+
for (const [key, value] of Object.entries(wanted)) config[key] = value
|
|
1915
|
+
log.warn(`the choice was written to ${file} instead of the profile configuration`)
|
|
1916
|
+
return { applied: 'fallback-file' }
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* The feature announcements the user has not seen yet.
|
|
1921
|
+
*
|
|
1922
|
+
* A feature is pending when the recorded version is not the current one: a
|
|
1923
|
+
* user who has seen the announcement of version 0.5.0 never sees it again,
|
|
1924
|
+
* and a user who upgrades to a newer version sees the announcement of that
|
|
1925
|
+
* version. A feature without a record is always pending — a missing record
|
|
1926
|
+
* must never swallow an announcement.
|
|
1927
|
+
*
|
|
1928
|
+
* @returns {{ feature: string, version: string }[]}
|
|
1929
|
+
*/
|
|
1930
|
+
const pendingAnnouncements = () => {
|
|
1931
|
+
const seen = state.seenFeatureVersions && typeof state.seenFeatureVersions === 'object'
|
|
1932
|
+
? state.seenFeatureVersions
|
|
1933
|
+
: {}
|
|
1934
|
+
return featureAnnouncements().filter((entry) => seen[entry.feature] !== entry.version)
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Record that the user has seen the announcement of one feature.
|
|
1939
|
+
*
|
|
1940
|
+
* The record lives in the checkpoint file, so it survives a restart of the
|
|
1941
|
+
* Host: an announcement that was shown once does not come back with the
|
|
1942
|
+
* next boot.
|
|
1943
|
+
*
|
|
1944
|
+
* @param {string} feature the id of the feature
|
|
1945
|
+
* @returns {string[]} the features that are recorded now
|
|
1946
|
+
*/
|
|
1947
|
+
const markFeatureSeen = (feature) => {
|
|
1948
|
+
const id = typeof feature === 'string' ? feature.trim() : ''
|
|
1949
|
+
if (id === '') return []
|
|
1950
|
+
const known = featureAnnouncements().find((entry) => entry.feature === id)
|
|
1951
|
+
if (!known) return []
|
|
1952
|
+
if (!state.seenFeatureVersions || typeof state.seenFeatureVersions !== 'object') {
|
|
1953
|
+
state.seenFeatureVersions = {}
|
|
1954
|
+
}
|
|
1955
|
+
state.seenFeatureVersions[id] = known.version
|
|
1956
|
+
persist()
|
|
1957
|
+
return Object.keys(state.seenFeatureVersions).sort()
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Record that the user has seen the announcement of EVERY feature.
|
|
1962
|
+
*
|
|
1963
|
+
* This is what the one-time install prompt does: it already explains the
|
|
1964
|
+
* features to a fresh installation, so no announcement may follow it.
|
|
1965
|
+
*
|
|
1966
|
+
* @returns {string[]} the features that are recorded now
|
|
1967
|
+
*/
|
|
1968
|
+
const markAllFeaturesSeen = () => {
|
|
1969
|
+
const seen = new Set()
|
|
1970
|
+
for (const entry of featureAnnouncements()) {
|
|
1971
|
+
for (const id of markFeatureSeen(entry.feature)) seen.add(id)
|
|
1972
|
+
}
|
|
1973
|
+
return Array.from(seen).sort()
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1392
1976
|
const runtime = {
|
|
1393
1977
|
get state() {
|
|
1394
1978
|
return state
|
|
@@ -1396,6 +1980,14 @@ function createRuntime(ctx, config) {
|
|
|
1396
1980
|
get bootId() {
|
|
1397
1981
|
return bootId
|
|
1398
1982
|
},
|
|
1983
|
+
/**
|
|
1984
|
+
* Where the effective `backupIncludePlugins` value came from:
|
|
1985
|
+
* `config` (the profile row), `fallback-file` (the degraded path) or
|
|
1986
|
+
* `default` (nothing was ever chosen).
|
|
1987
|
+
*/
|
|
1988
|
+
get backupChoiceSource() {
|
|
1989
|
+
return options.backupChoiceSource || 'default'
|
|
1990
|
+
},
|
|
1399
1991
|
get isNewBoot() {
|
|
1400
1992
|
return boot.isNewBoot
|
|
1401
1993
|
},
|
|
@@ -1430,6 +2022,53 @@ function createRuntime(ctx, config) {
|
|
|
1430
2022
|
stateFile,
|
|
1431
2023
|
log,
|
|
1432
2024
|
persist,
|
|
2025
|
+
/**
|
|
2026
|
+
* Persist one or more user choices into the profile row of this plugin.
|
|
2027
|
+
*
|
|
2028
|
+
* @param {{ backupOnRestart?: boolean, backupIncludePlugins?: boolean, showSleepButton?: boolean, sleepCountdownSeconds?: number }} changes
|
|
2029
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file' }>}
|
|
2030
|
+
*/
|
|
2031
|
+
applyConfigChoice: (changes) => persistConfigChoice(changes),
|
|
2032
|
+
/**
|
|
2033
|
+
* The feature announcements the user has not seen yet.
|
|
2034
|
+
*
|
|
2035
|
+
* @returns {{ feature: string, version: string }[]}
|
|
2036
|
+
*/
|
|
2037
|
+
pendingAnnouncements,
|
|
2038
|
+
/**
|
|
2039
|
+
* Record that the user has seen the announcement of one feature.
|
|
2040
|
+
*
|
|
2041
|
+
* @param {string} feature
|
|
2042
|
+
* @returns {string[]} the features that are recorded now
|
|
2043
|
+
*/
|
|
2044
|
+
markFeatureSeen,
|
|
2045
|
+
/**
|
|
2046
|
+
* Record that the user has seen the announcement of every feature.
|
|
2047
|
+
*
|
|
2048
|
+
* @returns {string[]} the features that are recorded now
|
|
2049
|
+
*/
|
|
2050
|
+
markAllFeaturesSeen,
|
|
2051
|
+
/**
|
|
2052
|
+
* Persist the user's one-time install choice: whether the pre-restart
|
|
2053
|
+
* backup also snapshots the installed plugin tree.
|
|
2054
|
+
*
|
|
2055
|
+
* @param {boolean} includePlugins
|
|
2056
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file', includePlugins: boolean }>}
|
|
2057
|
+
*/
|
|
2058
|
+
async applyBackupChoice(includePlugins) {
|
|
2059
|
+
const result = await persistConfigChoice({ backupIncludePlugins: includePlugins === true })
|
|
2060
|
+
return { applied: result.applied, includePlugins: includePlugins === true }
|
|
2061
|
+
},
|
|
2062
|
+
/**
|
|
2063
|
+
* Persist whether the graceful-sleep button is shown in the main UI.
|
|
2064
|
+
*
|
|
2065
|
+
* @param {boolean} showSleepButton
|
|
2066
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file', showSleepButton: boolean }>}
|
|
2067
|
+
*/
|
|
2068
|
+
async applySleepButtonChoice(showSleepButton) {
|
|
2069
|
+
const result = await persistConfigChoice({ showSleepButton: showSleepButton === true })
|
|
2070
|
+
return { applied: result.applied, showSleepButton: showSleepButton === true }
|
|
2071
|
+
},
|
|
1433
2072
|
toolErrors,
|
|
1434
2073
|
startPolling,
|
|
1435
2074
|
stopPolling,
|
|
@@ -2145,7 +2784,8 @@ function registerTools(ctx, runtime, config) {
|
|
|
2145
2784
|
+ '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
|
|
2146
2785
|
+ 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
|
|
2147
2786
|
+ 'whether a session was handed off to a fresh session (target session id and time) or is over '
|
|
2148
|
-
+ 'the model context window (such a session is never auto-resumed), the '
|
|
2787
|
+
+ 'the model context window (such a session is never auto-resumed), whether the pre-restart '
|
|
2788
|
+
+ 'backup also snapshots the installed plugins (backupIncludePlugins), the '
|
|
2149
2789
|
+ 'resolved state-file path, and the detected restart environment. Read-only.',
|
|
2150
2790
|
parameters: {
|
|
2151
2791
|
type: 'object',
|
|
@@ -2174,6 +2814,7 @@ function registerTools(ctx, runtime, config) {
|
|
|
2174
2814
|
handoffs: { type: 'array', items: { type: 'object' } },
|
|
2175
2815
|
profileDir: { type: 'string' },
|
|
2176
2816
|
profileDirSource: { type: 'string' },
|
|
2817
|
+
backupIncludePlugins: { type: 'boolean' },
|
|
2177
2818
|
lastRollback: { type: 'object' },
|
|
2178
2819
|
environment: { type: 'object' },
|
|
2179
2820
|
sessions: { type: 'array', items: { type: 'object' } },
|
|
@@ -2201,6 +2842,8 @@ function registerTools(ctx, runtime, config) {
|
|
|
2201
2842
|
// Where the pre-restart backup comes from (null dir = it would be skipped).
|
|
2202
2843
|
profileDirSource: runtime.profileDir.source,
|
|
2203
2844
|
...(runtime.profileDir.dir ? { profileDir: runtime.profileDir.dir } : {}),
|
|
2845
|
+
// Whether the pre-restart backup also snapshots the installed plugins.
|
|
2846
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
2204
2847
|
// Present only when the previous restart had to be rolled back.
|
|
2205
2848
|
...(runtime.lastRollback ? { lastRollback: runtime.lastRollback } : {}),
|
|
2206
2849
|
environment: {
|
|
@@ -2906,8 +3549,533 @@ function buildHandoffRoute(ctx, runtime, config) {
|
|
|
2906
3549
|
}
|
|
2907
3550
|
}
|
|
2908
3551
|
|
|
3552
|
+
/**
|
|
3553
|
+
* Build the announcements route: the one-time announcement of a new feature.
|
|
3554
|
+
*
|
|
3555
|
+
* It sits next to the health route and uses the same authentication rule
|
|
3556
|
+
* (loopback OR a request the connection service admits).
|
|
3557
|
+
*
|
|
3558
|
+
* - `GET` answers `{ ok: true, pending: [{ feature, version }] }`: the
|
|
3559
|
+
* announcements the user has not seen yet (an empty list when there is
|
|
3560
|
+
* nothing new — a fresh installation is asked at the install prompt, so
|
|
3561
|
+
* it never gets an announcement);
|
|
3562
|
+
* - `POST` with `{ "feature": "sleep" }` records that the user has seen
|
|
3563
|
+
* that announcement and answers `{ ok: true, seen: [...] }`.
|
|
3564
|
+
*
|
|
3565
|
+
* The record lives in the checkpoint file, so an announcement that was shown
|
|
3566
|
+
* once never comes back — not with the next boot and not with the next
|
|
3567
|
+
* version of the plugin.
|
|
3568
|
+
*/
|
|
3569
|
+
function buildAnnouncementsRoute(ctx, runtime, config) {
|
|
3570
|
+
const routePath = resolveAnnouncementsPath(config)
|
|
3571
|
+
return {
|
|
3572
|
+
kind: 'exact',
|
|
3573
|
+
path: routePath,
|
|
3574
|
+
handler: (req, res) => {
|
|
3575
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
3576
|
+
const loopback = remote === undefined
|
|
3577
|
+
|| remote === '127.0.0.1'
|
|
3578
|
+
|| remote === '::1'
|
|
3579
|
+
|| remote === '::ffff:127.0.0.1'
|
|
3580
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
3581
|
+
res.statusCode = 403
|
|
3582
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3583
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
3584
|
+
return
|
|
3585
|
+
}
|
|
3586
|
+
const answer = (payload, status = 200) => {
|
|
3587
|
+
res.statusCode = status
|
|
3588
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3589
|
+
res.setHeader('cache-control', 'no-store')
|
|
3590
|
+
res.end(JSON.stringify(payload))
|
|
3591
|
+
}
|
|
3592
|
+
const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
|
|
3593
|
+
if (method === 'GET') {
|
|
3594
|
+
answer({ ok: true, pending: runtime.pendingAnnouncements() })
|
|
3595
|
+
return
|
|
3596
|
+
}
|
|
3597
|
+
if (method !== 'POST') {
|
|
3598
|
+
res.statusCode = 405
|
|
3599
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3600
|
+
res.setHeader('allow', 'GET, POST')
|
|
3601
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
3602
|
+
return
|
|
3603
|
+
}
|
|
3604
|
+
void readJsonBody(req).then((payload) => {
|
|
3605
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
3606
|
+
const feature = typeof body.feature === 'string' ? body.feature.trim() : ''
|
|
3607
|
+
if (feature === '') {
|
|
3608
|
+
answer({
|
|
3609
|
+
ok: false,
|
|
3610
|
+
error: 'feature-required',
|
|
3611
|
+
instruction: 'Send { "feature": "sleep" }.',
|
|
3612
|
+
}, 400)
|
|
3613
|
+
return
|
|
3614
|
+
}
|
|
3615
|
+
const known = featureAnnouncements().some((entry) => entry.feature === feature)
|
|
3616
|
+
if (!known) {
|
|
3617
|
+
answer({
|
|
3618
|
+
ok: false,
|
|
3619
|
+
error: 'unknown-feature',
|
|
3620
|
+
instruction: `Send { "feature": ${featureAnnouncements().map((entry) => `"${entry.feature}"`).join(' | ')} }.`,
|
|
3621
|
+
}, 400)
|
|
3622
|
+
return
|
|
3623
|
+
}
|
|
3624
|
+
answer({ ok: true, seen: runtime.markFeatureSeen(feature) })
|
|
3625
|
+
}).catch((error) => {
|
|
3626
|
+
answer({ ok: false, error: describeError(error) }, 400)
|
|
3627
|
+
})
|
|
3628
|
+
},
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
/**
|
|
3633
|
+
* Build the POST `/choice` route: persist the one-time install choice of the
|
|
3634
|
+
* user (whether the pre-restart backup also snapshots the installed plugins),
|
|
3635
|
+
* the answer to the one-time question whether the graceful-sleep button is
|
|
3636
|
+
* shown, and the settings of the configuration page (how long the countdown
|
|
3637
|
+
* runs, whether the pre-restart backup is taken at all and whether that
|
|
3638
|
+
* backup includes the installed plugins).
|
|
3639
|
+
*
|
|
3640
|
+
* It sits next to the health route and uses the same authentication rule
|
|
3641
|
+
* (loopback OR a request the connection service admits). Body:
|
|
3642
|
+
* `{ "includePlugins"?: boolean, "backupIncludePlugins"?: boolean,
|
|
3643
|
+
* "backupOnRestart"?: boolean, "showSleepButton"?: boolean,
|
|
3644
|
+
* "sleepCountdownSeconds"?: 0 | 10 | 20 | 30 | 60 }` — at least one
|
|
3645
|
+
* of the keys must be present, and a key that IS present must carry a valid
|
|
3646
|
+
* value. Anything else is refused with a 400 and NOTHING is written.
|
|
3647
|
+
*
|
|
3648
|
+
* The answer is `{ ok: true, applied: 'config' | 'fallback-file', ... }`:
|
|
3649
|
+
* `applied` says where the choice landed — the profile configuration (the
|
|
3650
|
+
* official path) or the degraded fallback file. Only the keys that were asked
|
|
3651
|
+
* for are echoed back, plus the effective `countdownSeconds` so the settings
|
|
3652
|
+
* page can read them back at once.
|
|
3653
|
+
*/
|
|
3654
|
+
function buildChoiceRoute(ctx, runtime, config) {
|
|
3655
|
+
const routePath = resolveChoicePath(config)
|
|
3656
|
+
return {
|
|
3657
|
+
kind: 'exact',
|
|
3658
|
+
path: routePath,
|
|
3659
|
+
handler: (req, res) => {
|
|
3660
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
3661
|
+
const loopback = remote === undefined
|
|
3662
|
+
|| remote === '127.0.0.1'
|
|
3663
|
+
|| remote === '::1'
|
|
3664
|
+
|| remote === '::ffff:127.0.0.1'
|
|
3665
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
3666
|
+
res.statusCode = 403
|
|
3667
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3668
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
3669
|
+
return
|
|
3670
|
+
}
|
|
3671
|
+
if (!req || req.method !== 'POST') {
|
|
3672
|
+
res.statusCode = 405
|
|
3673
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3674
|
+
res.setHeader('allow', 'POST')
|
|
3675
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
3676
|
+
return
|
|
3677
|
+
}
|
|
3678
|
+
void readJsonBody(req).then(async (payload) => {
|
|
3679
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
3680
|
+
const hasInclude = Object.prototype.hasOwnProperty.call(body, 'includePlugins')
|
|
3681
|
+
const hasBackupInclude = Object.prototype.hasOwnProperty.call(body, 'backupIncludePlugins')
|
|
3682
|
+
const hasBackup = Object.prototype.hasOwnProperty.call(body, 'backupOnRestart')
|
|
3683
|
+
const hasShow = Object.prototype.hasOwnProperty.call(body, 'showSleepButton')
|
|
3684
|
+
const hasSeconds = Object.prototype.hasOwnProperty.call(body, 'sleepCountdownSeconds')
|
|
3685
|
+
if (!hasInclude && !hasBackupInclude && !hasBackup && !hasShow && !hasSeconds) {
|
|
3686
|
+
// No key was asked for: there is nothing to persist.
|
|
3687
|
+
res.statusCode = 400
|
|
3688
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3689
|
+
res.setHeader('cache-control', 'no-store')
|
|
3690
|
+
res.end(JSON.stringify({
|
|
3691
|
+
ok: false,
|
|
3692
|
+
error: 'no-choice',
|
|
3693
|
+
instruction: 'Send { "includePlugins": true | false } and/or { "backupIncludePlugins": true | false } and/or { "backupOnRestart": true | false } and/or { "showSleepButton": true | false } and/or { "sleepCountdownSeconds": 0 | 10 | 20 | 30 | 60 }.',
|
|
3694
|
+
}))
|
|
3695
|
+
return
|
|
3696
|
+
}
|
|
3697
|
+
if (hasInclude && typeof body.includePlugins !== 'boolean') {
|
|
3698
|
+
// Nothing is written for a malformed choice: the caller's answer
|
|
3699
|
+
// must not change the profile configuration.
|
|
3700
|
+
res.statusCode = 400
|
|
3701
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3702
|
+
res.setHeader('cache-control', 'no-store')
|
|
3703
|
+
res.end(JSON.stringify({
|
|
3704
|
+
ok: false,
|
|
3705
|
+
error: 'includePlugins-must-be-boolean',
|
|
3706
|
+
instruction: 'Send { "includePlugins": true | false }.',
|
|
3707
|
+
}))
|
|
3708
|
+
return
|
|
3709
|
+
}
|
|
3710
|
+
if (hasBackupInclude && typeof body.backupIncludePlugins !== 'boolean') {
|
|
3711
|
+
res.statusCode = 400
|
|
3712
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3713
|
+
res.setHeader('cache-control', 'no-store')
|
|
3714
|
+
res.end(JSON.stringify({
|
|
3715
|
+
ok: false,
|
|
3716
|
+
error: 'backupIncludePlugins-must-be-boolean',
|
|
3717
|
+
instruction: 'Send { "backupIncludePlugins": true | false }.',
|
|
3718
|
+
}))
|
|
3719
|
+
return
|
|
3720
|
+
}
|
|
3721
|
+
if (hasBackup && typeof body.backupOnRestart !== 'boolean') {
|
|
3722
|
+
res.statusCode = 400
|
|
3723
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3724
|
+
res.setHeader('cache-control', 'no-store')
|
|
3725
|
+
res.end(JSON.stringify({
|
|
3726
|
+
ok: false,
|
|
3727
|
+
error: 'backupOnRestart-must-be-boolean',
|
|
3728
|
+
instruction: 'Send { "backupOnRestart": true | false }.',
|
|
3729
|
+
}))
|
|
3730
|
+
return
|
|
3731
|
+
}
|
|
3732
|
+
if (hasShow && typeof body.showSleepButton !== 'boolean') {
|
|
3733
|
+
res.statusCode = 400
|
|
3734
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3735
|
+
res.setHeader('cache-control', 'no-store')
|
|
3736
|
+
res.end(JSON.stringify({
|
|
3737
|
+
ok: false,
|
|
3738
|
+
error: 'showSleepButton-must-be-boolean',
|
|
3739
|
+
instruction: 'Send { "showSleepButton": true | false }.',
|
|
3740
|
+
}))
|
|
3741
|
+
return
|
|
3742
|
+
}
|
|
3743
|
+
if (hasSeconds && COUNTDOWN_CHOICES.indexOf(body.sleepCountdownSeconds) === -1) {
|
|
3744
|
+
res.statusCode = 400
|
|
3745
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3746
|
+
res.setHeader('cache-control', 'no-store')
|
|
3747
|
+
res.end(JSON.stringify({
|
|
3748
|
+
ok: false,
|
|
3749
|
+
error: 'sleepCountdownSeconds-must-be-offered',
|
|
3750
|
+
instruction: `Send { "sleepCountdownSeconds": ${COUNTDOWN_CHOICES.join(' | ')} }.`,
|
|
3751
|
+
}))
|
|
3752
|
+
return
|
|
3753
|
+
}
|
|
3754
|
+
const changes = {}
|
|
3755
|
+
// The route field is `includePlugins`, the configuration key is
|
|
3756
|
+
// `backupIncludePlugins` (the profile row keeps the plugin's own name).
|
|
3757
|
+
if (hasInclude) changes.backupIncludePlugins = body.includePlugins === true
|
|
3758
|
+
if (hasBackupInclude) changes.backupIncludePlugins = body.backupIncludePlugins === true
|
|
3759
|
+
if (hasBackup) changes.backupOnRestart = body.backupOnRestart === true
|
|
3760
|
+
if (hasShow) changes.showSleepButton = body.showSleepButton === true
|
|
3761
|
+
if (hasSeconds) changes.sleepCountdownSeconds = body.sleepCountdownSeconds
|
|
3762
|
+
const result = await runtime.applyConfigChoice(changes)
|
|
3763
|
+
// The one-time install prompt explained the features to the user, so
|
|
3764
|
+
// the announcement of a fresh installation must not follow it.
|
|
3765
|
+
//
|
|
3766
|
+
// Only the install prompt sends `includePlugins` and `showSleepButton`
|
|
3767
|
+
// (the settings page writes `backupIncludePlugins`,
|
|
3768
|
+
// `backupOnRestart` and `sleepCountdownSeconds`), so these two keys
|
|
3769
|
+
// are exactly the "the install question was answered" moment.
|
|
3770
|
+
if (hasInclude || hasShow) {
|
|
3771
|
+
const seen = runtime.markAllFeaturesSeen()
|
|
3772
|
+
if (seen.length > 0) {
|
|
3773
|
+
runtime.log.info(`the install question marked the feature announcements as seen: ${seen.join(', ')}`)
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
const answer = { ok: true, applied: result.applied }
|
|
3777
|
+
if (hasInclude) answer.includePlugins = body.includePlugins === true
|
|
3778
|
+
if (hasBackupInclude) answer.backupIncludePlugins = body.backupIncludePlugins === true
|
|
3779
|
+
if (hasBackup) answer.backupOnRestart = body.backupOnRestart === true
|
|
3780
|
+
if (hasShow) answer.showSleepButton = body.showSleepButton === true
|
|
3781
|
+
if (hasSeconds) answer.sleepCountdownSeconds = body.sleepCountdownSeconds
|
|
3782
|
+
res.statusCode = 200
|
|
3783
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3784
|
+
res.setHeader('cache-control', 'no-store')
|
|
3785
|
+
res.end(JSON.stringify(answer))
|
|
3786
|
+
}).catch((error) => {
|
|
3787
|
+
res.statusCode = 500
|
|
3788
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3789
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
3790
|
+
})
|
|
3791
|
+
},
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
/**
|
|
3796
|
+
* Is the graceful sleep a dry run?
|
|
3797
|
+
*
|
|
3798
|
+
* With `DSH_AUTORESTART_SLEEP_DRYRUN=1` the plugin reports the command it
|
|
3799
|
+
* WOULD run and never runs it. That is what makes the feature testable on a
|
|
3800
|
+
* live machine.
|
|
3801
|
+
*
|
|
3802
|
+
* @returns {boolean}
|
|
3803
|
+
*/
|
|
3804
|
+
function sleepDryRun() {
|
|
3805
|
+
return process.env.DSH_AUTORESTART_SLEEP_DRYRUN === '1'
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
/**
|
|
3809
|
+
* The gate of the graceful sleep: the ONE question is "is any conversation
|
|
3810
|
+
* still working?".
|
|
3811
|
+
*
|
|
3812
|
+
* It is deliberately NOT the restart gate: that one also demands a quiet
|
|
3813
|
+
* settle window, and a settle window longer than the countdown would push the
|
|
3814
|
+
* power-off back by whole countdown periods (every restart of the countdown
|
|
3815
|
+
* would also claim that somebody started working again, which is not what
|
|
3816
|
+
* happened). The sleep feature counts down on screen itself, so it only needs
|
|
3817
|
+
* the busy check — and it keeps `settleMs` out of the picture entirely.
|
|
3818
|
+
*
|
|
3819
|
+
* The restart gate itself is untouched: the restart feature keeps its quiet
|
|
3820
|
+
* window exactly as it was.
|
|
3821
|
+
*
|
|
3822
|
+
* @param {object} runtime
|
|
3823
|
+
* @returns {{ allow: boolean, reason: 'busy'|'quiet', blockedBy: any[] }}
|
|
3824
|
+
*/
|
|
3825
|
+
function sleepGate(runtime) {
|
|
3826
|
+
let busy = []
|
|
3827
|
+
try {
|
|
3828
|
+
busy = runtime.busyAgents()
|
|
3829
|
+
} catch {
|
|
3830
|
+
busy = []
|
|
3831
|
+
}
|
|
3832
|
+
const list = Array.isArray(busy) ? busy : []
|
|
3833
|
+
if (list.length > 0) return { allow: false, reason: 'busy', blockedBy: list }
|
|
3834
|
+
return { allow: true, reason: 'quiet', blockedBy: [] }
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
/**
|
|
3838
|
+
* The FINAL plan of the graceful sleep: what will really be executed, and how
|
|
3839
|
+
* the host describes itself in the status JSON.
|
|
3840
|
+
*
|
|
3841
|
+
* Two rules:
|
|
3842
|
+
* 1. `supported` follows the plan that will be executed, NOT the detected
|
|
3843
|
+
* one: a machine whose platform could not be detected is still perfectly
|
|
3844
|
+
* usable when the operator configured a command of their own.
|
|
3845
|
+
* 2. the reported `kind`/`label` belong to that same plan, so the status
|
|
3846
|
+
* never says "this machine has no reliable way to switch off" while a
|
|
3847
|
+
* command is about to run.
|
|
3848
|
+
*
|
|
3849
|
+
* @param {object} plan the detected plan
|
|
3850
|
+
* @param {object} config
|
|
3851
|
+
* @returns {object} the effective plan
|
|
3852
|
+
*/
|
|
3853
|
+
function effectiveSleepPlan(plan, config) {
|
|
3854
|
+
const invocation = planInvocation(plan, {
|
|
3855
|
+
sleepCommand: config.sleepCommand,
|
|
3856
|
+
platform: plan.platform,
|
|
3857
|
+
})
|
|
3858
|
+
const custom = invocation.source === 'sleepCommand'
|
|
3859
|
+
const detectedKind = typeof plan.kind === 'string' ? plan.kind : 'unsupported'
|
|
3860
|
+
const detected = detectedKind !== 'unsupported'
|
|
3861
|
+
const command = typeof invocation.command === 'string' && invocation.command !== '' ? invocation.command : null
|
|
3862
|
+
const label = custom
|
|
3863
|
+
? (detected ? `执行你指定的关机命令(${plan.label})` : '执行你指定的关机命令')
|
|
3864
|
+
: plan.label
|
|
3865
|
+
const kind = custom
|
|
3866
|
+
// A custom command on a platform we could not classify: the machine is
|
|
3867
|
+
// real, the mechanism is the operator's own — it is NOT "unsupported".
|
|
3868
|
+
? (detected ? detectedKind : 'custom')
|
|
3869
|
+
: detectedKind
|
|
3870
|
+
return {
|
|
3871
|
+
...invocation,
|
|
3872
|
+
kind,
|
|
3873
|
+
label,
|
|
3874
|
+
// The short phrase the countdown message talks about.
|
|
3875
|
+
shortLabel: custom ? '执行你指定的关机命令' : plan.label,
|
|
3876
|
+
// The final plan decides whether the feature may run at all.
|
|
3877
|
+
supported: command !== null,
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
/**
|
|
3882
|
+
* Build the graceful-sleep service: the detected host plan plus the state
|
|
3883
|
+
* machine that waits for the conversations, counts down and switches the
|
|
3884
|
+
* machine off.
|
|
3885
|
+
*
|
|
3886
|
+
* The busy check is the runtime's own `busyAgents()`, so the sleep feature and
|
|
3887
|
+
* the restart feature agree on what "every conversation is idle" means. The
|
|
3888
|
+
* gate itself is the sleep-specific one (no settle window): the sleep feature
|
|
3889
|
+
* counts down on screen itself.
|
|
3890
|
+
*
|
|
3891
|
+
* `options.sleepNow` and `options.sleepSpawn` replace the clock and the
|
|
3892
|
+
* process spawner, so the whole flow can be driven without waiting and
|
|
3893
|
+
* without ever touching the machine. Production never passes them.
|
|
3894
|
+
*
|
|
3895
|
+
* @param {object} ctx
|
|
3896
|
+
* @param {object} runtime
|
|
3897
|
+
* @param {object} config
|
|
3898
|
+
* @param {{ sleepNow?: () => number, sleepSpawn?: Function }} [options]
|
|
3899
|
+
* @returns {{ plan: object, controller: object, invocation: () => object }}
|
|
3900
|
+
*/
|
|
3901
|
+
function createSleepService(ctx, runtime, config, options = {}) {
|
|
3902
|
+
const plan = detectShutdownPlan({ platform: process.platform, env: process.env })
|
|
3903
|
+
// The configured command (if any) replaces the detected one; the final plan
|
|
3904
|
+
// is what decides whether the feature may run and what the UI is told.
|
|
3905
|
+
const invocation = () => effectiveSleepPlan(plan, config)
|
|
3906
|
+
const gate = () => sleepGate(runtime)
|
|
3907
|
+
const final = invocation()
|
|
3908
|
+
const now = typeof options.sleepNow === 'function' ? options.sleepNow : () => Date.now()
|
|
3909
|
+
const spawn = typeof options.sleepSpawn === 'function' ? options.sleepSpawn : undefined
|
|
3910
|
+
const buildController = (seconds) => createSleepController({
|
|
3911
|
+
now,
|
|
3912
|
+
busyAgents: () => runtime.busyAgents(),
|
|
3913
|
+
gate,
|
|
3914
|
+
titleOf: (sessionId) => runtime.sessionTitle(sessionId),
|
|
3915
|
+
countdownSeconds: seconds,
|
|
3916
|
+
hostLabel: final.shortLabel,
|
|
3917
|
+
supported: final.supported,
|
|
3918
|
+
log: runtime.log,
|
|
3919
|
+
// The timer belongs to the plugin: `ctx.interval` is disposed with it.
|
|
3920
|
+
setInterval: (fn, ms) => ctx.interval(fn, ms),
|
|
3921
|
+
clearInterval: (handle) => {
|
|
3922
|
+
try {
|
|
3923
|
+
if (typeof handle === 'function') handle()
|
|
3924
|
+
} catch {}
|
|
3925
|
+
},
|
|
3926
|
+
shutdown: () => runShutdown(invocation(), {
|
|
3927
|
+
spawn,
|
|
3928
|
+
dryRun: sleepDryRun(),
|
|
3929
|
+
log: runtime.log,
|
|
3930
|
+
// The gate is checked ONE more time right before the machine is closed —
|
|
3931
|
+
// with the SAME sleep gate (a busy conversation still stops it).
|
|
3932
|
+
gate,
|
|
3933
|
+
}),
|
|
3934
|
+
})
|
|
3935
|
+
// The countdown length belongs to the CONFIGURATION, and the configuration
|
|
3936
|
+
// page can change it while the plugin runs. The controller is rebuilt as
|
|
3937
|
+
// soon as the new length is known — but never while a countdown is running:
|
|
3938
|
+
// that one keeps the length it was started with.
|
|
3939
|
+
let active = buildController(config.sleepCountdownSeconds)
|
|
3940
|
+
let activeSeconds = config.sleepCountdownSeconds
|
|
3941
|
+
const current = () => {
|
|
3942
|
+
const seconds = config.sleepCountdownSeconds
|
|
3943
|
+
if (seconds === activeSeconds) return active
|
|
3944
|
+
if (active.status().armed) return active
|
|
3945
|
+
active.dispose()
|
|
3946
|
+
active = buildController(seconds)
|
|
3947
|
+
activeSeconds = seconds
|
|
3948
|
+
return active
|
|
3949
|
+
}
|
|
3950
|
+
const liveController = {
|
|
3951
|
+
arm: (...args) => current().arm(...args),
|
|
3952
|
+
cancel: (...args) => current().cancel(...args),
|
|
3953
|
+
tick: (...args) => current().tick(...args),
|
|
3954
|
+
status: (...args) => current().status(...args),
|
|
3955
|
+
dispose: (...args) => current().dispose(...args),
|
|
3956
|
+
}
|
|
3957
|
+
return {
|
|
3958
|
+
plan,
|
|
3959
|
+
get controller() {
|
|
3960
|
+
return liveController
|
|
3961
|
+
},
|
|
3962
|
+
invocation,
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
/**
|
|
3967
|
+
* The state of the graceful sleep, in the shape the client renders it.
|
|
3968
|
+
*
|
|
3969
|
+
* @param {{ plan: object, controller: object, invocation: () => object }} sleep
|
|
3970
|
+
* @param {object} config
|
|
3971
|
+
* @returns {object}
|
|
3972
|
+
*/
|
|
3973
|
+
function sleepStatusPayload(sleep, config) {
|
|
3974
|
+
const status = sleep.controller.status()
|
|
3975
|
+
const invocation = sleep.invocation()
|
|
3976
|
+
return {
|
|
3977
|
+
ok: true,
|
|
3978
|
+
phase: status.phase,
|
|
3979
|
+
armed: status.armed,
|
|
3980
|
+
showSleepButton: config.showSleepButton !== false,
|
|
3981
|
+
countdownSeconds: status.countdownSeconds,
|
|
3982
|
+
/**
|
|
3983
|
+
* The pre-restart backup scope, straight from the effective configuration:
|
|
3984
|
+
* the settings page reads its two switches from here.
|
|
3985
|
+
*/
|
|
3986
|
+
backupOnRestart: config.backupOnRestart !== false,
|
|
3987
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3988
|
+
remainingMs: status.remainingMs,
|
|
3989
|
+
blockers: status.blockers,
|
|
3990
|
+
host: {
|
|
3991
|
+
platform: invocation.platform || process.platform,
|
|
3992
|
+
kind: invocation.kind,
|
|
3993
|
+
label: invocation.label,
|
|
3994
|
+
source: invocation.source,
|
|
3995
|
+
// Machine-readable: the host was only recognized as WSL because the
|
|
3996
|
+
// Windows drive is mounted (a hint, not a proof). The UI and the tests
|
|
3997
|
+
// read this instead of guessing from the Chinese label.
|
|
3998
|
+
suspect: invocation.suspect === true,
|
|
3999
|
+
},
|
|
4000
|
+
dryRun: sleepDryRun(),
|
|
4001
|
+
message: status.message,
|
|
4002
|
+
lastRun: status.lastRun,
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
/**
|
|
4007
|
+
* Build the `/sleep` route: `GET` reports the state of the graceful sleep,
|
|
4008
|
+
* `POST` arms or cancels it.
|
|
4009
|
+
*
|
|
4010
|
+
* It sits next to the health route and uses the same authentication rule
|
|
4011
|
+
* (loopback OR a request the connection service admits). Body:
|
|
4012
|
+
* `{ "action": "arm" | "cancel" }`; anything else is refused with a 400.
|
|
4013
|
+
*
|
|
4014
|
+
* There is NO ticket and no extra header: the desktop client and the hosted
|
|
4015
|
+
* web client drive the very same route, so a request carries nothing but the
|
|
4016
|
+
* action itself.
|
|
4017
|
+
*/
|
|
4018
|
+
function buildSleepRoute(ctx, runtime, config, sleep) {
|
|
4019
|
+
const routePath = resolveSleepPath(config)
|
|
4020
|
+
return {
|
|
4021
|
+
kind: 'exact',
|
|
4022
|
+
path: routePath,
|
|
4023
|
+
handler: (req, res) => {
|
|
4024
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
4025
|
+
const loopback = remote === undefined
|
|
4026
|
+
|| remote === '127.0.0.1'
|
|
4027
|
+
|| remote === '::1'
|
|
4028
|
+
|| remote === '::ffff:127.0.0.1'
|
|
4029
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
4030
|
+
res.statusCode = 403
|
|
4031
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4032
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
4033
|
+
return
|
|
4034
|
+
}
|
|
4035
|
+
const answer = (payload, status = 200) => {
|
|
4036
|
+
res.statusCode = status
|
|
4037
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4038
|
+
res.setHeader('cache-control', 'no-store')
|
|
4039
|
+
res.end(JSON.stringify(payload))
|
|
4040
|
+
}
|
|
4041
|
+
const method = req && typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'
|
|
4042
|
+
if (method === 'GET') {
|
|
4043
|
+
answer(sleepStatusPayload(sleep, config))
|
|
4044
|
+
return
|
|
4045
|
+
}
|
|
4046
|
+
if (method !== 'POST') {
|
|
4047
|
+
res.statusCode = 405
|
|
4048
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
4049
|
+
res.setHeader('allow', 'GET, POST')
|
|
4050
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
4051
|
+
return
|
|
4052
|
+
}
|
|
4053
|
+
void readJsonBody(req).then((payload) => {
|
|
4054
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
4055
|
+
const action = typeof body.action === 'string' ? body.action.trim() : ''
|
|
4056
|
+
if (action !== 'arm' && action !== 'cancel') {
|
|
4057
|
+
answer({
|
|
4058
|
+
ok: false,
|
|
4059
|
+
error: 'unknown-action',
|
|
4060
|
+
instruction: 'Send { "action": "arm" | "cancel" }.',
|
|
4061
|
+
}, 400)
|
|
4062
|
+
return
|
|
4063
|
+
}
|
|
4064
|
+
if (action === 'arm') {
|
|
4065
|
+
sleep.controller.arm()
|
|
4066
|
+
} else {
|
|
4067
|
+
sleep.controller.cancel()
|
|
4068
|
+
}
|
|
4069
|
+
answer(sleepStatusPayload(sleep, config))
|
|
4070
|
+
}).catch((error) => {
|
|
4071
|
+
answer({ ok: false, error: describeError(error) }, 400)
|
|
4072
|
+
})
|
|
4073
|
+
},
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
2909
4077
|
/** Expose a tiny read-only health route for humans and verification scripts. */
|
|
2910
|
-
function registerHealthRoute(ctx, runtime, config) {
|
|
4078
|
+
function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
2911
4079
|
const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
|
|
2912
4080
|
? config.healthPath
|
|
2913
4081
|
: '/__dsh_autorestart/health'
|
|
@@ -3016,6 +4184,14 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3016
4184
|
// the restart).
|
|
3017
4185
|
parkUrl: resolveParkPath(config),
|
|
3018
4186
|
recoverUrl: resolveRecoverPath(config),
|
|
4187
|
+
// The route that persists the one-time install choice, plus the
|
|
4188
|
+
// value that is effective right now.
|
|
4189
|
+
choiceUrl: resolveChoicePath(config),
|
|
4190
|
+
// The route that reports (and records) the one-time feature
|
|
4191
|
+
// announcements, so the client never has to guess the prefix.
|
|
4192
|
+
announcementsUrl: resolveAnnouncementsPath(config),
|
|
4193
|
+
backupOnRestart: config.backupOnRestart !== false,
|
|
4194
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3019
4195
|
parkedGoals: parkedGoalList(runtime),
|
|
3020
4196
|
recovery: recoveryList(live),
|
|
3021
4197
|
})
|
|
@@ -3030,6 +4206,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3030
4206
|
quietForMs: 0,
|
|
3031
4207
|
parkUrl: resolveParkPath(config),
|
|
3032
4208
|
recoverUrl: resolveRecoverPath(config),
|
|
4209
|
+
choiceUrl: resolveChoicePath(config),
|
|
4210
|
+
announcementsUrl: resolveAnnouncementsPath(config),
|
|
4211
|
+
backupOnRestart: config.backupOnRestart !== false,
|
|
4212
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3033
4213
|
parkedGoals: [],
|
|
3034
4214
|
recovery: [],
|
|
3035
4215
|
})
|
|
@@ -3086,6 +4266,9 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3086
4266
|
const parkRoute = buildParkRoute(ctx, runtime, config)
|
|
3087
4267
|
const recoverRoute = buildRecoverRoute(ctx, runtime, config)
|
|
3088
4268
|
const handoffRoute = buildHandoffRoute(ctx, runtime, config)
|
|
4269
|
+
const choiceRoute = buildChoiceRoute(ctx, runtime, config)
|
|
4270
|
+
const announcementsRoute = buildAnnouncementsRoute(ctx, runtime, config)
|
|
4271
|
+
const sleepRoute = sleep ? buildSleepRoute(ctx, runtime, config, sleep) : null
|
|
3089
4272
|
try {
|
|
3090
4273
|
const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
|
|
3091
4274
|
const portMatch = /:(\d+)\/?$/.exec(origin)
|
|
@@ -3112,6 +4295,23 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3112
4295
|
} catch (error) {
|
|
3113
4296
|
runtime.log.warn('could not register the handoff route:', String(error))
|
|
3114
4297
|
}
|
|
4298
|
+
try {
|
|
4299
|
+
target.effect(() => webServer.register(choiceRoute), 'dsh-autorestart: choice route')
|
|
4300
|
+
} catch (error) {
|
|
4301
|
+
runtime.log.warn('could not register the choice route:', String(error))
|
|
4302
|
+
}
|
|
4303
|
+
try {
|
|
4304
|
+
target.effect(() => webServer.register(announcementsRoute), 'dsh-autorestart: announcements route')
|
|
4305
|
+
} catch (error) {
|
|
4306
|
+
runtime.log.warn('could not register the announcements route:', String(error))
|
|
4307
|
+
}
|
|
4308
|
+
if (sleepRoute !== null) {
|
|
4309
|
+
try {
|
|
4310
|
+
target.effect(() => webServer.register(sleepRoute), 'dsh-autorestart: sleep route')
|
|
4311
|
+
} catch (error) {
|
|
4312
|
+
runtime.log.warn('could not register the sleep route:', String(error))
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
3115
4315
|
if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
|
|
3116
4316
|
try {
|
|
3117
4317
|
target.effect(
|
|
@@ -3242,11 +4442,36 @@ function trackAgents(ctx, runtime, config) {
|
|
|
3242
4442
|
* Plugin entry point.
|
|
3243
4443
|
* @param ctx - Cordis plugin context.
|
|
3244
4444
|
* @param rawConfig - the loader row config (merged over the built-in defaults).
|
|
4445
|
+
* @param {{
|
|
4446
|
+
* sleepNow?: () => number,
|
|
4447
|
+
* sleepSpawn?: Function,
|
|
4448
|
+
* }} [options] - the injectable primitives of the graceful sleep: they replace
|
|
4449
|
+
* the clock and the process spawner, so the whole flow can be driven without
|
|
4450
|
+
* waiting and without ever touching the machine. Production never passes them.
|
|
3245
4451
|
*/
|
|
3246
|
-
export function apply(ctx, rawConfig) {
|
|
4452
|
+
export function apply(ctx, rawConfig, options = {}) {
|
|
3247
4453
|
const config = normalizeConfig(rawConfig)
|
|
3248
|
-
|
|
4454
|
+
// The one-time install choice lives in the profile row config (the single
|
|
4455
|
+
// source of truth). Only when the row does not set the key at all does the
|
|
4456
|
+
// degraded fallback file get a say.
|
|
4457
|
+
const stateDir = resolveStateDir(config)
|
|
4458
|
+
const choice = resolveBackupIncludePlugins(rawConfig, stateDir)
|
|
4459
|
+
config.backupIncludePlugins = choice.includePlugins
|
|
4460
|
+
// The same rule for the pre-restart backup itself: the profile row decides,
|
|
4461
|
+
// the fallback file fills the gap, the built-in default applies otherwise.
|
|
4462
|
+
const backupChoice = resolveBackupOnRestart(rawConfig, stateDir)
|
|
4463
|
+
config.backupOnRestart = backupChoice.backupOnRestart
|
|
4464
|
+
// The same rule for the graceful-sleep button: the profile row decides, the
|
|
4465
|
+
// fallback file fills the gap, the built-in default applies otherwise.
|
|
4466
|
+
const sleepChoice = resolveShowSleepButton(rawConfig, stateDir)
|
|
4467
|
+
config.showSleepButton = sleepChoice.showSleepButton
|
|
4468
|
+
const runtime = createRuntime(ctx, config, {
|
|
4469
|
+
backupChoiceSource: choice.source,
|
|
4470
|
+
})
|
|
3249
4471
|
const log = runtime.log
|
|
4472
|
+
// The graceful sleep: the host plan and the state machine that waits for the
|
|
4473
|
+
// conversations, counts down and switches the machine off.
|
|
4474
|
+
const sleep = createSleepService(ctx, runtime, config, options)
|
|
3250
4475
|
|
|
3251
4476
|
log.info('activated', {
|
|
3252
4477
|
stateFile: runtime.stateFile,
|
|
@@ -3256,6 +4481,15 @@ export function apply(ctx, rawConfig) {
|
|
|
3256
4481
|
desktopApp: runtime.env.isDesktopApp,
|
|
3257
4482
|
appExePath: runtime.env.appExePath,
|
|
3258
4483
|
configSchema: Config !== undefined,
|
|
4484
|
+
backupOnRestart: config.backupOnRestart,
|
|
4485
|
+
backupIncludePlugins: config.backupIncludePlugins,
|
|
4486
|
+
backupChoiceSource: choice.source,
|
|
4487
|
+
showSleepButton: config.showSleepButton,
|
|
4488
|
+
sleepChoiceSource: sleepChoice.source,
|
|
4489
|
+
// The FINAL plan: what the sleep feature would really do.
|
|
4490
|
+
sleepHost: sleep.invocation().kind,
|
|
4491
|
+
sleepSource: sleep.invocation().source,
|
|
4492
|
+
sleepSupported: sleep.invocation().supported,
|
|
3259
4493
|
})
|
|
3260
4494
|
if (Config === undefined) {
|
|
3261
4495
|
log.warn('Config schema is unavailable in this build (schemastery could not be loaded); configuration still works through cordis.patch.yml and the built-in defaults')
|
|
@@ -3285,13 +4519,25 @@ export function apply(ctx, rawConfig) {
|
|
|
3285
4519
|
registerTools(ctx, runtime, config)
|
|
3286
4520
|
registerManualRestartGuard(ctx, runtime, config)
|
|
3287
4521
|
registerSystemPrompt(ctx, runtime)
|
|
3288
|
-
registerHealthRoute(ctx, runtime, config)
|
|
4522
|
+
registerHealthRoute(ctx, runtime, config, sleep)
|
|
3289
4523
|
|
|
3290
4524
|
ctx.effect(() => () => {
|
|
3291
4525
|
runtime.markDisposed()
|
|
3292
4526
|
runtime.stopPolling()
|
|
4527
|
+
// The countdown timer belongs to the plugin: it must not survive it.
|
|
4528
|
+
sleep.controller.dispose()
|
|
3293
4529
|
persistSafely(runtime)
|
|
3294
4530
|
}, 'dsh-autorestart lifecycle')
|
|
3295
4531
|
}
|
|
3296
4532
|
|
|
3297
|
-
export {
|
|
4533
|
+
export {
|
|
4534
|
+
DEFAULTS,
|
|
4535
|
+
normalizeConfig,
|
|
4536
|
+
resolveStateFile,
|
|
4537
|
+
resolveStateDir,
|
|
4538
|
+
resolveChoicePath,
|
|
4539
|
+
resolveSleepPath,
|
|
4540
|
+
resolveAnnouncementsPath,
|
|
4541
|
+
featureAnnouncements,
|
|
4542
|
+
pluginVersion,
|
|
4543
|
+
}
|