fix(profiles): ship a retry path for the rename identity migration
A rename under a live multiplexer that could not reach the control verb warned and stopped there, leaving the operator with no way to finish: the rename cannot be repeated (profiles/<old> is gone) and the CLI deliberately never rewrites the routing DB a live gateway holds in memory. - `hermes profile migrate-identity <old> <new>`: retries the migration — delegates to the gateway control verb while a multiplexer is live, performs the durable rewrite of both state DBs when none is. Idempotent, and exits non-zero naming the offending database on a collision, a lock, or a partial failure. Only the name format and the existence of the new profile are checked; the old profile directory is expected to be gone. - An older gateway that does not implement the verb is reported as such (`identify` answers while the migrate verb does not), not as "no gateway". - `_migrate_profile_identity` returns an explicit success/failure result so the command can set its exit code; the rename warning now names the exact invocation. - A failed control answer keeps the raw payload when it carries no reason field. - The offline failure branch called `click.echo` in a module that never imports `click`: a failed second database raised NameError instead of printing its warning.
This commit is contained in:
@@ -938,6 +938,84 @@ class TestRenameProfile:
|
||||
acquire.assert_not_called()
|
||||
assert "Restart the gateway" in capsys.readouterr().err
|
||||
|
||||
def test_migrate_identity_command_repairs_a_failed_live_migration(self, profile_env, capsys):
|
||||
"""The failed-live-migration end state must be recoverable: `hermes profile
|
||||
migrate-identity <old> <new>` rekeys the durable rows once no gateway holds the store, and
|
||||
is idempotent (a second run has nothing left to rekey but still succeeds)."""
|
||||
from hermes_cli.profile_cmd import cmd_profile
|
||||
from hermes_state import SessionDB
|
||||
from argparse import Namespace
|
||||
tmp_path = profile_env
|
||||
create_profile("oldname", no_alias=True)
|
||||
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
|
||||
pdb = SessionDB(old_dir / "state.db")
|
||||
pdb.create_session(
|
||||
"sess1", "feishu", session_key="agent:oldname:feishu:dm:chatA",
|
||||
profile_name="oldname", chat_id="chatA", chat_type="dm")
|
||||
pdb.close()
|
||||
root_db = SessionDB(tmp_path / ".hermes" / "state.db")
|
||||
root_db.save_gateway_routing_entry(
|
||||
"agent:oldname:feishu:dm:chatA",
|
||||
json.dumps({"session_key": "agent:oldname:feishu:dm:chatA", "session_id": "sess1",
|
||||
"origin": {"platform": "feishu", "chat_id": "chatA", "profile": "oldname"}}),
|
||||
scope=str(tmp_path / ".hermes" / "sessions"))
|
||||
root_db.close()
|
||||
|
||||
# Rename under a live multiplexer whose control verb answers nothing: the CLI warns and
|
||||
# leaves the (in-memory-owned) store alone, so the rows still name the old profile.
|
||||
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
|
||||
patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
|
||||
patch("hermes_cli.profiles._notify_multiplexer"), \
|
||||
patch("gateway.control_socket.migrate_gateway_profile_identity", return_value=None):
|
||||
rename_profile("oldname", "newname")
|
||||
assert "hermes profile migrate-identity oldname newname" in capsys.readouterr().err
|
||||
|
||||
# Gateway restarted/stopped → the retry command repairs both stores.
|
||||
with patch("hermes_cli.profiles._live_default_multiplexer", return_value=False):
|
||||
cmd_profile(Namespace(profile_action="migrate-identity",
|
||||
old_name="oldname", new_name="newname"))
|
||||
assert "✓ Session/routing identity migrated" in capsys.readouterr().out
|
||||
# Idempotent: nothing left to rekey, still a success.
|
||||
cmd_profile(Namespace(profile_action="migrate-identity",
|
||||
old_name="oldname", new_name="newname"))
|
||||
|
||||
moved_db = SessionDB(tmp_path / ".hermes" / "profiles" / "newname" / "state.db")
|
||||
row = moved_db._read_one(
|
||||
"SELECT session_key, profile_name FROM sessions WHERE id = ?", ("sess1",))
|
||||
assert row is not None
|
||||
assert row["session_key"] == "agent:newname:feishu:dm:chatA"
|
||||
assert row["profile_name"] == "newname"
|
||||
moved_db.close()
|
||||
root_db2 = SessionDB(tmp_path / ".hermes" / "state.db")
|
||||
routing = root_db2.load_gateway_routing_entries(
|
||||
scope=str(tmp_path / ".hermes" / "sessions"))
|
||||
assert "agent:oldname:feishu:dm:chatA" not in routing
|
||||
assert "agent:newname:feishu:dm:chatA" in routing
|
||||
root_db2.close()
|
||||
|
||||
def test_migrate_identity_reports_the_raw_answer_and_exits_nonzero(self, profile_env, capsys):
|
||||
"""A live gateway that answers with something unusable must fail loudly — exit non-zero,
|
||||
name the retry command, and quote the raw answer (a non-dict payload used to print a
|
||||
reason-less warning). The live gateway keeps ownership: no direct DB rewrite."""
|
||||
from hermes_cli.profile_cmd import cmd_profile
|
||||
from argparse import Namespace
|
||||
create_profile("oldname", no_alias=True)
|
||||
create_profile("newname", no_alias=True) # the rename already happened; only <new> must exist
|
||||
|
||||
with patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
|
||||
patch("gateway.control_socket.migrate_gateway_profile_identity",
|
||||
return_value="<html>not a control answer</html>"), \
|
||||
patch("hermes_state_registry.acquire") as acquire:
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cmd_profile(Namespace(profile_action="migrate-identity",
|
||||
old_name="oldname", new_name="newname"))
|
||||
|
||||
assert excinfo.value.code != 0
|
||||
err = capsys.readouterr().err
|
||||
assert "not a control answer" in err
|
||||
assert "hermes profile migrate-identity oldname newname" in err
|
||||
acquire.assert_not_called()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestExportImport
|
||||
|
||||
Reference in New Issue
Block a user