From bbbcbae6929b034102ced43d844d17f07b77d52f Mon Sep 17 00:00:00 2001 From: alvinfei Date: Wed, 21 Oct 2026 16:12:41 +0800 Subject: [PATCH] feat(memhub): expose offline migration bundle command Co-authored-by: Cursor --- memhub/apps/core/src/one_memory/cli.py | 90 ++++++++++++++++++++++ memhub/apps/core/tests/test_cli_restore.py | 86 +++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/memhub/apps/core/src/one_memory/cli.py b/memhub/apps/core/src/one_memory/cli.py index dd821e2..4e20ed2 100644 --- a/memhub/apps/core/src/one_memory/cli.py +++ b/memhub/apps/core/src/one_memory/cli.py @@ -75,6 +75,8 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int: command, *options = arguments if command == "restore": return restore(options, stream) + if command == "migration-bundle": + return migration_bundle(options, stream) if command == "boundary" and not options: from one_memory.workbuddy_boundary import write_hook @@ -95,6 +97,7 @@ def run(arguments: Sequence[str], *, output: TextIO | None = None) -> int: stream.write( "Usage: one-memory [serve [--maintenance|--reader]|status [--json]|doctor [--json]" "|restore --source --yes [--json]" + "|migration-bundle --bundle-id --runtime-build --yes [--json]" "|boundary [--peek|--ack TOKEN]|capture SOURCE]\n" ) return 2 @@ -198,6 +201,93 @@ def _write_restore( stream.write(f"{text}\n") +def migration_bundle(options: Sequence[str], stream: TextIO) -> int: + """Create a complete management + SDK bundle while Runtime is stopped.""" + bundle_id: str | None = None + runtime_build: str | None = None + confirmed = False + as_json = False + index = 0 + while index < len(options): + option = options[index] + if option == "--bundle-id" and index + 1 < len(options): + bundle_id = options[index + 1] + index += 2 + continue + if option == "--runtime-build" and index + 1 < len(options): + runtime_build = options[index + 1] + index += 2 + continue + if option == "--yes": + confirmed = True + index += 1 + continue + if option == "--json": + as_json = True + index += 1 + continue + stream.write(f"one-memory: 参数无效:{option}\n") + return 2 + if not bundle_id or not runtime_build: + stream.write( + "one-memory: migration-bundle 需要 " + "--bundle-id --runtime-build \n" + ) + return 2 + + settings = load_settings() + status = inspect_runtime(settings.data_dir) + if status.state != "stopped": + document = { + "schema_version": 1, + "state": "blocked", + "code": "sdk_migration_bundle_runtime_running", + "runtime_state": status.state, + } + _write_restore(stream, as_json, document, "Runtime 仍在运行;不能创建迁移备份。") + return 4 + if not confirmed: + document = { + "schema_version": 1, + "state": "preview", + "bundle_id": bundle_id, + "requires_confirmation": True, + } + _write_restore(stream, as_json, document, "预览通过;加 --yes 后创建完整迁移备份。") + return 4 + + from one_memory.sdk_migration_bundle import ( + MigrationBundleRejected, + create_sdk_migration_bundle, + ) + + try: + result = create_sdk_migration_bundle( + data_dir=settings.data_dir, + bundle_id=bundle_id, + runtime_build=runtime_build, + runtime_stopped=True, + ) + except MigrationBundleRejected as error: + document = { + "schema_version": 1, + "state": "failed", + "code": error.code, + } + _write_restore(stream, as_json, document, f"迁移备份失败:{error.code}") + return 1 + document = { + "schema_version": 1, + "state": "created", + "bundle_id": result.bundle_id, + "bundle_path": str(result.bundle_path), + "file_count": result.file_count, + "total_bytes": result.total_bytes, + } + _write_restore(stream, as_json, document, f"完整迁移备份已创建:{result.bundle_path}") + return 0 + + def _status_text(command: str, status: RuntimeStatus) -> str: if status.state == "running": location = f"{status.host}:{status.port}" diff --git a/memhub/apps/core/tests/test_cli_restore.py b/memhub/apps/core/tests/test_cli_restore.py index d58fdc4..6e367f8 100644 --- a/memhub/apps/core/tests/test_cli_restore.py +++ b/memhub/apps/core/tests/test_cli_restore.py @@ -66,3 +66,89 @@ def test_restore_success_returns_zero(tmp_path, monkeypatch) -> None: ) assert code == 0 assert '"state":"restored"' in output.getvalue().replace(" ", "") + + +def test_migration_bundle_refuses_while_runtime_is_running(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=tmp_path)) + monkeypatch.setattr( + cli, + "inspect_runtime", + lambda _data_dir: RuntimeStatus("running", "runtime_verified"), + ) + output = io.StringIO() + + code = cli.run( + [ + "migration-bundle", + "--bundle-id", + "migration-1", + "--runtime-build", + "build-13", + "--yes", + "--json", + ], + output=output, + ) + assert code == 4 + assert "sdk_migration_bundle_runtime_running" in output.getvalue() + + +def test_migration_bundle_previews_before_writing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=tmp_path)) + monkeypatch.setattr( + cli, + "inspect_runtime", + lambda _data_dir: RuntimeStatus("stopped", "runtime_receipt_missing"), + ) + output = io.StringIO() + + code = cli.run( + [ + "migration-bundle", + "--bundle-id", + "migration-1", + "--runtime-build", + "build-13", + "--json", + ], + output=output, + ) + assert code == 4 + assert '"state": "preview"' in output.getvalue() or '"state":"preview"' in output.getvalue() + + +def test_migration_bundle_success_reports_bundle_identity(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(cli, "load_settings", lambda: Settings(_env_file=None, data_dir=tmp_path)) + monkeypatch.setattr( + cli, + "inspect_runtime", + lambda _data_dir: RuntimeStatus("stopped", "runtime_receipt_missing"), + ) + + class Result: + bundle_id = "migration-1" + bundle_path = tmp_path / "backups" / "migration-1.sdk-bundle" + file_count = 4 + total_bytes = 123 + + monkeypatch.setattr( + "one_memory.sdk_migration_bundle.create_sdk_migration_bundle", + lambda **_kwargs: Result(), + ) + output = io.StringIO() + code = cli.run( + [ + "migration-bundle", + "--bundle-id", + "migration-1", + "--runtime-build", + "build-13", + "--yes", + "--json", + ], + output=output, + ) + assert code == 0 + document = output.getvalue().replace(" ", "") + assert '"state":"created"' in document + assert '"bundle_id":"migration-1"' in document