fix: preserve source channel in desktop update handoffs
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
# CONTRACT (keep in sync with apps/desktop/electron/main.ts):
|
||||
# bash scripts/desktop-update/posix.sh
|
||||
# --install-root <path> repo checkout (HERMES_HOME/hermes-agent)
|
||||
# --branch <ref> branch to update against
|
||||
# [--branch <ref> | --channel stable|canary|main] default: branch main
|
||||
# --desktop-pid <pid> the Electron main process to wait out
|
||||
# [--relaunch-target <p>] mac: running .app to swap+reopen;
|
||||
# linux: running binary (omit = no relaunch)
|
||||
@@ -36,7 +36,8 @@
|
||||
set -u
|
||||
|
||||
ORIGINAL_ARGS=("$@")
|
||||
INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET=""
|
||||
INSTALL_ROOT="" BRANCH="main" CHANNEL="" DESKTOP_PID=0 RELAUNCH_TARGET=""
|
||||
BRANCH_EXPLICIT=0
|
||||
RELAUNCH_CWD="" SANDBOX_FALLBACK=0 RELAUNCH_ARGS=()
|
||||
NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0 SELF_TEST_GATE=0 SELF_TEST_MARKER=0
|
||||
SELF_TEST_TCC_HEAL=0
|
||||
@@ -44,7 +45,13 @@ HANDOFF_DAEMONIZED=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install-root) INSTALL_ROOT="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; BRANCH_EXPLICIT=1; shift 2 ;;
|
||||
--channel)
|
||||
case "${2:-}" in
|
||||
stable|canary|main) CHANNEL="$2" ;;
|
||||
*) echo "--channel must be stable, canary, or main" >&2; exit 64 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--desktop-pid) DESKTOP_PID="$2"; shift 2 ;;
|
||||
--relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;;
|
||||
--relaunch-cwd) RELAUNCH_CWD="$2"; shift 2 ;;
|
||||
@@ -61,10 +68,14 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
[ "$SELF_TEST_UI" -eq 1 ] || [ -n "$INSTALL_ROOT" ] || { echo "--install-root is required" >&2; exit 64; }
|
||||
[ "$BRANCH_EXPLICIT" -eq 0 ] || [ -z "$CHANNEL" ] || { echo "--branch and --channel are mutually exclusive" >&2; exit 64; }
|
||||
TARGET_ARGS=(--branch "$BRANCH")
|
||||
[ -z "$CHANNEL" ] || TARGET_ARGS=(--channel "$CHANNEL")
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HERMES_HOME="${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}"
|
||||
HERMES_HOME="${HERMES_HOME:-${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}}"
|
||||
HERMES_HOME="${HERMES_HOME:-${TMPDIR:-/tmp}}"
|
||||
export HERMES_HOME
|
||||
MARKER="$HERMES_HOME/.hermes-update-in-progress"
|
||||
LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
LOG="$LOG_DIR/desktop-update-handoff.log"
|
||||
@@ -422,10 +433,10 @@ launch_app() { # attempted BEFORE the terminal event (launch acceptance is
|
||||
MANUAL=0 # 1 = update landed but the user must act (result protocol field)
|
||||
|
||||
write_result() {
|
||||
printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","finished_at":%s}' \
|
||||
printf '{"ok":%s,"exit_code":%s,"manual":%s,"message":"%s","branch":"%s","channel":"%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)" \
|
||||
"$(json_escape "$FINAL_MSG")" "$(json_escape "$BRANCH")" "$(json_escape "$CHANNEL")" "$(date +%s)" \
|
||||
> "$RESULT.tmp" 2>/dev/null && mv -f "$RESULT.tmp" "$RESULT" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -681,7 +692,7 @@ fi
|
||||
# command has returned; the already-running server keeps the inherited setting
|
||||
# until normal cleanup closes it.
|
||||
trap '' TERM
|
||||
log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH desktopPid=$DESKTOP_PID pid=$$"
|
||||
log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH channel=$CHANNEL desktopPid=$DESKTOP_PID pid=$$"
|
||||
rm -f "$RESULT" 2>/dev/null || true
|
||||
|
||||
# Marker claim: same cross-process lock contract as windows.ps1 /
|
||||
@@ -761,9 +772,9 @@ if "${UPDATE_INVOKE[@]}" update --help 2>/dev/null | grep -q -- '--keep-stash';
|
||||
else
|
||||
log "installed hermes predates --keep-stash; running without it"
|
||||
fi
|
||||
log "running: ${UPDATE_INVOKE[*]} update --yes --gateway $KEEP_STASH --branch $BRANCH"
|
||||
log "running: ${UPDATE_INVOKE[*]} update --yes --gateway $KEEP_STASH ${TARGET_ARGS[*]}"
|
||||
publish_stage "Updating code and dependencies"
|
||||
OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
|
||||
OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH "${TARGET_ARGS[@]}" 2>&1)"; CODE=$?
|
||||
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
|
||||
log "hermes update exit code: $CODE"
|
||||
|
||||
@@ -785,7 +796,7 @@ if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then
|
||||
fi
|
||||
log "retrying once (freshly pulled fix loads on the second run)"
|
||||
publish_stage "Retrying update"
|
||||
OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH --branch "$BRANCH" 2>&1)"; CODE=$?
|
||||
OUT="$("${UPDATE_INVOKE[@]}" update --yes --gateway $KEEP_STASH "${TARGET_ARGS[@]}" 2>&1)"; CODE=$?
|
||||
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
|
||||
log "retry exit code: $CODE"
|
||||
fi
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass
|
||||
# -File scripts\desktop-update\windows.ps1
|
||||
# -InstallRoot <path> repo checkout (HERMES_HOME\hermes-agent)
|
||||
# -Branch <ref> branch to update against
|
||||
# [-Branch <ref> | -Channel stable|canary|main] default: branch main
|
||||
# -DesktopPid <pid> the Electron main process to wait out
|
||||
# [-RelaunchExe <path>] Hermes.exe to start when done (omit = no relaunch)
|
||||
# [-NoUi] headless (tests); default shows a progress window
|
||||
@@ -44,6 +44,8 @@
|
||||
param(
|
||||
[string]$InstallRoot,
|
||||
[string]$Branch = "main",
|
||||
[ValidateSet("stable", "canary", "main")]
|
||||
[string]$Channel,
|
||||
[int]$DesktopPid = 0,
|
||||
[string]$RelaunchExe = "",
|
||||
[switch]$NoUi,
|
||||
@@ -54,6 +56,11 @@ param(
|
||||
[switch]$SelfTestWorkingDirectory
|
||||
)
|
||||
|
||||
if ($PSBoundParameters.ContainsKey("Branch") -and $PSBoundParameters.ContainsKey("Channel")) {
|
||||
throw "-Branch and -Channel are mutually exclusive"
|
||||
}
|
||||
$targetArgs = if ($Channel) { @("--channel", $Channel.ToLowerInvariant()) } else { @("--branch", $Branch) }
|
||||
|
||||
if (-not $SelfTestUi -and -not $SelfTestPipeDrain -and -not $InstallRoot) {
|
||||
# Mandatory in spirit; relaxed in the signature only so the self-test
|
||||
# switches can drive the UI / the pipe drain without a checkout.
|
||||
@@ -81,7 +88,8 @@ try {
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
} catch {}
|
||||
$TempDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
|
||||
$HermesHome = if ($InstallRoot) { Split-Path -Parent $InstallRoot } else { $TempDir }
|
||||
$HermesHome = if ($env:HERMES_HOME) { $env:HERMES_HOME } elseif ($InstallRoot) { Split-Path -Parent $InstallRoot } else { $TempDir }
|
||||
$env:HERMES_HOME = $HermesHome
|
||||
$MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress"
|
||||
$LogDir = Join-Path $HermesHome "logs"
|
||||
$LogPath = Join-Path $LogDir "desktop-update-handoff.log"
|
||||
@@ -576,6 +584,7 @@ function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualActi
|
||||
manual = $ManualAction
|
||||
message = $Message
|
||||
branch = $Branch
|
||||
channel = $Channel
|
||||
finished_at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
||||
} | ConvertTo-Json -Compress
|
||||
[System.IO.File]::WriteAllText($ResultPath, $obj)
|
||||
@@ -1436,7 +1445,7 @@ try {
|
||||
New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
|
||||
Show-ProgressWindow
|
||||
Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID"
|
||||
Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch channel=$Channel desktopPid=$DesktopPid pid=$PID"
|
||||
|
||||
# -- 0. Claim the update marker with OUR pid ---------------------------
|
||||
try {
|
||||
@@ -1593,7 +1602,7 @@ try {
|
||||
Write-HandoffLog $finalMsg
|
||||
exit $finalCode
|
||||
}
|
||||
$updateArgs = @("-m", "hermes_cli.main", "update", "--yes", "--gateway", "--force", "--branch", $Branch)
|
||||
$updateArgs = @("-m", "hermes_cli.main", "update", "--yes", "--gateway", "--force") + $targetArgs
|
||||
# --keep-stash: never re-apply local source edits after the update (they
|
||||
# stay parked in git stash). Probe --help first: the flag ships with newer
|
||||
# backends and an unknown flag would abort argparse with exit 2, which
|
||||
|
||||
@@ -150,6 +150,7 @@ def _run_handoff(tmp_path, exits: dict[int, int]) -> list[dict]:
|
||||
"HERMES_TEST_CALLS": str(calls),
|
||||
"HERMES_TEST_EXITS": str(tmp_path / "exits"),
|
||||
}
|
||||
env.pop("HERMES_HOME", None) # Exercise the legacy install-parent fallback.
|
||||
# The hand-off daemonizes and the launcher exits immediately; the result
|
||||
# file is the orchestrator's own completion signal.
|
||||
subprocess.run(
|
||||
|
||||
227
tests/test_desktop_update_target.py
Normal file
227
tests/test_desktop_update_target.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Run the real handoffs against a disposable CLI, never an installed updater."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" / "desktop-update"
|
||||
FAKE_CLI = """
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--help' in sys.argv:
|
||||
print('update options')
|
||||
sys.exit(0)
|
||||
receipt = Path(os.environ['HANDOFF_CAPTURE'])
|
||||
previous = receipt.read_text(encoding='utf-8') if receipt.exists() else ''
|
||||
with receipt.open('a', encoding='utf-8') as stream:
|
||||
stream.write(json.dumps({'argv': sys.argv[1:], 'home': os.environ.get('HERMES_HOME'),
|
||||
'install_root': os.environ.get('HERMES_INSTALL_ROOT'),
|
||||
'cwd': os.getcwd()}) + '\\n')
|
||||
sys.exit(1 if not previous else 0)
|
||||
"""
|
||||
|
||||
|
||||
def _run_handoff(tmp_path, target, *, windows=False, inherited_home=True):
|
||||
install = tmp_path / "checkout with spaces"
|
||||
if windows:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "venv", "--without-pip", str(install / "venv")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
package = (
|
||||
install / "venv" / "Lib" / "site-packages" if windows else install
|
||||
) / "hermes_cli"
|
||||
package.mkdir(parents=True)
|
||||
(package / "__init__.py").touch()
|
||||
(package / "main.py").write_text(FAKE_CLI, encoding="utf-8")
|
||||
capture = tmp_path / "calls.jsonl"
|
||||
home = tmp_path / "profile home" if inherited_home else tmp_path
|
||||
home.mkdir(exist_ok=True)
|
||||
env = {
|
||||
**os.environ,
|
||||
"HOME": str(tmp_path),
|
||||
"TMPDIR": str(tmp_path),
|
||||
"HERMES_INSTALL_ROOT": str(install),
|
||||
"HANDOFF_CAPTURE": str(capture),
|
||||
}
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
env.pop("HERMES_HOME", None)
|
||||
if inherited_home:
|
||||
env["HERMES_HOME"] = str(home)
|
||||
if windows:
|
||||
# The disposable runtime has only the fixture CLI; verification is outside
|
||||
# this transport contract and runs its own harmless fixture implementation.
|
||||
(package / "desktop_update_verify.py").write_text(
|
||||
"def verify_windows_desktop_update(): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
command = [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(SCRIPTS / "windows.ps1"),
|
||||
"-InstallRoot",
|
||||
str(install),
|
||||
"-NoUi",
|
||||
]
|
||||
else:
|
||||
bin_dir = install / "venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "python3").symlink_to(sys.executable)
|
||||
hermes = bin_dir / "hermes"
|
||||
hermes.write_text(
|
||||
f'#!/usr/bin/env bash\nexec {shlex.quote(sys.executable)} -m hermes_cli.main "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
hermes.chmod(0o755)
|
||||
command = [
|
||||
"bash",
|
||||
str(SCRIPTS / "posix.sh"),
|
||||
"--install-root",
|
||||
str(install),
|
||||
"--daemonized",
|
||||
"--no-ui",
|
||||
]
|
||||
result = subprocess.run(
|
||||
[*command, *target],
|
||||
env=env,
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=90,
|
||||
)
|
||||
calls = (
|
||||
[json.loads(line) for line in capture.read_text(encoding="utf-8").splitlines()]
|
||||
if capture.exists()
|
||||
else []
|
||||
)
|
||||
return result, calls, home, install
|
||||
|
||||
|
||||
def _assert_forwarded(
|
||||
tmp_path, target, expected, *, windows=False, inherited_home=True
|
||||
):
|
||||
result, calls, home, install = _run_handoff(
|
||||
tmp_path,
|
||||
target,
|
||||
windows=windows,
|
||||
inherited_home=inherited_home,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert len(calls) == 2, calls
|
||||
expected_args = ["update", "--yes", "--gateway"]
|
||||
if windows:
|
||||
expected_args += ["--force"]
|
||||
expected_args += expected
|
||||
assert (
|
||||
calls
|
||||
== [
|
||||
{
|
||||
"argv": expected_args,
|
||||
"home": str(home),
|
||||
"cwd": str(install),
|
||||
"install_root": str(install),
|
||||
}
|
||||
]
|
||||
* 2
|
||||
)
|
||||
receipt = json.loads(
|
||||
(home / ".hermes-update-result.json").read_text(encoding="utf-8-sig")
|
||||
)
|
||||
assert receipt["ok"]
|
||||
assert receipt["channel"] == (expected[1] if expected[0] == "--channel" else "")
|
||||
assert not (home / ".hermes-update-in-progress").exists()
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
@pytest.mark.parametrize("channel", ["stable", "canary", "main"])
|
||||
def test_posix_channel_survives_retry_in_active_profile(tmp_path, channel):
|
||||
_assert_forwarded(tmp_path, ["--channel", channel], ["--channel", channel])
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
@pytest.mark.parametrize("channel", ["stable", "canary", "main"])
|
||||
def test_windows_channel_survives_retry_in_active_profile(tmp_path, channel):
|
||||
_assert_forwarded(
|
||||
tmp_path, ["-Channel", channel], ["--channel", channel], windows=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
@pytest.mark.parametrize(
|
||||
"target, expected",
|
||||
[
|
||||
([], ["--branch", "main"]),
|
||||
(["--branch", "feature/target"], ["--branch", "feature/target"]),
|
||||
],
|
||||
)
|
||||
def test_posix_legacy_branch_and_default_home(tmp_path, target, expected):
|
||||
_assert_forwarded(tmp_path, target, expected, inherited_home=False)
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
@pytest.mark.parametrize(
|
||||
"target, expected",
|
||||
[
|
||||
([], ["--branch", "main"]),
|
||||
(["-Branch", "feature/target"], ["--branch", "feature/target"]),
|
||||
],
|
||||
)
|
||||
def test_windows_legacy_branch_and_default_home(tmp_path, target, expected):
|
||||
_assert_forwarded(tmp_path, target, expected, windows=True, inherited_home=False)
|
||||
|
||||
|
||||
def _assert_rejected(tmp_path, target, *, windows=False):
|
||||
result, calls, home, _ = _run_handoff(tmp_path, target, windows=windows)
|
||||
assert result.returncode != 0, result.stdout + result.stderr
|
||||
assert not calls
|
||||
assert not (home / ".hermes-update-in-progress").exists()
|
||||
assert not (home / ".hermes-update-result.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
["--channel", "nightly"],
|
||||
["--channel", ""],
|
||||
["--channel"],
|
||||
["--branch", "main", "--channel", "stable"],
|
||||
["--channel", "canary", "--branch", "main"],
|
||||
],
|
||||
)
|
||||
def test_posix_rejects_invalid_or_conflicting_target_before_update(tmp_path, target):
|
||||
_assert_rejected(tmp_path, target)
|
||||
|
||||
|
||||
@pytest.mark.platforms("windows")
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
["-Channel", "nightly"],
|
||||
["-Channel", ""],
|
||||
["-Channel"],
|
||||
["-Branch", "main", "-Channel", "stable"],
|
||||
["-Channel", "canary", "-Branch", "main"],
|
||||
],
|
||||
)
|
||||
def test_windows_rejects_invalid_or_conflicting_target_before_update(tmp_path, target):
|
||||
_assert_rejected(tmp_path, target, windows=True)
|
||||
Reference in New Issue
Block a user