4280413dba
A manual:true hand-off result is the durable action-required channel: on a browserless Linux box with no working notifier, the boot dialog is the first and only place the message ever surfaces. The 30-minute freshness gate discarded it if the user reopened Hermes later, stranding exactly the machine the channel exists to serve. Parse before the age check and skip the window for manual results; the file is still unlinked before any age check, so it's surfaced at most once. Ordinary results still expire. Regression: a stale ordinary result is discarded (and consumed) while a stale manual result is still returned once.
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
/**
|
|
* Consume the detached update hand-off's result file (#82328 follow-up).
|
|
*
|
|
* scripts/desktop-update/windows.ps1 runs hidden/detached — the user never sees its
|
|
* console. It writes HERMES_HOME/.hermes-update-result.json on every exit
|
|
* path; the relaunched Desktop reads it exactly once on boot and surfaces
|
|
* failures (a silent failed update looks identical to "nothing happened",
|
|
* which is how the 2026-08-09 'closed the app then nothing' report was
|
|
* born). Read-and-delete so a result is reported at most once; ordinary
|
|
* results older than the freshness window are discarded unread (a stale
|
|
* file from a crashed relaunch chain must not resurface days later).
|
|
*
|
|
* manual:true results are exempt from the freshness window. They are the
|
|
* durable action-required channel — on a browserless Linux box with no
|
|
* working notifier, the boot dialog is the FIRST and ONLY place the message
|
|
* ever surfaces, and the user may not reopen Hermes within 30 minutes.
|
|
* Dropping it as stale strands exactly the machine it exists to serve. It is
|
|
* still consumed once (the file is unlinked before any age check), so it
|
|
* cannot resurface on a later boot.
|
|
*/
|
|
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
|
|
export const HANDOFF_RESULT_MAX_AGE_MS = 30 * 60 * 1000
|
|
|
|
export interface HandoffResult {
|
|
ok: boolean
|
|
exitCode: number
|
|
/** Update succeeded but the user must act (reopen the app, reinstall the
|
|
* GUI package, fix the sandbox helper). The consumer must SURFACE these —
|
|
* an ok:true manual result that only gets logged never reaches the user
|
|
* on exactly the machines where no shim/notifier could show it live. */
|
|
manual: boolean
|
|
message: string
|
|
branch: string
|
|
}
|
|
|
|
export function handoffResultPath(hermesHome: string): string {
|
|
return path.join(hermesHome, '.hermes-update-result.json')
|
|
}
|
|
|
|
export function readAndConsumeHandoffResult(
|
|
hermesHome: string,
|
|
{ now = Date.now, maxAgeMs = HANDOFF_RESULT_MAX_AGE_MS }: { now?: () => number; maxAgeMs?: number } = {}
|
|
): HandoffResult | null {
|
|
const file = handoffResultPath(hermesHome)
|
|
let raw: string
|
|
|
|
try {
|
|
raw = fs.readFileSync(file, 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
// Consume unconditionally — even a malformed/stale file must not be
|
|
// re-reported on every subsequent boot.
|
|
try {
|
|
fs.unlinkSync(file)
|
|
} catch {
|
|
// Best-effort; a locked file just gets consumed on the next boot.
|
|
}
|
|
|
|
let parsed: any
|
|
|
|
try {
|
|
parsed = JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const manual = Boolean(parsed?.manual)
|
|
const finishedAt = Number(parsed?.finished_at)
|
|
|
|
if (!Number.isFinite(finishedAt)) {
|
|
return null
|
|
}
|
|
|
|
// Ordinary results expire; a manual (action-required) result never does —
|
|
// it's the last-resort surface for machines with no live channel, so the
|
|
// user must see it whenever they next reopen, not only within the window.
|
|
if (!manual && now() - finishedAt * 1000 > maxAgeMs) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
ok: Boolean(parsed?.ok),
|
|
exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1,
|
|
manual,
|
|
message: typeof parsed?.message === 'string' ? parsed.message : '',
|
|
branch: typeof parsed?.branch === 'string' ? parsed.branch : ''
|
|
}
|
|
}
|