Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/architecture/rfcs/self-update-explicit-runtime-route-v0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Preserve the selected runtime during rollback

Status: implementation proposal.

[中文](self-update-explicit-runtime-route-v0.zh-CN.md)

## Problem and ownership

An explicitly routed `update --rollback` dropped its registry and runtime root
when planning and launching the target runtime. With two populated default
roots, the target's read-only authority check rejected the implicit route. The
parent reported a format incompatibility even though the selected store was
readable. A successful check could also be followed by diagnostics against a
different store.

The existing updater owns this defect. Rollback and forward update resolve the
same selected route through the existing registry/runtime resolver, then pass
that route to planning and every child check. The existing argument builder is
shared; callers that supply no route directly retain their implicit behavior.
This adds no state, provider, migration, permission, scheduler or executor.

## Failure and acceptance boundaries

Authority compatibility remains a read-only `--require-current` check before
the launcher changes. Its exit status and bounded output remain in the existing
execution readback, and failure is reported without guessing its cause. A
failed check also exposes its bounded stdout/stderr in the default CLI output. A
failed core doctor still restores the previous launcher; an extension failure
still reports the activated runtime with its provider unavailable.

Acceptance follows the real rollback CLI through planning and all three child
checks, including each failure boundary and paths containing spaces. Existing
archive, pip and pipx update route regressions remain applicable. Installed
acceptance must distinguish an isolated rollback from changing the user's
active launcher or restoring a production store. This repair does not explain
why a separate installer selected an older source revision.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 回滚全程保留所选运行目录

状态:实现提案。

[English](self-update-explicit-runtime-route-v0.md)

## 问题与责任层

显式指定 registry 与 runtime root 的 `update --rollback`,在规划和启动目标运行时
时丢失了这两个参数。两个默认目录都已有状态时,目标运行时的只读权威兼容检查会
拒绝隐式路由,父进程却将它报告为格式不兼容;即使该检查通过,后续诊断也可能
读取另一个目录。

修复位于既有更新器。回滚与前向更新复用原有 registry/runtime 解析器选择路由,
将同一路由传给规划诊断及每个子进程,并共用参数构建函数。直接调用且未提供路由
的调用者仍保留原有隐式行为,不增加状态、provider、迁移、权限、调度器或执行者。

## 失败与验收边界

兼容检查仍在修改启动器之前,以只读 `--require-current` 执行。退出码和有界输出
保留在既有 execution 读回中,默认 CLI 同时展示失败检查的有界输出,不猜测原因。
核心诊断失败仍恢复原启动器;
扩展失败仍如实报告已激活运行时与不可用 provider,不把失败改成成功。

验收从实际回滚 CLI 贯穿规划与三个子检查,覆盖各阶段拒绝和含空格路径,并保留
archive、pip、pipx 前向更新的既有路由回归。安装验收分别说明隔离回滚、本人实际
启动器变化与生产存储恢复的范围。本修复不解释另一安装过程为何选择了较旧源码。
32 changes: 24 additions & 8 deletions loopx/cli_commands/support_control_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@
UPDATE_CONTROL_COMMANDS = {"update", "upgrade"}


def _selected_runtime_route(
registry_path: Path, runtime_root: str | None, *, registry_was_supplied: bool,
) -> tuple[Path, Path]:
selected_registry = registry_path if registry_was_supplied else explicit_global_registry(
runtime_root, registry_path=registry_path,
)
selected_runtime = resolve_runtime_root(
load_registry(selected_registry) if selected_registry.exists() else {},
runtime_root, registry_path=selected_registry,
)
return selected_registry, selected_runtime


def register_update_command(
subparsers: argparse._SubParsersAction,
add_subcommand_format: AddFormat,
Expand Down Expand Up @@ -219,9 +232,16 @@ def renderer(value):
print_payload(payload, output_format(args), renderer)
return 0 if payload.get("ok") else 1
if args.rollback:
payload = build_rollback_plan(release_id=args.rollback)
selected_registry, selected_runtime = _selected_runtime_route(
registry_path, args.runtime_root, registry_was_supplied=registry_was_supplied,
)
payload = build_rollback_plan(
release_id=args.rollback, registry_path=selected_registry,
runtime_root=str(selected_runtime),
)
payload = execute_rollback_plan(
payload, timeout_seconds=args.timeout_seconds
payload, timeout_seconds=args.timeout_seconds,
registry_path=selected_registry, runtime_root=str(selected_runtime),
)
else:
doctor_payload = None
Expand Down Expand Up @@ -276,12 +296,8 @@ def renderer(value):
from ..control_plane.heartbeat.installed_prompt_update import (
update_with_prompts,
)
selected_registry = registry_path if registry_was_supplied else explicit_global_registry(
args.runtime_root, registry_path=registry_path,
)
selected_runtime = resolve_runtime_root(
load_registry(selected_registry) if selected_registry.exists() else {},
args.runtime_root, registry_path=selected_registry,
selected_registry, selected_runtime = _selected_runtime_route(
registry_path, args.runtime_root, registry_was_supplied=registry_was_supplied,
)
payload = update_with_prompts(
payload, registry=selected_registry,
Expand Down
64 changes: 46 additions & 18 deletions loopx/self_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,13 @@ def build_rollback_plan(
release_id: str,
doctor_payload: dict[str, Any] | None = None,
home: Path | None = None,
registry_path: Path | None = None,
runtime_root: str | None = None,
) -> dict[str, Any]:
doctor = doctor_payload or collect_doctor()
doctor = doctor_payload or (
collect_doctor(registry_path=registry_path, runtime_root_override=runtime_root)
if registry_path is not None or runtime_root is not None else collect_doctor()
)
current_release_root = _release_root_from_doctor(doctor)
releases_dir = _user_releases_dir(home)
releases = _list_release_roots(releases_dir)
Expand Down Expand Up @@ -1174,18 +1179,23 @@ def _execute_python_distribution_update(
return updated


def _runtime_route_arguments(registry_path: Path | None, runtime_root: str | None) -> list[str]:
arguments = []
if registry_path is not None:
arguments += ["--registry", str(registry_path.resolve())]
if runtime_root is not None:
arguments += ["--runtime-root", runtime_root]
return arguments


def execute_update_plan(
payload: dict[str, Any], *, timeout_seconds: int = 600,
registry_path: Path | None = None, runtime_root: str | None = None,
progress: Callable[[str], None] = lambda _stage: None,
) -> dict[str, Any]:
if not payload.get("plan", {}).get("apply_supported", True):
return dict(payload, ok=False, changes_applied=False)
route_arguments = []
if registry_path is not None:
route_arguments += ["--registry", str(registry_path.resolve())]
if runtime_root is not None:
route_arguments += ["--runtime-root", runtime_root]
route_arguments = _runtime_route_arguments(registry_path, runtime_root)
lifecycle = (
payload.get("install_lifecycle")
if isinstance(payload.get("install_lifecycle"), dict)
Expand Down Expand Up @@ -1375,6 +1385,8 @@ def execute_rollback_plan(
*,
timeout_seconds: int = 600,
home: Path | None = None,
registry_path: Path | None = None,
runtime_root: str | None = None,
) -> dict[str, Any]:
if os.name == "nt":
updated = dict(payload)
Expand All @@ -1394,6 +1406,7 @@ def execute_rollback_plan(
return payload
release_root = Path(str(selected_release_root))
target_script = _release_script(release_root)
route_arguments = _runtime_route_arguments(registry_path, runtime_root)
loopx_bin = _user_loopx_bin(home)
previous_link_target = os.readlink(loopx_bin) if loopx_bin.is_symlink() else None
execution: dict[str, Any] = {
Expand All @@ -1405,22 +1418,27 @@ def execute_rollback_plan(
updated = dict(payload)
try:
compatible = subprocess.run(
[str(target_script), "--format", "json", "authority-archive", "upgrade",
[str(target_script), *route_arguments, "--format", "json", "authority-archive", "upgrade",
"--all-known", "--require-current"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout_seconds,
)
execution.update(
compatibility_returncode=compatible.returncode,
compatibility_stdout_tail=compatible.stdout[-2000:],
compatibility_stderr_tail=compatible.stderr[-2000:],
)
if compatible.returncode != 0:
raise RuntimeError("Rollback target cannot read current authority formats. "
"Keep the current runtime; recover a verified backup into an isolated store first.")
raise RuntimeError("Rollback target authority check failed. Keep the current runtime "
"and inspect the compatibility readback before retrying.")
loopx_bin.parent.mkdir(parents=True, exist_ok=True)
temp_link = loopx_bin.with_name(f".{loopx_bin.name}.rollback.{os.getpid()}")
if temp_link.exists() or temp_link.is_symlink():
temp_link.unlink()
temp_link.symlink_to(target_script)
os.replace(temp_link, loopx_bin)
doctor_result = subprocess.run(
[str(loopx_bin), "--format", "json", "doctor"],
[str(loopx_bin), *route_arguments, "--format", "json", "doctor"],
text=True, encoding="utf-8", errors="replace",
capture_output=True,
timeout=timeout_seconds,
Expand All @@ -1436,6 +1454,7 @@ def execute_rollback_plan(
extension_doctor_result = subprocess.run(
[
str(loopx_bin),
*route_arguments,
"--format",
"json",
"extension",
Expand Down Expand Up @@ -1537,14 +1556,23 @@ def render_update_plan_markdown(payload: dict[str, Any]) -> str:
["", "## Rollback Command", "", "```bash", str(rollback_command), "```"]
)
if execution:
lines.extend(
[
"",
"## Execution",
"",
f"- Doctor return code: `{execution.get('doctor_returncode')}`",
]
)
lines.extend(["", "## Execution", ""])
for stage, label in (
("compatibility", "Authority compatibility"),
("doctor", "Doctor"),
("extension_doctor", "Extension doctor"),
):
returncode = execution.get(f"{stage}_returncode")
if returncode is None:
continue
lines.append(f"- {label} return code: `{returncode}`")
if returncode != 0:
for stream in ("stdout", "stderr"):
output = execution.get(f"{stage}_{stream}_tail")
if output:
lines.extend(["", f"### {label} {stream}", "", "```text", str(output), "```"])
if execution.get("error"):
lines.extend(["", f"Error: {execution['error']}"])
return "\n".join(lines) + "\n"

source = payload.get("source") if isinstance(payload.get("source"), dict) else {}
Expand Down
76 changes: 76 additions & 0 deletions tests/test_self_update_runtime_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,82 @@ def run(command, **kwargs):
assert installer_env["LOOPX_RUNTIME_ROOT"] == str(runtime)


@pytest.mark.skipif(os.name == "nt", reason="POSIX snapshot rollback")
@pytest.mark.parametrize("failed_stage", [None, "compatibility", "doctor", "extensions"])
def test_rollback_cli_preserves_selected_route_and_failure_boundaries(
tmp_path, monkeypatch, capsys, failed_stage,
):
from loopx.cli import main
from loopx.self_update import render_update_plan_markdown

runtime = tmp_path / "selected runtime"
runtime.mkdir()
registry = runtime / "registry.global.json"
registry.write_text(json.dumps({"goals": [], "common_runtime_root": str(runtime)}))
releases = tmp_path / "releases"
current, target = releases / "current", releases / "target"
for release in (current, target):
(release / "scripts").mkdir(parents=True)
(release / "scripts/loopx").write_text("#!/bin/sh\nexit 0\n")
launcher = tmp_path / "bin/loopx"
launcher.parent.mkdir()
launcher.symlink_to(current / "scripts/loopx")
monkeypatch.setattr("loopx.self_update._user_loopx_bin", lambda *_: launcher)
monkeypatch.setattr("loopx.self_update._user_releases_dir", lambda *_: releases)
snapshot = doctor_payload()
snapshot["package"]["release_root"] = str(current)
planning_calls, calls = [], []

def collect(**kwargs):
planning_calls.append(kwargs)
return snapshot

def run(command, **kwargs):
stage = ("compatibility" if "authority-archive" in command else
"extensions" if "extension" in command else "doctor")
calls.append((stage, command))
passed = stage != failed_stage
return subprocess.CompletedProcess(
command, 0 if passed else 1,
json.dumps({"ok": passed, "stage": stage, "error": None if passed else "synthetic refusal"}),
"" if passed else "synthetic diagnostic",
)

monkeypatch.setattr("loopx.self_update.collect_doctor", collect)
monkeypatch.setattr("loopx.self_update.subprocess.run", run)
exit_code = main([
"--registry", str(registry), "--runtime-root", str(runtime),
"--format", "json", "update", "--rollback", "target",
])
result = json.loads(capsys.readouterr().out)
assert exit_code == (0 if failed_stage is None else 1)
assert result["ok"] is (failed_stage is None)
assert planning_calls == [{"registry_path": registry, "runtime_root_override": str(runtime)}]
assert [stage for stage, _ in calls] == {
None: ["compatibility", "doctor", "extensions"],
"compatibility": ["compatibility"],
"doctor": ["compatibility", "doctor"],
"extensions": ["compatibility", "doctor", "extensions"],
}[failed_stage]
for _, command in calls:
assert command[command.index("--registry") + 1] == str(registry.resolve())
assert command[command.index("--runtime-root") + 1] == str(runtime)
compatibility = calls[0][1]
assert "--require-current" in compatibility and "--execute" not in compatibility
assert result["execution"]["compatibility_returncode"] == (1 if failed_stage == "compatibility" else 0)
if failed_stage == "compatibility":
assert "synthetic refusal" in result["execution"]["compatibility_stdout_tail"]
rendered = render_update_plan_markdown(result)
if failed_stage:
assert "synthetic refusal" in rendered
assert "synthetic diagnostic" in rendered
if failed_stage == "compatibility":
assert "Rollback target authority check failed" in rendered
assert "cannot read current authority formats" not in rendered
expected_release = current if failed_stage in {"compatibility", "doctor"} else target
assert launcher.resolve() == expected_release / "scripts/loopx"


@pytest.mark.skipif(os.name == "nt", reason="POSIX archive installation ownership")
@pytest.mark.parametrize("bin_target", [
None, "", "default_alias", "custom", "custom_launcher_link", "relative_custom",
Expand Down
Loading