fix(update): manual-result protocol so gated outcomes reach the user
Round 4 of helix4u's review — the durable fallback is now real: - Result protocol gains `manual`: an ok result the user still must act on (reopen the app, reinstall the GUI package, fix the sandbox helper). Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop consumer surfaces manual results in a real dialog on next boot instead of a log line — the browserless-Linux disappearance now ends at a visible dialog, worst case one boot later. Older result files without the field parse as manual:false (covered). - notify ladder verifies EXECUTION, not existence: zenity/kdialog must survive their first second (an instant death means no display and falls through); the no-surface case is an explicit best-effort contract whose guaranteed channel is the result dialog. - mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no longer swallowed (`|| true` dropped): the durable message carries both facts. - launch/gate matrices assert `manual` in the result JSON; consumer round-trip tested in handoff-result.test.ts.
This commit is contained in:
@@ -67,3 +67,24 @@ test('malformed JSON is consumed silently', () => {
|
||||
test('absent file returns null', () => {
|
||||
assert.equal(readAndConsumeHandoffResult(tempHome()), null)
|
||||
})
|
||||
|
||||
test('manual flag survives the round trip and defaults false', () => {
|
||||
const home = tempHome()
|
||||
write(home, {
|
||||
ok: true,
|
||||
exit_code: 0,
|
||||
manual: true,
|
||||
message: 'Update complete. Reopen Hermes to finish (it could not restart itself).',
|
||||
branch: 'main',
|
||||
finished_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
|
||||
const result = readAndConsumeHandoffResult(home)
|
||||
|
||||
assert.ok(result)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.manual, true)
|
||||
|
||||
write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) })
|
||||
assert.equal(readAndConsumeHandoffResult(home)?.manual, false, 'older writers without the field parse as manual:false')
|
||||
})
|
||||
|
||||
@@ -19,6 +19,11 @@ 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
|
||||
}
|
||||
@@ -65,6 +70,7 @@ export function readAndConsumeHandoffResult(
|
||||
return {
|
||||
ok: Boolean(parsed?.ok),
|
||||
exitCode: Number.isFinite(Number(parsed?.exit_code)) ? Number(parsed.exit_code) : 1,
|
||||
manual: Boolean(parsed?.manual),
|
||||
message: typeof parsed?.message === 'string' ? parsed.message : '',
|
||||
branch: typeof parsed?.branch === 'string' ? parsed.branch : ''
|
||||
}
|
||||
|
||||
@@ -1815,7 +1815,18 @@ async function waitForUpdateToFinish() {
|
||||
try {
|
||||
const result = readAndConsumeHandoffResult(HERMES_HOME)
|
||||
|
||||
if (result && result.ok) {
|
||||
if (result && result.ok && result.manual) {
|
||||
// Update landed but the user must act (reopen/reinstall/sandbox). On
|
||||
// machines with no shim browser and no notifier this dialog is the
|
||||
// FIRST time the message is visible — it must not be a log line.
|
||||
rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`)
|
||||
dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'Hermes update',
|
||||
message: 'The update finished, but needs one more step',
|
||||
detail: result.message
|
||||
})
|
||||
} else if (result && result.ok) {
|
||||
rememberLog(`[updates] detached update finished OK (branch ${result.branch})`)
|
||||
} else if (result) {
|
||||
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
|
||||
|
||||
@@ -80,11 +80,13 @@ json_escape() { # minimal JSON string escape: \ " and control whitespace
|
||||
}
|
||||
|
||||
notify_fallback() { # status message — renderer-free recovery surface.
|
||||
# Fires only when there is no shim window: a gated/failed outcome must
|
||||
# never be a silent disappearance (gille rounds 2-3). Each rung FALLS
|
||||
# THROUGH on execution failure (notify-send existing but unable to reach
|
||||
# D-Bus must not eat the message), ending at osascript on mac — present
|
||||
# on every macOS — and at a logged last resort everywhere.
|
||||
# Fires only when there is no shim window. BEST-EFFORT immediate channel:
|
||||
# each rung requires EXECUTION acceptance, not existence — notify-send's
|
||||
# exit code is its acceptance (fire-and-forget), zenity/kdialog must
|
||||
# survive their first second (a dialog that dies instantly had no display
|
||||
# and must not eat the message). The GUARANTEED channel is the result
|
||||
# file: a manual/error outcome is durably marked and the next Desktop
|
||||
# boot surfaces it in a dialog (handoff-result.ts + main.ts).
|
||||
case "$1" in manual|error) ;; *) return 0 ;; esac
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
/usr/bin/osascript -e "display notification \"$(printf '%s' "$2" | sed 's/"/\\"/g')\" with title \"Hermes update\"" 2>/dev/null && return 0
|
||||
@@ -92,16 +94,23 @@ notify_fallback() { # status message — renderer-free recovery surface.
|
||||
if command -v notify-send >/dev/null 2>&1; then
|
||||
notify-send -u critical "Hermes update" "$2" 2>/dev/null && return 0
|
||||
fi
|
||||
local p
|
||||
if command -v zenity >/dev/null 2>&1; then
|
||||
(zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &) && return 0
|
||||
zenity --warning --title="Hermes update" --text="$2" 2>/dev/null &
|
||||
p=$!; sleep 1
|
||||
kill -0 "$p" 2>/dev/null && return 0
|
||||
wait "$p" 2>/dev/null
|
||||
fi
|
||||
if command -v kdialog >/dev/null 2>&1; then
|
||||
(kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &) && return 0
|
||||
kdialog --title "Hermes update" --sorry "$2" 2>/dev/null &
|
||||
p=$!; sleep 1
|
||||
kill -0 "$p" 2>/dev/null && return 0
|
||||
wait "$p" 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
# Explicit contract for the no-surface case: the result file already
|
||||
# tells the next boot the truth; log that this was the only channel.
|
||||
log "NOTICE: no notification surface available; outcome reaches the user via the result file on next launch: $2"
|
||||
# No immediate surface landed. The durable channel takes over: the result
|
||||
# is marked manual/failed and the next boot shows it in a real dialog.
|
||||
log "NOTICE: no notification surface accepted; outcome reaches the user via the result dialog on next launch: $2"
|
||||
}
|
||||
|
||||
publish() { # status message -- atomic replace; the server reads per poll
|
||||
@@ -274,9 +283,12 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is
|
||||
fi
|
||||
}
|
||||
|
||||
MANUAL=0 # 1 = update landed but the user must act (result protocol field)
|
||||
|
||||
write_result() {
|
||||
printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \
|
||||
printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \
|
||||
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" \
|
||||
"$([ "$MANUAL" -eq 1 ] && echo true || echo false)" \
|
||||
"$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(date +%s)" \
|
||||
> "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true
|
||||
}
|
||||
@@ -293,7 +305,7 @@ finish() {
|
||||
# A rejected launch rewrites the result (nothing consumed it — the app
|
||||
# never started) so the next boot tells the truth too.
|
||||
deliver_outcome
|
||||
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && FINAL_MSG="$DONE_NOTE"
|
||||
[ "$FINAL_CODE" -eq 0 ] && [ -n "$DONE_NOTE" ] && { FINAL_MSG="$DONE_NOTE"; MANUAL=1; }
|
||||
write_result
|
||||
|
||||
if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then
|
||||
@@ -312,15 +324,21 @@ finish() {
|
||||
# mac DONE_NOTE = swap failed but the PREVIOUS bundle was kept/rolled
|
||||
# back — bring it back up; the note still tells the user to re-run.
|
||||
# A gated linux outcome (skew/manual) skips the launch BY DESIGN.
|
||||
launch_app || true
|
||||
if ! launch_app; then
|
||||
# Even the kept bundle didn't come back: the durable message must
|
||||
# carry BOTH facts (update ok, previous app not reopened).
|
||||
FINAL_MSG="$DONE_NOTE Hermes also could not reopen itself - open it manually."
|
||||
write_result
|
||||
fi
|
||||
fi
|
||||
publish "manual" "$DONE_NOTE"; stop_ui leave-window
|
||||
publish "manual" "$FINAL_MSG"; stop_ui leave-window
|
||||
elif launch_app; then
|
||||
publish "done" ""; stop_ui
|
||||
else
|
||||
# Launch was due and did not land. Downgrade: truthful result for the
|
||||
# next boot, manual state held on screen now.
|
||||
FINAL_MSG="Update complete. Reopen Hermes to finish (it could not restart itself)."
|
||||
MANUAL=1
|
||||
write_result
|
||||
publish "manual" "$FINAL_MSG"; stop_ui leave-window
|
||||
fi
|
||||
|
||||
@@ -159,13 +159,13 @@ case "$MODE" in
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
|
||||
--relaunch-target "$UNPACKED/hermes" >/dev/null 2>&1 || true
|
||||
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']"
|
||||
expect_msg "instant-exit relaunch downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
|
||||
else
|
||||
# mac: a SUPPLIED target that is missing is a REJECTED launch and
|
||||
# must downgrade to manual — never a clean "Update complete."
|
||||
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
|
||||
--relaunch-target "$L/NoSuch.app" >/dev/null 2>&1 || true
|
||||
expect_msg "missing bundle downgrades to manual" "d['ok']==True and 'Reopen Hermes' in d['message']"
|
||||
expect_msg "missing bundle downgrades to manual" "d['ok']==True and d['manual']==True and 'Reopen Hermes' in d['message']"
|
||||
fi
|
||||
|
||||
# 2. gated skew: success result carries the skew message (the manual
|
||||
@@ -174,7 +174,7 @@ case "$MODE" in
|
||||
bash "$SCRIPT_DIR/posix.sh" --no-ui --desktop-pid 0 --install-root "$L/hermes-agent" \
|
||||
--relaunch-target /opt/Hermes/hermes >/dev/null 2>&1 || true
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
expect_msg "skew outcome surfaces in result message" "d['ok']==True and 'was not changed' in d['message']"
|
||||
expect_msg "skew outcome surfaces in result message" "d['ok']==True and d['manual']==True and 'was not changed' in d['message']"
|
||||
fi
|
||||
|
||||
rm -rf "$L"
|
||||
|
||||
@@ -409,13 +409,16 @@ function Close-ProgressWindow {
|
||||
}
|
||||
}
|
||||
|
||||
function Write-Result([bool]$Ok, [int]$Code, [string]$Message) {
|
||||
function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualAction = $false) {
|
||||
# Consumed (read + deleted) by the relaunched Desktop on boot so the
|
||||
# user actually SEES how a detached update ended.
|
||||
# user actually SEES how a detached update ended. $ManualAction marks an
|
||||
# ok result the user still must act on -- the Desktop surfaces those in
|
||||
# a dialog, not just the log (same protocol as posix.sh).
|
||||
try {
|
||||
$obj = @{
|
||||
ok = $Ok
|
||||
exit_code = $Code
|
||||
manual = $ManualAction
|
||||
message = $Message
|
||||
branch = $Branch
|
||||
finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
@@ -733,7 +736,7 @@ try {
|
||||
# Launch was due and did not verifiably land: truthful result
|
||||
# for the next boot, manual state held on screen now.
|
||||
$finalMsg = "Update complete. Reopen Hermes to finish (it could not restart itself)."
|
||||
Write-Result $true 0 $finalMsg
|
||||
Write-Result $true 0 $finalMsg $true
|
||||
Show-ManualFinale $finalMsg
|
||||
}
|
||||
Close-ProgressWindow
|
||||
|
||||
Reference in New Issue
Block a user