Add Herdr update and live handoff menu actions

The Ctrl+A h command menu gains two host-scoped actions: a plain
`herdr update` that streams updater output into the popup, and an
experimental update-and-hot-restart flow that verifies a live server
handoff and reconnects the originating Hub tab once the popup closes.

Remote updates use the remote host's own proxy settings. A standalone
helper reads literal proxy assignments from shell profiles and running
Clash/Mihomo listeners without executing startup files; Windows reads
process, user and machine proxy variables through PowerShell.
This commit is contained in:
2026-10-03 02:51:44 +00:00
parent a5e8e32f1e
commit 6f933e1c80
10 changed files with 1130 additions and 38 deletions
+227
View File
@@ -6,6 +6,9 @@ import curses
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
import threading
import time
import unittest
@@ -18,6 +21,7 @@ from herdr_hub.control import (
ControlMenu,
HerdrControl,
parse_snapshot,
update_failure,
)
from herdr_hub.remotes import RemoteEntry, remote_control_command
@@ -41,6 +45,13 @@ def completed(payload, code=0):
class SnapshotTests(unittest.TestCase):
def test_missing_session_reports_identity_problem_not_version(self):
payload = snapshot()
del payload["result"]["snapshot"]["panes"][0]["agent_session"]
with self.assertRaisesRegex(ControlError, "尚未向 Herdr 上报会话 ID") as caught:
parse_snapshot(payload)
self.assertNotIn("需要 Herdr 0.9", str(caught.exception))
def test_focused_pane_and_exact_native_arguments(self):
for kind, args in (("codex", ["fork", SESSION_ID]),
("claude", ["--resume", SESSION_ID, "--fork-session"])):
@@ -68,6 +79,99 @@ class SnapshotTests(unittest.TestCase):
class ControlTests(unittest.TestCase):
def test_windows_hot_restart_rejected_before_update(self):
control = HerdrControl(Endpoint.remote("windows-host", remote_transport="ssh"), "unused")
control.platform = "windows"
with patch.object(control, "_execute") as execute:
with self.assertRaisesRegex(ControlError, "Windows 不支持热切换"):
control.update_and_restart(lambda _: None)
execute.assert_not_called()
def test_remote_hot_restart_runs_helper_on_exact_host(self):
control = HerdrControl(Endpoint.remote("ssh://me@host:2222", "named"), "unused", "fake-ssh")
control.platform = "posix"
with patch.object(control, "_execute", return_value=subprocess.CompletedProcess([], 0, b"ok", b"")) as execute:
control.update_and_restart(lambda _: None)
command = execute.call_args.args[0]
self.assertEqual(command[0], "fake-ssh")
self.assertIn("host", command)
self.assertIn("2222", command)
self.assertIn("exec python3 -u -c", command[-1])
self.assertIn(' "$hub_herdr" named', command[-1])
self.assertIn("unset HERDR_ENV;", command[-1])
def test_streaming_shows_unterminated_output_before_process_exits(self):
with tempfile.TemporaryDirectory() as directory:
marker = Path(directory) / "seen"
script = (
"import os, sys, time\n"
"from pathlib import Path\n"
"os.write(2, b'downloading 10%')\n"
"deadline = time.monotonic() + 3\n"
"while not Path(sys.argv[1]).exists():\n"
" if time.monotonic() > deadline: sys.exit(9)\n"
" time.sleep(.01)\n"
"print(' done')\n"
)
updates = []
def progress(value):
updates.append(value)
marker.touch()
result = HerdrControl(Endpoint.local(), "unused")._execute(
[sys.executable, "-c", script, str(marker)], 5, progress=progress
)
self.assertEqual(result.returncode, 0)
self.assertIn("downloading 10%", updates[0])
self.assertIn(b"done", result.stdout)
def test_streaming_timeout_preserves_diagnostic(self):
with self.assertRaisesRegex(ControlError, "waiting for network"):
HerdrControl(Endpoint.local(), "unused")._execute(
[sys.executable, "-c", "import os,time; os.write(2,b'waiting for network'); time.sleep(10)"],
.3, progress=lambda _: None,
)
def test_update_local_is_host_scoped_and_accepts_plain_text(self):
result = subprocess.CompletedProcess([], 0, b"Already up to date", b"notice")
with patch.dict(os.environ, {"HERDR_SESSION": "wrong"}), patch(
"herdr_hub.control.subprocess.run", return_value=result
) as run:
output = HerdrControl(Endpoint.local("named"), "/fake/herdr").update()
self.assertIn("Already up to date", output)
self.assertIn("notice", output)
self.assertEqual(run.call_args.args[0], ["/fake/herdr", "update"])
self.assertEqual(run.call_args.kwargs["timeout"], 300)
self.assertEqual(run.call_args.kwargs["stdin"], subprocess.DEVNULL)
self.assertNotIn("HERDR_SESSION", run.call_args.kwargs["env"])
def test_update_remote_routes_once_on_both_platforms(self):
for transport, path in (("native", "/home/me/sessions"),
("ssh", "C:\\Users\\me\\sessions")):
listing = {"sessions": [{"name": "named", "running": True, "session_dir": path}]}
failure = subprocess.CompletedProcess([], 1, b"download started", b"network error")
with self.subTest(transport=transport), patch(
"herdr_hub.control.subprocess.run", side_effect=[completed(listing), failure]
) as run:
control = HerdrControl(Endpoint.remote("host", "named", remote_transport=transport), "unused", "fake-ssh")
with self.assertRaisesRegex(ControlError, "network error"):
control.update()
self.assertEqual(run.call_count, 2)
command = run.call_args.args[0]
self.assertEqual(command[0], "fake-ssh")
self.assertIn("host", command)
script = command[-1]
if transport == "ssh":
script = base64.b64decode(script.split()[-1]).decode("utf-16le")
self.assertIn("update", script)
self.assertNotIn("--session", script)
self.assertNotIn("--handoff", script)
def test_update_timeout_is_not_replayed(self):
with patch("herdr_hub.control.subprocess.run", side_effect=subprocess.TimeoutExpired("herdr", 300)) as run:
with self.assertRaisesRegex(ControlError, "操作可能已完成"):
HerdrControl(Endpoint.local(), "herdr").update()
run.assert_called_once()
def test_local_explicit_session_and_clean_environment(self):
for session in ("default", "named"):
with self.subTest(session=session), patch.dict(os.environ, {
@@ -234,6 +338,35 @@ class ImmediateExecutor:
class MenuTests(unittest.TestCase):
def test_small_popup_shows_download_failure_before_proxy_notice(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.update_and_restart.side_effect = ControlError(update_failure(
"更新失败", "未检测到代理\n正在下载\ncurl: (28) Connection timed out\n没有自动执行普通重启;请检查当前 Session。"
))
self.run_menu([curses.KEY_DOWN, curses.KEY_DOWN, 10, 27])
rendered = str(self.screen.addstr.call_args_list)
self.assertIn("curl: (28) Connection timed out", rendered)
self.assertNotIn("未检测到代理", rendered)
def test_hot_restart_success_requests_reconnect(self):
self.control.update_and_restart.return_value = "verified"
self.screen.getch.side_effect = [curses.KEY_DOWN, curses.KEY_DOWN, 10]
menu = ControlMenu(self.control)
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
self.assertEqual(menu.run(self.screen), 0)
self.assertTrue(menu.reconnect_requested)
self.control.update_and_restart.assert_called_once()
def test_hot_restart_failure_stays_visible_without_reconnect(self):
self.control.update_and_restart.side_effect = ControlError("handoff failed")
self.screen.getch.side_effect = [curses.KEY_DOWN, curses.KEY_DOWN, 10, 10, 27]
menu = ControlMenu(self.control)
with patch("herdr_hub.control.ThreadPoolExecutor", ImmediateExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
self.assertEqual(menu.run(self.screen), 0)
self.assertFalse(menu.reconnect_requested)
self.control.update_and_restart.assert_called_once()
self.assertIn("handoff failed", str(self.screen.addstr.call_args_list))
def setUp(self):
self.control = Mock(endpoint=Endpoint.local())
self.control.source.return_value = parse_snapshot(snapshot())
@@ -268,8 +401,102 @@ class MenuTests(unittest.TestCase):
self.run_menu([10, 27])
self.control.fork.assert_not_called()
def test_refresh_restores_fork_after_session_identity_becomes_available(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.source.side_effect = [ControlError("尚未上报会话 ID"), parse_snapshot(snapshot())]
self.run_menu([10, 18, 10])
self.assertEqual(self.control.source.call_count, 2)
self.control.fork.assert_called_once()
self.assertIn("不可用", str(self.screen.addstr.call_args_list))
def test_compact_mouse_fork_still_executes(self):
self.screen.getmaxyx.return_value = (8, 71)
with patch("curses.getmouse", return_value=(0, 3, 1, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE])
self.control.fork.assert_called_once()
self.control.update.assert_not_called()
def test_update_without_agent_keeps_result_and_prevents_repeat(self):
self.control.source.side_effect = ControlError("没有 Agent")
self.control.update.return_value = "更新成功"
self.run_menu([curses.KEY_DOWN, 10, 10, 27])
self.control.update.assert_called_once()
self.control.fork.assert_not_called()
rendered = str(self.screen.addstr.call_args_list)
self.assertIn("更新成功", rendered)
self.assertIn("更新目标:本机", rendered)
def test_click_update_reports_remote_target_and_failure(self):
self.control.endpoint = Endpoint.remote("my-host")
self.control.update.side_effect = ControlError("更新失败:连接断开")
with patch("curses.getmouse", return_value=(0, 3, 5, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE, 10, 27])
self.control.update.assert_called_once()
rendered = str(self.screen.addstr.call_args_list)
self.assertIn("更新目标:my-host", rendered)
self.assertIn("更新失败:连接断开", rendered)
def test_update_output_can_be_scrolled(self):
self.control.update.return_value = "\n".join(f"line-{i}" for i in range(60))
self.run_menu([curses.KEY_DOWN, 10, curses.KEY_NPAGE, curses.KEY_PPAGE, 27])
self.assertIn("line-20", str(self.screen.addstr.call_args_list))
def test_update_result_visible_in_actual_small_popup(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.update.return_value = "更新命令已完成\nAlready up to date"
self.run_menu([curses.KEY_DOWN, 10, 27])
calls = self.screen.addstr.call_args_list
results = [call.args for call in calls if "Already up to date" in call.args[2]]
self.assertTrue(results, "结果必须在真实的 8 行弹窗内绘制")
self.assertTrue(all(3 <= args[0] < 7 for args in results))
self.assertTrue(all(0 <= call.args[0] < 8 for call in calls))
def test_compact_mouse_update_row(self):
self.screen.getmaxyx.return_value = (8, 71)
self.control.update.return_value = "完成"
with patch("curses.getmouse", return_value=(0, 3, 2, 0, curses.BUTTON1_PRESSED)):
self.run_menu([curses.KEY_MOUSE, 27])
self.control.update.assert_called_once()
self.control.fork.assert_not_called()
def test_update_during_snapshot_load_is_not_ignored(self):
cancel = None
class LoadingFuture:
def done(self):
return cancel.is_set()
def result(self):
raise ControlCancelled()
class LoadingExecutor(ImmediateExecutor):
def submit(inner, fn, *args):
nonlocal cancel
if fn == self.control.source:
cancel = args[0]
return LoadingFuture()
return ImmediateFuture(fn, *args)
self.control.update.return_value = "完成"
self.screen.getch.side_effect = [curses.KEY_DOWN, 10, -1, 27]
with patch("herdr_hub.control.ThreadPoolExecutor", LoadingExecutor), patch("curses.set_escdelay"), patch("curses.curs_set"), patch("curses.mousemask"):
ControlMenu(self.control).run(self.screen)
self.control.update.assert_called_once()
class CliControlTests(unittest.TestCase):
def test_hot_restart_reconnects_original_window_after_popup_closes(self):
args = build_parser().parse_args(["control"])
endpoint = Endpoint.local()
original = ManagedWindow("@17", endpoint.key, "Local", False, True, endpoint)
other = ManagedWindow("@18", "local:other", "Other", False, True, Endpoint.local("other"))
hub = Mock(ssh_bin="fake-ssh")
hub.managed_windows.side_effect = [[original], [original, other]]
def close_popup(run):
hub.reconnect.assert_not_called()
run.__self__.reconnect_requested = True
return 0
with patch("herdr_hub.cli.make_hub", return_value=hub), patch("herdr_hub.cli.curses.wrapper", side_effect=close_popup):
self.assertEqual(command_control(args), 0)
hub.reconnect.assert_called_once_with("@17")
hub.wait_for_window_startup.assert_called_once_with("@17")
def test_active_window_metadata_selects_control_endpoint(self):
args = build_parser().parse_args(["control"])
hub = Mock(ssh_bin="fake-ssh")