fix: notify gateway users when updates finish or fail

This commit is contained in:
clabbe-bot 2026-03-11 20:43:31 +01:00 committed by teknium1
parent cf3dceafe1
commit 3126c60885
2 changed files with 201 additions and 20 deletions

View file

@ -133,6 +133,7 @@ class TestHandleUpdateCommand:
assert data["platform"] == "telegram"
assert data["chat_id"] == "99999"
assert "timestamp" in data
assert not (hermes_home / ".update_exit_code").exists()
@pytest.mark.asyncio
async def test_spawns_systemd_run(self, tmp_path):
@ -160,6 +161,7 @@ class TestHandleUpdateCommand:
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "/usr/bin/systemd-run"
assert "--scope" in call_args
assert ".update_exit_code" in call_args[-1]
assert "Starting Hermes update" in result
@pytest.mark.asyncio
@ -196,6 +198,7 @@ class TestHandleUpdateCommand:
call_args = mock_popen.call_args[0][0]
assert call_args[0] == "bash"
assert "nohup" in call_args[2]
assert ".update_exit_code" in call_args[2]
assert "Starting Hermes update" in result
@pytest.mark.asyncio
@ -222,6 +225,7 @@ class TestHandleUpdateCommand:
assert "Failed to start update" in result
# Pending file should be cleaned up
assert not (hermes_home / ".update_pending.json").exists()
assert not (hermes_home / ".update_exit_code").exists()
@pytest.mark.asyncio
async def test_returns_user_friendly_message(self, tmp_path):
@ -266,6 +270,53 @@ class TestSendUpdateNotification:
# Should not raise
await runner._send_update_notification()
@pytest.mark.asyncio
async def test_defers_notification_while_update_still_running(self, tmp_path):
"""Returns False and keeps marker files when the update has not exited yet."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending_path = hermes_home / ".update_pending.json"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
}))
(hermes_home / ".update_output.txt").write_text("still running")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._send_update_notification()
assert result is False
mock_adapter.send.assert_not_called()
assert pending_path.exists()
@pytest.mark.asyncio
async def test_recovers_from_claimed_pending_file(self, tmp_path):
"""A claimed pending file from a crashed notifier is still deliverable."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
claimed_path = hermes_home / ".update_pending.claimed.json"
claimed_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
}))
(hermes_home / ".update_output.txt").write_text("done")
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._send_update_notification()
assert result is True
mock_adapter.send.assert_called_once()
assert not claimed_path.exists()
@pytest.mark.asyncio
async def test_sends_notification_with_output(self, tmp_path):
"""Sends update output to the correct platform and chat."""
@ -284,6 +335,7 @@ class TestSendUpdateNotification:
(hermes_home / ".update_output.txt").write_text(
"→ Found 3 new commit(s)\n✓ Code updated!\n✓ Update complete!"
)
(hermes_home / ".update_exit_code").write_text("0")
# Mock the adapter
mock_adapter = AsyncMock()
@ -310,6 +362,7 @@ class TestSendUpdateNotification:
(hermes_home / ".update_output.txt").write_text(
"\x1b[32m✓ Code updated!\x1b[0m\n\x1b[1mDone\x1b[0m"
)
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
@ -331,6 +384,7 @@ class TestSendUpdateNotification:
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("x" * 5000)
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
@ -344,6 +398,29 @@ class TestSendUpdateNotification:
# Total message should not be absurdly long
assert len(sent_text) < 4500
@pytest.mark.asyncio
async def test_sends_failure_message_when_update_fails(self, tmp_path):
"""Non-zero exit codes produce a failure notification with captured output."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("Traceback: boom")
(hermes_home / ".update_exit_code").write_text("1")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
with patch("gateway.run._hermes_home", hermes_home):
result = await runner._send_update_notification()
assert result is True
sent_text = mock_adapter.send.call_args[0][1]
assert "update failed" in sent_text.lower()
assert "Traceback: boom" in sent_text
@pytest.mark.asyncio
async def test_sends_generic_message_when_no_output(self, tmp_path):
"""Sends a success message even if the output file is missing."""
@ -354,6 +431,7 @@ class TestSendUpdateNotification:
pending = {"platform": "telegram", "chat_id": "111", "user_id": "222"}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
# No .update_output.txt created
(hermes_home / ".update_exit_code").write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
@ -362,7 +440,7 @@ class TestSendUpdateNotification:
await runner._send_update_notification()
sent_text = mock_adapter.send.call_args[0][1]
assert "restarted successfully" in sent_text
assert "finished successfully" in sent_text
@pytest.mark.asyncio
async def test_cleans_up_files_after_notification(self, tmp_path):
@ -373,10 +451,12 @@ class TestSendUpdateNotification:
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
exit_code_path = hermes_home / ".update_exit_code"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "111", "user_id": "222",
}))
output_path.write_text("✓ Done")
exit_code_path.write_text("0")
mock_adapter = AsyncMock()
runner.adapters = {Platform.TELEGRAM: mock_adapter}
@ -386,6 +466,7 @@ class TestSendUpdateNotification:
assert not pending_path.exists()
assert not output_path.exists()
assert not exit_code_path.exists()
@pytest.mark.asyncio
async def test_cleans_up_on_error(self, tmp_path):
@ -396,10 +477,12 @@ class TestSendUpdateNotification:
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
exit_code_path = hermes_home / ".update_exit_code"
pending_path.write_text(json.dumps({
"platform": "telegram", "chat_id": "111", "user_id": "222",
}))
output_path.write_text("✓ Done")
exit_code_path.write_text("0")
# Adapter send raises
mock_adapter = AsyncMock()
@ -412,6 +495,7 @@ class TestSendUpdateNotification:
# Files should still be cleaned up (finally block)
assert not pending_path.exists()
assert not output_path.exists()
assert not exit_code_path.exists()
@pytest.mark.asyncio
async def test_handles_corrupt_pending_file(self, tmp_path):
@ -440,8 +524,10 @@ class TestSendUpdateNotification:
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
pending_path = hermes_home / ".update_pending.json"
output_path = hermes_home / ".update_output.txt"
exit_code_path = hermes_home / ".update_exit_code"
pending_path.write_text(json.dumps(pending))
output_path.write_text("Done")
exit_code_path.write_text("0")
# Only telegram adapter available, but pending says discord
mock_adapter = AsyncMock()
@ -454,6 +540,7 @@ class TestSendUpdateNotification:
mock_adapter.send.assert_not_called()
# Files should still be cleaned up
assert not pending_path.exists()
assert not exit_code_path.exists()
# ---------------------------------------------------------------------------