fix(desktop): run updater state.db pre-flight through the installation launcher

PM-managed checkouts retire the in-tree `venv` once a generation is committed,
so `findPythonForRoot` returns null there and the desktop Update button refused
every hand-off with:

  state.db pre-flight failed: Python not found. Update cancelled before
  backend shutdown. Update the selected installation with its hermes update
  command, then retry.

The pre-flight was the only remaining update-path consumer of the checkout-venv
resolution — the source check (`readSourceUpdate`) and the hand-off script
(`scripts/desktop-update/runtime.ps1`) already resolve the installation
launcher for managed checkouts.

`preflightStateDb` now accepts the launcher and runs the emergency snapshot
through it (`--run-module hermes_cli.backup_sqlite`, mirroring the update
check), keeping the legacy `python -I -S <script>` shape for pre-PM installs
and the update check's fail-closed ComSpec wrapping for a `.cmd` launcher. The
composition resolves the launcher off the same `pm` marker as `readSourceUpdate`
and surfaces a repair message before the backend stops when it is missing.

Test: apps/desktop/electron/updater/state-db-preflight.test.ts gains a managed
case that drives the launcher shape end-to-end (shim launcher -> snapshot
published); the legacy-path cases are unchanged.
This commit is contained in:
Hermes Agent
2026-09-26 19:01:42 +01:00
committed by Teknium
parent 55507eab90
commit e33fd7e09b
3 changed files with 111 additions and 6 deletions

View File

@@ -523,6 +523,7 @@ import {
} from './updater'
import {
observeUpdaterHandoff,
resolveInstallationLauncher,
resolveStagedUpdaterBinary,
resolveVenvDir,
spawnUpdaterProcess,
@@ -3552,8 +3553,26 @@ function resolveCheckoutUpdateStrategy(): UpdaterStrategy {
return
}
// PM-managed checkouts carry no venv of their own: the installation
// launcher owns interpreter and generation selection there — same
// contract as readSourceUpdate and the hand-off script.
const managed: boolean = directoryExists(path.join(root, 'pm'))
const launcher: string | null = managed
? resolveInstallationLauncher(root, IS_WINDOWS, HERMES_HOME)
: null
if (managed && !launcher) {
const message =
`state.db pre-flight failed: the installation launcher under ${root} is missing. ` +
'Update cancelled before backend shutdown. Repair this installation before retrying.'
log(`[updates] ${message}`)
throw new Error(message)
}
preflightStateDb({
python: await findPythonForRoot(root),
python: managed ? null : await findPythonForRoot(root),
launcher,
script: path.join(root, 'hermes_cli', 'backup_sqlite.py'),
home,
log

View File

@@ -87,6 +87,62 @@ with sqlite3.connect(sys.argv[1]) as c:
}
})
test('a managed installation runs the snapshot through the installation launcher', (): void => {
const home: string = fs.mkdtempSync(path.join(os.tmpdir(), 'managed-preflight-'))
const shims: string = fs.mkdtempSync(path.join(os.tmpdir(), 'launcher-shim-'))
const python: string = process.env.HERMES_PYTHON || 'python3'
const script: string = fileURLToPath(new URL('../../../../hermes_cli/backup_sqlite.py', import.meta.url))
// Stand-in for the installation launcher under `.hermes/bin`: it must accept
// exactly what the runtime passes it — `--run-module hermes_cli.backup_sqlite
// <home>` — and publish the snapshot like the real launcher does.
const shim: string = path.join(shims, process.platform === 'win32' ? 'hermes.cmd' : 'hermes')
fs.writeFileSync(
shim,
process.platform === 'win32'
? `@echo off\r\n"${python}" -I -S "${script}" %3\r\n`
: `#!/bin/sh\nexec "${python}" -I -S "${script}" "$3"\n`
)
if (process.platform !== 'win32') {
fs.chmodSync(shim, 0o755)
}
const logs: string[] = []
try {
const created = spawnSync(
python,
[
'-I',
'-S',
'-c',
"import sqlite3, sys; c = sqlite3.connect(sys.argv[1]); c.execute('CREATE TABLE t (x)'); c.commit(); c.close()",
path.join(home, 'state.db')
],
{ encoding: 'utf8' }
)
assert.equal(created.status, 0, created.stderr)
preflightStateDb({
python: null,
launcher: shim,
script,
home,
log: (message: string): void => {
logs.push(message)
}
})
const backups: string[] = fs.readdirSync(home).filter((name: string): boolean => name.endsWith('.bak'))
assert.equal(backups.length, 1, logs.join('\n'))
} finally {
fs.rmSync(home, { recursive: true, force: true })
fs.rmSync(shims, { recursive: true, force: true })
}
})
test('an older selected checkout without the snapshot helper refuses before backend stop', (): void => {
const oldRoot: string = fs.mkdtempSync(path.join(os.tmpdir(), 'old-preflight-'))
let stopped = false

View File

@@ -7,19 +7,49 @@ interface StateDbPreflight {
script: string
home: string
log: (message: string) => void
/**
* The installation launcher of a PM-managed checkout (`.hermes/bin/hermes`).
* A managed checkout carries no venv of its own — the launcher owns
* interpreter and generation selection there — so the snapshot runs through
* it exactly like the update check does (`readSourceUpdate`).
*/
launcher?: string | null
}
// Synchronous by design: the caller must not stop the backend before the snapshot.
export function preflightStateDb({ python, script, home, log }: StateDbPreflight): void {
export function preflightStateDb({ python, script, home, log, launcher = null }: StateDbPreflight): void {
try {
if (!python) {
const command: string | null = launcher ?? python
if (!command) {
throw new Error('Python not found')
}
const args: string[] = launcher
? ['--run-module', 'hermes_cli.backup_sqlite', home]
: ['-I', '-S', script, home]
// Node refuses direct .cmd execFile; an older published launcher can still
// be one. Same fail-closed guard as the update check: shell:true would
// interpolate untrusted paths, so keep cmd.exe's one unavoidable parse
// closed instead.
const viaCmd: boolean = process.platform === 'win32' && /\.cmd$/i.test(command)
if (viaCmd && [command, ...args].some((value: string): boolean => /["%&|<>^\r\n]/.test(value))) {
throw new Error('The pre-flight snapshot contains an unsafe Windows command argument.')
}
const result: string = execFileSync(
python,
['-I', '-S', script, home],
hiddenWindowsChildOptions({ encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] })
viaCmd ? (process.env.ComSpec ?? 'cmd.exe') : command,
viaCmd
? ['/d', '/v:off', '/s', '/c', `""${command}" ${args.map((arg: string): string => `"${arg}"`).join(' ')}"`]
: args,
hiddenWindowsChildOptions({
encoding: 'utf8',
timeout: 30_000,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: viaCmd
})
)
log(`[updates] state.db pre-flight: ${result.trim()}`)