dsh-autorestart 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/client.js +467 -0
- package/cordis.patch.yml +3 -0
- package/docs/README.zh.md +27 -0
- package/index.js +486 -69
- package/locale/en.json +13 -0
- package/locale/zh.json +13 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -173,6 +173,9 @@ export const inject = ['agents', 'goals', 'tools', 'timer', 'systemPrompt', 'ses
|
|
|
173
173
|
* the real schema and project defaults; `normalizeConfig` still re-applies the same
|
|
174
174
|
* defaults for direct `apply()` calls in tests.
|
|
175
175
|
*
|
|
176
|
+
* Every key carries a one-sentence description, so the settings page explains what
|
|
177
|
+
* each knob does instead of showing a bare name.
|
|
178
|
+
*
|
|
176
179
|
* Construction is guarded: a wrong/foreign schemastery fallback must never turn a
|
|
177
180
|
* missing schema into a failed plugin activation, and the coarsened `schemasterySource`
|
|
178
181
|
* is reported by the health route instead of an absolute install path.
|
|
@@ -181,38 +184,63 @@ function buildConfigSchema() {
|
|
|
181
184
|
if (schemastery === undefined) return undefined
|
|
182
185
|
try {
|
|
183
186
|
return schemastery.object({
|
|
184
|
-
enabled: schemastery.boolean().default(true)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
187
|
+
enabled: schemastery.boolean().default(true)
|
|
188
|
+
.description('Master switch: when false the plugin loads but does nothing.'),
|
|
189
|
+
stateFile: schemastery.string().default('')
|
|
190
|
+
.description('Absolute path of the durable checkpoint file; empty means <DSH_HOME>/storages/dsh-autorestart/state.json.'),
|
|
191
|
+
resumeOnBoot: schemastery.boolean().default(true)
|
|
192
|
+
.description('Resume interrupted sessions automatically after the host starts again.'),
|
|
193
|
+
recoveryDelayMs: schemastery.number().min(0).default(6000)
|
|
194
|
+
.description('Milliseconds the plugin waits after boot before it recovers interrupted sessions.'),
|
|
195
|
+
maxSessions: schemastery.number().step(1).min(1).default(4)
|
|
196
|
+
.description('How many interrupted sessions one recovery pass resumes.'),
|
|
197
|
+
maxResumeAttempts: schemastery.number().step(1).min(1).default(2)
|
|
198
|
+
.description('How often a single session is retried before the recovery gives up on it.'),
|
|
199
|
+
deferPollMs: schemastery.number().min(250).default(3000)
|
|
200
|
+
.description('Milliseconds between two restart-gate checks.'),
|
|
201
|
+
deferSoftMs: schemastery.number().min(0).default(300000)
|
|
202
|
+
.description('Milliseconds after which a pending restart is announced as waiting for a long time.'),
|
|
203
|
+
deferHardMs: schemastery.number().min(0).default(900000)
|
|
204
|
+
.description('Milliseconds after which a pending restart gives up, unless forceRestartWhenBusy is enabled.'),
|
|
205
|
+
deferPolicy: schemastery.union(['auto', 'wait']).default('auto')
|
|
206
|
+
.description('How a pending restart treats a busy conversation: auto waits for it, wait blocks until every conversation is idle.'),
|
|
194
207
|
// hard deadline only forces a restart over a running conversation when enabled
|
|
195
|
-
forceRestartWhenBusy: schemastery.boolean().default(false)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
208
|
+
forceRestartWhenBusy: schemastery.boolean().default(false)
|
|
209
|
+
.description('Let the hard deadline restart the host even while a conversation is still running.'),
|
|
210
|
+
restartMode: schemastery.union(['app', 'command']).default('app')
|
|
211
|
+
.description('How the host is restarted: app relaunches the desktop app, command runs restartCommand.'),
|
|
212
|
+
restartCommand: schemastery.string().default('')
|
|
213
|
+
.description('Shell command used when restartMode is "command"; empty means there is no custom command.'),
|
|
214
|
+
relaunchDelaySeconds: schemastery.number().min(0).default(2)
|
|
215
|
+
.description('Seconds between the kill of the old host and the relaunch of the new one.'),
|
|
199
216
|
// quiet window that must pass after the last conversation activity
|
|
200
|
-
settleMs: schemastery.number().min(0).default(2000)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
217
|
+
settleMs: schemastery.number().min(0).default(2000)
|
|
218
|
+
.description('Milliseconds the host must stay quiet after the last conversation activity before a restart may fire.'),
|
|
219
|
+
autoReloadPage: schemastery.boolean().default(true)
|
|
220
|
+
.description('Reload the web page automatically when the host boot token changes.'),
|
|
221
|
+
blockManualRestart: schemastery.boolean().default(true)
|
|
222
|
+
.description('Block shell commands that would kill or relaunch DeepSeek Harness by hand.'),
|
|
223
|
+
healthPath: schemastery.string().default('/__dsh_autorestart/health')
|
|
224
|
+
.description('HTTP path of the read-only health route used by the client UI and by verification scripts.'),
|
|
225
|
+
continuationText: schemastery.string().default('')
|
|
226
|
+
.description('Custom text injected as the continuation message; empty means the built-in notice.'),
|
|
205
227
|
// pause between two recovery batches (each batch resumes maxSessions sessions)
|
|
206
|
-
recoveryBatchDelayMs: schemastery.number().min(0).default(1200)
|
|
228
|
+
recoveryBatchDelayMs: schemastery.number().min(0).default(1200)
|
|
229
|
+
.description('Milliseconds between two recovery batches.'),
|
|
207
230
|
// pause between two resolveAgent attempts inside one boot
|
|
208
|
-
resumeRetryDelayMs: schemastery.number().min(0).default(1000)
|
|
231
|
+
resumeRetryDelayMs: schemastery.number().min(0).default(1000)
|
|
232
|
+
.description('Milliseconds between two resolveAgent attempts inside one boot.'),
|
|
209
233
|
// pre-restart snapshot + automatic rollback when the host never comes back
|
|
210
|
-
backupOnRestart: schemastery.boolean().default(true)
|
|
234
|
+
backupOnRestart: schemastery.boolean().default(true)
|
|
235
|
+
.description('Take a snapshot of the profile before every restart, so a host that cannot boot again is rolled back automatically.'),
|
|
211
236
|
// include the installed plugin tree in the snapshot (bigger, but it makes a
|
|
212
237
|
// rollback of "a plugin upgrade broke the boot" possible)
|
|
213
|
-
backupIncludePlugins: schemastery.boolean().default(true)
|
|
214
|
-
|
|
215
|
-
|
|
238
|
+
backupIncludePlugins: schemastery.boolean().default(true)
|
|
239
|
+
.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.'),
|
|
240
|
+
maxBackups: schemastery.number().step(1).min(1).default(2)
|
|
241
|
+
.description('How many snapshots are kept; older ones are deleted automatically.'),
|
|
242
|
+
rollbackTimeoutMs: schemastery.number().min(1000).default(90000)
|
|
243
|
+
.description('Milliseconds the detached watcher waits for the relaunched host before it restores the snapshot.'),
|
|
216
244
|
})
|
|
217
245
|
} catch (error) {
|
|
218
246
|
schemasterySource = `construction-failed: ${String(error)}`
|
|
@@ -328,6 +356,20 @@ function resolveHandoffPath(config) {
|
|
|
328
356
|
return `${prefix}/handoff`
|
|
329
357
|
}
|
|
330
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Resolve the choice-route path: a sibling of the health route, under the same
|
|
361
|
+
* prefix (`POST /choice` persists the one-time install choice of the user).
|
|
362
|
+
*
|
|
363
|
+
* @param {object} config
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function resolveChoicePath(config) {
|
|
367
|
+
const healthPath = resolveHealthPath(config)
|
|
368
|
+
const index = healthPath.lastIndexOf('/')
|
|
369
|
+
const prefix = index > 0 ? healthPath.slice(0, index) : '/__dsh_autorestart'
|
|
370
|
+
return `${prefix}/choice`
|
|
371
|
+
}
|
|
372
|
+
|
|
331
373
|
/**
|
|
332
374
|
* The blocking sessions with the reason each one blocks: a session that is
|
|
333
375
|
* held by an armed goal reports `goal-active` (that goal can be disarmed), a
|
|
@@ -484,6 +526,86 @@ function resolveStateFile(config) {
|
|
|
484
526
|
return path.join(home, 'storages', 'dsh-autorestart', 'state.json')
|
|
485
527
|
}
|
|
486
528
|
|
|
529
|
+
/**
|
|
530
|
+
* The directory the plugin owns its durable state in.
|
|
531
|
+
*
|
|
532
|
+
* It is the directory of the checkpoint file, so a custom `stateFile` moves the
|
|
533
|
+
* whole state directory with it (the backups live next to it as well).
|
|
534
|
+
*
|
|
535
|
+
* @param {object} config
|
|
536
|
+
* @returns {string}
|
|
537
|
+
*/
|
|
538
|
+
function resolveStateDir(config) {
|
|
539
|
+
return path.dirname(resolveStateFile(config))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The file the DEGRADED choice path writes to.
|
|
544
|
+
*
|
|
545
|
+
* It is only used when the official `configEditor` service is unavailable: the
|
|
546
|
+
* profile configuration stays the single source of truth, and this file is the
|
|
547
|
+
* last-resort record of a choice the Host could not persist yet.
|
|
548
|
+
*/
|
|
549
|
+
const INSTALL_CHOICE_FILE = 'install-choice.json'
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Read the recorded install choice from the fallback file.
|
|
553
|
+
*
|
|
554
|
+
* @param {string} stateDir
|
|
555
|
+
* @returns {boolean|null} the recorded value, or null when there is none
|
|
556
|
+
*/
|
|
557
|
+
function readInstallChoice(stateDir) {
|
|
558
|
+
try {
|
|
559
|
+
const raw = fs.readFileSync(path.join(stateDir, INSTALL_CHOICE_FILE), 'utf8')
|
|
560
|
+
const parsed = JSON.parse(raw)
|
|
561
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
562
|
+
return typeof parsed.includePlugins === 'boolean' ? parsed.includePlugins : null
|
|
563
|
+
} catch {
|
|
564
|
+
return null
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Write the install choice into the fallback file.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} stateDir
|
|
572
|
+
* @param {boolean} includePlugins
|
|
573
|
+
* @returns {string} the file that was written
|
|
574
|
+
*/
|
|
575
|
+
function writeInstallChoice(stateDir, includePlugins) {
|
|
576
|
+
const file = path.join(stateDir, INSTALL_CHOICE_FILE)
|
|
577
|
+
fs.mkdirSync(stateDir, { recursive: true })
|
|
578
|
+
fs.writeFileSync(
|
|
579
|
+
file,
|
|
580
|
+
JSON.stringify({ includePlugins: includePlugins === true, at: new Date().toISOString() }, null, 2),
|
|
581
|
+
'utf8',
|
|
582
|
+
)
|
|
583
|
+
return file
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Resolve the effective `backupIncludePlugins` value and where it came from.
|
|
588
|
+
*
|
|
589
|
+
* The profile row config always wins: it is the single source of truth (the
|
|
590
|
+
* settings page and the one-time install prompt write the same key). Only when
|
|
591
|
+
* the row config does not set the key at all does the degraded fallback file
|
|
592
|
+
* get a say, and only when it does not exist does the built-in default apply.
|
|
593
|
+
*
|
|
594
|
+
* @param {object|null|undefined} rawConfig the loader row config, as received
|
|
595
|
+
* @param {string} stateDir
|
|
596
|
+
* @returns {{ includePlugins: boolean, source: 'config'|'fallback-file'|'default' }}
|
|
597
|
+
*/
|
|
598
|
+
export function resolveBackupIncludePlugins(rawConfig, stateDir) {
|
|
599
|
+
if (rawConfig && typeof rawConfig === 'object'
|
|
600
|
+
&& Object.prototype.hasOwnProperty.call(rawConfig, 'backupIncludePlugins')
|
|
601
|
+
&& typeof rawConfig.backupIncludePlugins === 'boolean') {
|
|
602
|
+
return { includePlugins: rawConfig.backupIncludePlugins, source: 'config' }
|
|
603
|
+
}
|
|
604
|
+
const fallback = typeof stateDir === 'string' && stateDir !== '' ? readInstallChoice(stateDir) : null
|
|
605
|
+
if (fallback !== null) return { includePlugins: fallback, source: 'fallback-file' }
|
|
606
|
+
return { includePlugins: DEFAULTS.backupIncludePlugins, source: 'default' }
|
|
607
|
+
}
|
|
608
|
+
|
|
487
609
|
/**
|
|
488
610
|
* Resolve the profile directory the running host was started from.
|
|
489
611
|
*
|
|
@@ -553,6 +675,76 @@ function resolveProfileDir() {
|
|
|
553
675
|
return resolveProfileDirDetailed().dir
|
|
554
676
|
}
|
|
555
677
|
|
|
678
|
+
/**
|
|
679
|
+
* The pre-restart snapshot: copy the profile into `<stateDir>/backups/<stamp>/`
|
|
680
|
+
* and clear the rollback report an older boot may have left behind.
|
|
681
|
+
*
|
|
682
|
+
* This is the ONE backup entry of the plugin. EVERY restart mode takes its
|
|
683
|
+
* snapshot through this function — the desktop app relaunch AND the custom
|
|
684
|
+
* restart command — so a hosted host that is restarted by a command is just as
|
|
685
|
+
* recoverable as the desktop app. The configuration files decide whether every
|
|
686
|
+
* bundle loads, so a snapshot is what makes a "the host does not come back"
|
|
687
|
+
* rollback possible at all.
|
|
688
|
+
*
|
|
689
|
+
* A failing backup never blocks the restart: the reason is logged and the
|
|
690
|
+
* caller continues without a rollback point.
|
|
691
|
+
*
|
|
692
|
+
* @param {{
|
|
693
|
+
* config?: object,
|
|
694
|
+
* stateDir?: string,
|
|
695
|
+
* log?: { warn: Function },
|
|
696
|
+
* profile?: { dir?: string|null, source?: string },
|
|
697
|
+
* createBackup?: Function,
|
|
698
|
+
* }} [options]
|
|
699
|
+
* @returns {{ backup: object|null, profileDir: string|null, profile: { dir: string|null, source: string } }}
|
|
700
|
+
*/
|
|
701
|
+
export function takePreRestartSnapshot({
|
|
702
|
+
config = {},
|
|
703
|
+
stateDir = '',
|
|
704
|
+
log = console,
|
|
705
|
+
profile = null,
|
|
706
|
+
createBackup: createBackupFn = createBackup,
|
|
707
|
+
} = {}) {
|
|
708
|
+
const info = profile && typeof profile === 'object'
|
|
709
|
+
? { dir: profile.dir ?? null, source: profile.source ?? 'unknown' }
|
|
710
|
+
: { dir: null, source: 'unknown' }
|
|
711
|
+
const profileDir = typeof info.dir === 'string' && info.dir.trim() !== '' ? info.dir : null
|
|
712
|
+
// The snapshot is optional: the operator can turn it off entirely.
|
|
713
|
+
if (config.backupOnRestart !== true) return { backup: null, profileDir, profile: info }
|
|
714
|
+
if (profileDir === null) {
|
|
715
|
+
log.warn('no profile directory is known; skipping the pre-restart backup', { source: info.source })
|
|
716
|
+
return { backup: null, profileDir: null, profile: info }
|
|
717
|
+
}
|
|
718
|
+
let backup = null
|
|
719
|
+
try {
|
|
720
|
+
backup = createBackupFn({
|
|
721
|
+
profileDir,
|
|
722
|
+
backupsDir: backupsRoot(stateDir),
|
|
723
|
+
includePlugins: config.backupIncludePlugins !== false,
|
|
724
|
+
maxBackups: config.maxBackups,
|
|
725
|
+
})
|
|
726
|
+
} catch (error) {
|
|
727
|
+
// A backup that throws must not take the restart down with it.
|
|
728
|
+
backup = { ok: false, error: String(error) }
|
|
729
|
+
}
|
|
730
|
+
if (backup && backup.ok === true) {
|
|
731
|
+
log.warn('pre-restart backup written', {
|
|
732
|
+
dir: backup.dir,
|
|
733
|
+
bytes: backup.bytes,
|
|
734
|
+
includePlugins: backup.includePlugins,
|
|
735
|
+
pruned: backup.pruned,
|
|
736
|
+
profileDirSource: info.source,
|
|
737
|
+
})
|
|
738
|
+
// A report from an older boot must not be mistaken for this one.
|
|
739
|
+
clearRollbackReport(stateDir)
|
|
740
|
+
} else {
|
|
741
|
+
log.warn('the pre-restart backup failed; continuing without a rollback point', {
|
|
742
|
+
error: backup && backup.error ? backup.error : 'unknown error',
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
return { backup, profileDir, profile: info }
|
|
746
|
+
}
|
|
747
|
+
|
|
556
748
|
/** A logger that never throws and never requires a particular Cordis logger shape. */
|
|
557
749
|
function createLogger(ctx) {
|
|
558
750
|
const logger = ctx.logger
|
|
@@ -596,10 +788,91 @@ function isRootAgent(ctx, agent) {
|
|
|
596
788
|
}
|
|
597
789
|
}
|
|
598
790
|
|
|
599
|
-
/**
|
|
600
|
-
|
|
791
|
+
/**
|
|
792
|
+
* Resolve the official `configEditor` service, or null when it is unavailable.
|
|
793
|
+
*
|
|
794
|
+
* The service is optional: the plugin must load in a composition that does not
|
|
795
|
+
* provide it, and the one-time install choice then falls back to a file.
|
|
796
|
+
*
|
|
797
|
+
* @param {object} ctx
|
|
798
|
+
* @returns {any|null}
|
|
799
|
+
*/
|
|
800
|
+
function resolveConfigEditor(ctx) {
|
|
801
|
+
try {
|
|
802
|
+
if (ctx && ctx.configEditor && typeof ctx.configEditor.edit === 'function') return ctx.configEditor
|
|
803
|
+
} catch {}
|
|
804
|
+
try {
|
|
805
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
806
|
+
const service = ctx.get('configEditor')
|
|
807
|
+
if (service && typeof service.edit === 'function') return service
|
|
808
|
+
}
|
|
809
|
+
} catch {}
|
|
810
|
+
return null
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Find this plugin's own row among the profile entries.
|
|
815
|
+
*
|
|
816
|
+
* The service hands out the LOADER's own entries, so the row id lives in
|
|
817
|
+
* `entry.options.id` (the id the bundle layer inserts: `dsh-autorestart`).
|
|
818
|
+
* A projected entry (the shape the settings UI shows) carries the same id as
|
|
819
|
+
* `patchId` or `id`, so both are accepted as well.
|
|
820
|
+
*
|
|
821
|
+
* @param {any} editor the configEditor service
|
|
822
|
+
* @returns {any|null} the entry, or null when this plugin has no row
|
|
823
|
+
*/
|
|
824
|
+
function findOwnEntry(editor) {
|
|
825
|
+
let entries = []
|
|
826
|
+
try {
|
|
827
|
+
entries = typeof editor.entries === 'function' ? editor.entries() : []
|
|
828
|
+
} catch {
|
|
829
|
+
return null
|
|
830
|
+
}
|
|
831
|
+
if (!Array.isArray(entries)) return null
|
|
832
|
+
for (const entry of entries) {
|
|
833
|
+
if (!entry || typeof entry !== 'object') continue
|
|
834
|
+
const options = entry.options && typeof entry.options === 'object' ? entry.options : null
|
|
835
|
+
const candidates = [
|
|
836
|
+
options ? options.id : undefined,
|
|
837
|
+
entry.patchId,
|
|
838
|
+
entry.id,
|
|
839
|
+
]
|
|
840
|
+
for (const candidate of candidates) {
|
|
841
|
+
if (candidate === undefined || candidate === null) continue
|
|
842
|
+
if (String(candidate) === name) return entry
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return null
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* The plugin's whole runtime state.
|
|
850
|
+
*
|
|
851
|
+
* @param {object} ctx
|
|
852
|
+
* @param {object} config the normalized config
|
|
853
|
+
* @param {{
|
|
854
|
+
* backupChoiceSource?: string,
|
|
855
|
+
* env?: object,
|
|
856
|
+
* snapshot?: Function,
|
|
857
|
+
* runCommand?: Function,
|
|
858
|
+
* scheduleDetached?: Function,
|
|
859
|
+
* }} [options] `env` replaces parts of the detected environment and the three
|
|
860
|
+
* functions replace the side-effecting primitives of the restart path. They
|
|
861
|
+
* exist so a restart can be exercised without spawning a process or touching
|
|
862
|
+
* the real profile; production never passes them.
|
|
863
|
+
*/
|
|
864
|
+
export function createRuntime(ctx, config, options = {}) {
|
|
601
865
|
const log = createLogger(ctx)
|
|
602
|
-
const env =
|
|
866
|
+
const env = {
|
|
867
|
+
...detectEnvironment(),
|
|
868
|
+
...(options.env && typeof options.env === 'object' ? options.env : {}),
|
|
869
|
+
}
|
|
870
|
+
// The ONE pre-restart snapshot entry: every restart mode uses it.
|
|
871
|
+
const snapshotFn = typeof options.snapshot === 'function' ? options.snapshot : takePreRestartSnapshot
|
|
872
|
+
const runCommandFn = typeof options.runCommand === 'function' ? options.runCommand : runCommandNow
|
|
873
|
+
const scheduleDetachedFn = typeof options.scheduleDetached === 'function'
|
|
874
|
+
? options.scheduleDetached
|
|
875
|
+
: scheduleDetachedRestart
|
|
603
876
|
const stateFile = resolveStateFile(config)
|
|
604
877
|
|
|
605
878
|
const loaded = readState(stateFile, Date.now())
|
|
@@ -1246,7 +1519,32 @@ function createRuntime(ctx, config) {
|
|
|
1246
1519
|
persist()
|
|
1247
1520
|
log.warn('executing graceful restart', { generation: state.generation, pid: process.pid })
|
|
1248
1521
|
try {
|
|
1249
|
-
|
|
1522
|
+
// How the host is restarted: the custom command, or the desktop app.
|
|
1523
|
+
const useCustomCommand = config.restartMode === 'command' && config.restartCommand.trim() !== ''
|
|
1524
|
+
const canRelaunchDesktop = Boolean(env.isDesktopApp && env.appExePath)
|
|
1525
|
+
if (!useCustomCommand && !canRelaunchDesktop) {
|
|
1526
|
+
// No restart method at all (the host is not the desktop app and there is
|
|
1527
|
+
// no custom command): nothing is restarted, so nothing is snapshotted
|
|
1528
|
+
// either — there is no rollback point for a restart that never happens.
|
|
1529
|
+
log.warn('no restart method available (not the desktop app and restartCommand is empty); leaving the checkpoint in place')
|
|
1530
|
+
rollbackRestart('no restart method available')
|
|
1531
|
+
return
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// ONE snapshot for EVERY restart mode: the profile is copied BEFORE
|
|
1535
|
+
// anything is killed, no matter how the host is restarted. The desktop
|
|
1536
|
+
// app relaunch and the custom restart command share this single entry, so
|
|
1537
|
+
// a hosted host that is restarted by a command is just as recoverable as
|
|
1538
|
+
// the desktop app.
|
|
1539
|
+
const stateDir = path.dirname(stateFile)
|
|
1540
|
+
const snapshot = snapshotFn({
|
|
1541
|
+
config,
|
|
1542
|
+
stateDir,
|
|
1543
|
+
log,
|
|
1544
|
+
profile: resolveProfileDirDetailed(),
|
|
1545
|
+
})
|
|
1546
|
+
|
|
1547
|
+
if (useCustomCommand) {
|
|
1250
1548
|
const command = buildCustomCommand({
|
|
1251
1549
|
template: config.restartCommand,
|
|
1252
1550
|
pid: process.pid,
|
|
@@ -1254,9 +1552,10 @@ function createRuntime(ctx, config) {
|
|
|
1254
1552
|
appExePath: env.appExePath || '',
|
|
1255
1553
|
})
|
|
1256
1554
|
log.warn('running custom restart command:', command)
|
|
1257
|
-
|
|
1555
|
+
// A failed snapshot never blocks the restart: the command runs anyway.
|
|
1556
|
+
const outcome = runCommandFn({ command })
|
|
1258
1557
|
if (outcome && outcome.ok === false) rollbackRestart(outcome.error || 'custom command failed')
|
|
1259
|
-
} else
|
|
1558
|
+
} else {
|
|
1260
1559
|
const aborts = Number(state.restart.gateAborts) || 0
|
|
1261
1560
|
const url = aborts > 20 ? null : restartGateUrl()
|
|
1262
1561
|
if (aborts > 20) {
|
|
@@ -1264,37 +1563,9 @@ function createRuntime(ctx, config) {
|
|
|
1264
1563
|
} else if (url === null) {
|
|
1265
1564
|
log.warn('the web server port is unknown; scheduling an UNGATED restart (the helper cannot re-check the idle gate)')
|
|
1266
1565
|
}
|
|
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
|
-
|
|
1566
|
+
// The snapshot was already taken above (the shared entry).
|
|
1567
|
+
const backup = snapshot && snapshot.backup ? snapshot.backup : null
|
|
1568
|
+
const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
|
|
1298
1569
|
const healthUrl = restartHealthUrl()
|
|
1299
1570
|
const watchScriptPath = backup && backup.ok === true && healthUrl !== null && profileDir !== null
|
|
1300
1571
|
? path.join(stateDir, 'restart-' + state.generation + '-watch.ps1')
|
|
@@ -1311,7 +1582,7 @@ function createRuntime(ctx, config) {
|
|
|
1311
1582
|
// Only a crash log written after this point belongs to this restart.
|
|
1312
1583
|
since: new Date().toISOString(),
|
|
1313
1584
|
})
|
|
1314
|
-
const result =
|
|
1585
|
+
const result = scheduleDetachedFn({
|
|
1315
1586
|
appExePath: env.appExePath,
|
|
1316
1587
|
relaunchDelaySeconds: config.relaunchDelaySeconds,
|
|
1317
1588
|
gateUrl: url,
|
|
@@ -1322,9 +1593,6 @@ function createRuntime(ctx, config) {
|
|
|
1322
1593
|
log.warn('scheduled detached restart:', result)
|
|
1323
1594
|
if (!result || result.ok !== true) rollbackRestart((result && result.error) || 'detached restart refused')
|
|
1324
1595
|
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
1596
|
}
|
|
1329
1597
|
} catch (error) {
|
|
1330
1598
|
rollbackRestart(String(error))
|
|
@@ -1396,6 +1664,14 @@ function createRuntime(ctx, config) {
|
|
|
1396
1664
|
get bootId() {
|
|
1397
1665
|
return bootId
|
|
1398
1666
|
},
|
|
1667
|
+
/**
|
|
1668
|
+
* Where the effective `backupIncludePlugins` value came from:
|
|
1669
|
+
* `config` (the profile row), `fallback-file` (the degraded path) or
|
|
1670
|
+
* `default` (nothing was ever chosen).
|
|
1671
|
+
*/
|
|
1672
|
+
get backupChoiceSource() {
|
|
1673
|
+
return options.backupChoiceSource || 'default'
|
|
1674
|
+
},
|
|
1399
1675
|
get isNewBoot() {
|
|
1400
1676
|
return boot.isNewBoot
|
|
1401
1677
|
},
|
|
@@ -1430,6 +1706,53 @@ function createRuntime(ctx, config) {
|
|
|
1430
1706
|
stateFile,
|
|
1431
1707
|
log,
|
|
1432
1708
|
persist,
|
|
1709
|
+
/**
|
|
1710
|
+
* Persist the user's one-time install choice: whether the pre-restart
|
|
1711
|
+
* backup also snapshots the installed plugin tree.
|
|
1712
|
+
*
|
|
1713
|
+
* The official `configEditor` service is the primary path: the choice goes
|
|
1714
|
+
* into the profile row of this plugin, so the settings page and this prompt
|
|
1715
|
+
* read and write the same key (one source of truth). When the service is
|
|
1716
|
+
* unavailable the choice degrades to a file in the plugin's state directory
|
|
1717
|
+
* and a warning is logged, so the choice is never silently lost.
|
|
1718
|
+
*
|
|
1719
|
+
* @param {boolean} includePlugins
|
|
1720
|
+
* @returns {Promise<{ applied: 'config'|'fallback-file', includePlugins: boolean }>}
|
|
1721
|
+
*/
|
|
1722
|
+
async applyBackupChoice(includePlugins) {
|
|
1723
|
+
const value = includePlugins === true
|
|
1724
|
+
const editor = resolveConfigEditor(ctx)
|
|
1725
|
+
if (editor !== null) {
|
|
1726
|
+
try {
|
|
1727
|
+
const entry = findOwnEntry(editor)
|
|
1728
|
+
if (entry !== null) {
|
|
1729
|
+
await editor.edit(entry, (current) => {
|
|
1730
|
+
const base = current && typeof current === 'object' ? { ...current } : {}
|
|
1731
|
+
return { ...base, backupIncludePlugins: value }
|
|
1732
|
+
})
|
|
1733
|
+
// The Loader reconciles the row, but the running instance must not
|
|
1734
|
+
// keep the old value until that happens.
|
|
1735
|
+
config.backupIncludePlugins = value
|
|
1736
|
+
log.info(`the install choice was written into the profile configuration: backupIncludePlugins=${value}`)
|
|
1737
|
+
return { applied: 'config', includePlugins: value }
|
|
1738
|
+
}
|
|
1739
|
+
log.warn(`the configEditor service has no profile row for ${name}; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
log.warn(`could not write the install choice into the profile configuration: ${describeError(error)}`)
|
|
1742
|
+
}
|
|
1743
|
+
} else {
|
|
1744
|
+
log.warn(`the configEditor service is unavailable; the install choice degrades to ${INSTALL_CHOICE_FILE}`)
|
|
1745
|
+
}
|
|
1746
|
+
const file = path.join(path.dirname(stateFile), INSTALL_CHOICE_FILE)
|
|
1747
|
+
try {
|
|
1748
|
+
writeInstallChoice(path.dirname(stateFile), value)
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
throw new Error(`could not write the install choice to ${file}: ${describeError(error)}`)
|
|
1751
|
+
}
|
|
1752
|
+
config.backupIncludePlugins = value
|
|
1753
|
+
log.warn(`the install choice was written to ${file} instead of the profile configuration`)
|
|
1754
|
+
return { applied: 'fallback-file', includePlugins: value }
|
|
1755
|
+
},
|
|
1433
1756
|
toolErrors,
|
|
1434
1757
|
startPolling,
|
|
1435
1758
|
stopPolling,
|
|
@@ -2145,7 +2468,8 @@ function registerTools(ctx, runtime, config) {
|
|
|
2145
2468
|
+ '("goal-active" = an armed goal keeps it busy, "goal-exhausted" = its goal can no longer be '
|
|
2146
2469
|
+ 'resumed), the goals this plugin disarmed, the last recovery action/detail per session, '
|
|
2147
2470
|
+ '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 '
|
|
2471
|
+
+ 'the model context window (such a session is never auto-resumed), whether the pre-restart '
|
|
2472
|
+
+ 'backup also snapshots the installed plugins (backupIncludePlugins), the '
|
|
2149
2473
|
+ 'resolved state-file path, and the detected restart environment. Read-only.',
|
|
2150
2474
|
parameters: {
|
|
2151
2475
|
type: 'object',
|
|
@@ -2174,6 +2498,7 @@ function registerTools(ctx, runtime, config) {
|
|
|
2174
2498
|
handoffs: { type: 'array', items: { type: 'object' } },
|
|
2175
2499
|
profileDir: { type: 'string' },
|
|
2176
2500
|
profileDirSource: { type: 'string' },
|
|
2501
|
+
backupIncludePlugins: { type: 'boolean' },
|
|
2177
2502
|
lastRollback: { type: 'object' },
|
|
2178
2503
|
environment: { type: 'object' },
|
|
2179
2504
|
sessions: { type: 'array', items: { type: 'object' } },
|
|
@@ -2201,6 +2526,8 @@ function registerTools(ctx, runtime, config) {
|
|
|
2201
2526
|
// Where the pre-restart backup comes from (null dir = it would be skipped).
|
|
2202
2527
|
profileDirSource: runtime.profileDir.source,
|
|
2203
2528
|
...(runtime.profileDir.dir ? { profileDir: runtime.profileDir.dir } : {}),
|
|
2529
|
+
// Whether the pre-restart backup also snapshots the installed plugins.
|
|
2530
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
2204
2531
|
// Present only when the previous restart had to be rolled back.
|
|
2205
2532
|
...(runtime.lastRollback ? { lastRollback: runtime.lastRollback } : {}),
|
|
2206
2533
|
environment: {
|
|
@@ -2906,6 +3233,77 @@ function buildHandoffRoute(ctx, runtime, config) {
|
|
|
2906
3233
|
}
|
|
2907
3234
|
}
|
|
2908
3235
|
|
|
3236
|
+
/**
|
|
3237
|
+
* Build the POST `/choice` route: persist the one-time install choice of the
|
|
3238
|
+
* user (whether the pre-restart backup also snapshots the installed plugins).
|
|
3239
|
+
*
|
|
3240
|
+
* It sits next to the health route and uses the same authentication rule
|
|
3241
|
+
* (loopback OR a request the connection service admits). Body:
|
|
3242
|
+
* `{ "includePlugins": boolean }`; anything that is not a boolean is refused
|
|
3243
|
+
* with a 400 and NOTHING is written.
|
|
3244
|
+
*
|
|
3245
|
+
* The answer is `{ ok: true, applied: 'config' | 'fallback-file', includePlugins }`:
|
|
3246
|
+
* `applied` says where the choice landed — the profile configuration (the
|
|
3247
|
+
* official path) or the degraded fallback file.
|
|
3248
|
+
*/
|
|
3249
|
+
function buildChoiceRoute(ctx, runtime, config) {
|
|
3250
|
+
const routePath = resolveChoicePath(config)
|
|
3251
|
+
return {
|
|
3252
|
+
kind: 'exact',
|
|
3253
|
+
path: routePath,
|
|
3254
|
+
handler: (req, res) => {
|
|
3255
|
+
const remote = req && req.socket ? req.socket.remoteAddress : undefined
|
|
3256
|
+
const loopback = remote === undefined
|
|
3257
|
+
|| remote === '127.0.0.1'
|
|
3258
|
+
|| remote === '::1'
|
|
3259
|
+
|| remote === '::ffff:127.0.0.1'
|
|
3260
|
+
if (!loopback && !admittedByConnection(ctx, req)) {
|
|
3261
|
+
res.statusCode = 403
|
|
3262
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3263
|
+
res.end('{"ok":false,"error":"loopback-only"}')
|
|
3264
|
+
return
|
|
3265
|
+
}
|
|
3266
|
+
if (!req || req.method !== 'POST') {
|
|
3267
|
+
res.statusCode = 405
|
|
3268
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3269
|
+
res.setHeader('allow', 'POST')
|
|
3270
|
+
res.end('{"ok":false,"error":"method-not-allowed"}')
|
|
3271
|
+
return
|
|
3272
|
+
}
|
|
3273
|
+
void readJsonBody(req).then(async (payload) => {
|
|
3274
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
3275
|
+
const includePlugins = body.includePlugins
|
|
3276
|
+
if (typeof includePlugins !== 'boolean') {
|
|
3277
|
+
// Nothing is written for a malformed choice: the caller's answer
|
|
3278
|
+
// must not change the profile configuration.
|
|
3279
|
+
res.statusCode = 400
|
|
3280
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3281
|
+
res.setHeader('cache-control', 'no-store')
|
|
3282
|
+
res.end(JSON.stringify({
|
|
3283
|
+
ok: false,
|
|
3284
|
+
error: 'includePlugins-must-be-boolean',
|
|
3285
|
+
instruction: 'Send { "includePlugins": true | false }.',
|
|
3286
|
+
}))
|
|
3287
|
+
return
|
|
3288
|
+
}
|
|
3289
|
+
const result = await runtime.applyBackupChoice(includePlugins)
|
|
3290
|
+
res.statusCode = 200
|
|
3291
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3292
|
+
res.setHeader('cache-control', 'no-store')
|
|
3293
|
+
res.end(JSON.stringify({
|
|
3294
|
+
ok: true,
|
|
3295
|
+
applied: result.applied,
|
|
3296
|
+
includePlugins: result.includePlugins === true,
|
|
3297
|
+
}))
|
|
3298
|
+
}).catch((error) => {
|
|
3299
|
+
res.statusCode = 500
|
|
3300
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
3301
|
+
res.end(JSON.stringify({ ok: false, error: describeError(error) }))
|
|
3302
|
+
})
|
|
3303
|
+
},
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
|
|
2909
3307
|
/** Expose a tiny read-only health route for humans and verification scripts. */
|
|
2910
3308
|
function registerHealthRoute(ctx, runtime, config) {
|
|
2911
3309
|
const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
|
|
@@ -3016,6 +3414,10 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3016
3414
|
// the restart).
|
|
3017
3415
|
parkUrl: resolveParkPath(config),
|
|
3018
3416
|
recoverUrl: resolveRecoverPath(config),
|
|
3417
|
+
// The route that persists the one-time install choice, plus the
|
|
3418
|
+
// value that is effective right now.
|
|
3419
|
+
choiceUrl: resolveChoicePath(config),
|
|
3420
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3019
3421
|
parkedGoals: parkedGoalList(runtime),
|
|
3020
3422
|
recovery: recoveryList(live),
|
|
3021
3423
|
})
|
|
@@ -3030,6 +3432,8 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3030
3432
|
quietForMs: 0,
|
|
3031
3433
|
parkUrl: resolveParkPath(config),
|
|
3032
3434
|
recoverUrl: resolveRecoverPath(config),
|
|
3435
|
+
choiceUrl: resolveChoicePath(config),
|
|
3436
|
+
backupIncludePlugins: config.backupIncludePlugins !== false,
|
|
3033
3437
|
parkedGoals: [],
|
|
3034
3438
|
recovery: [],
|
|
3035
3439
|
})
|
|
@@ -3086,6 +3490,7 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3086
3490
|
const parkRoute = buildParkRoute(ctx, runtime, config)
|
|
3087
3491
|
const recoverRoute = buildRecoverRoute(ctx, runtime, config)
|
|
3088
3492
|
const handoffRoute = buildHandoffRoute(ctx, runtime, config)
|
|
3493
|
+
const choiceRoute = buildChoiceRoute(ctx, runtime, config)
|
|
3089
3494
|
try {
|
|
3090
3495
|
const origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin()) : ''
|
|
3091
3496
|
const portMatch = /:(\d+)\/?$/.exec(origin)
|
|
@@ -3112,6 +3517,11 @@ function registerHealthRoute(ctx, runtime, config) {
|
|
|
3112
3517
|
} catch (error) {
|
|
3113
3518
|
runtime.log.warn('could not register the handoff route:', String(error))
|
|
3114
3519
|
}
|
|
3520
|
+
try {
|
|
3521
|
+
target.effect(() => webServer.register(choiceRoute), 'dsh-autorestart: choice route')
|
|
3522
|
+
} catch (error) {
|
|
3523
|
+
runtime.log.warn('could not register the choice route:', String(error))
|
|
3524
|
+
}
|
|
3115
3525
|
if (config.autoReloadPage !== false && typeof webServer.tapIndex === 'function') {
|
|
3116
3526
|
try {
|
|
3117
3527
|
target.effect(
|
|
@@ -3245,7 +3655,12 @@ function trackAgents(ctx, runtime, config) {
|
|
|
3245
3655
|
*/
|
|
3246
3656
|
export function apply(ctx, rawConfig) {
|
|
3247
3657
|
const config = normalizeConfig(rawConfig)
|
|
3248
|
-
|
|
3658
|
+
// The one-time install choice lives in the profile row config (the single
|
|
3659
|
+
// source of truth). Only when the row does not set the key at all does the
|
|
3660
|
+
// degraded fallback file get a say.
|
|
3661
|
+
const choice = resolveBackupIncludePlugins(rawConfig, resolveStateDir(config))
|
|
3662
|
+
config.backupIncludePlugins = choice.includePlugins
|
|
3663
|
+
const runtime = createRuntime(ctx, config, { backupChoiceSource: choice.source })
|
|
3249
3664
|
const log = runtime.log
|
|
3250
3665
|
|
|
3251
3666
|
log.info('activated', {
|
|
@@ -3256,6 +3671,8 @@ export function apply(ctx, rawConfig) {
|
|
|
3256
3671
|
desktopApp: runtime.env.isDesktopApp,
|
|
3257
3672
|
appExePath: runtime.env.appExePath,
|
|
3258
3673
|
configSchema: Config !== undefined,
|
|
3674
|
+
backupIncludePlugins: config.backupIncludePlugins,
|
|
3675
|
+
backupChoiceSource: choice.source,
|
|
3259
3676
|
})
|
|
3260
3677
|
if (Config === undefined) {
|
|
3261
3678
|
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')
|
|
@@ -3294,4 +3711,4 @@ export function apply(ctx, rawConfig) {
|
|
|
3294
3711
|
}, 'dsh-autorestart lifecycle')
|
|
3295
3712
|
}
|
|
3296
3713
|
|
|
3297
|
-
export { DEFAULTS, normalizeConfig, resolveStateFile }
|
|
3714
|
+
export { DEFAULTS, normalizeConfig, resolveStateFile, resolveStateDir, resolveChoicePath }
|