diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 1477f233..f6bfc073 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -8021,7 +8021,7 @@ async def set_release_channel(channel: str = "stable"): # ── Admin: Update & Restart ─────────────────────────── @app.post("/admin/update") - async def admin_update(branch: str = "", dry_run: bool = False): + async def admin_update(branch: str = "", dry_run: bool = False, force_deps: bool = False): """Pull latest code, rebuild if needed, and restart the daemon. The process manager (launchctl/systemd) must be installed for @@ -8031,9 +8031,15 @@ async def admin_update(branch: str = "", dry_run: bool = False): Beta channel: pulls HEAD of the beta branch. Branch defaults to PINKYBOT_CHANNEL env var ("stable" -> release tags, "beta" -> beta branch), falling back to "stable" if unset. + + force_deps: if True, reinstall dependencies even when git HEAD didn't + change. Use this when installed package versions have drifted from + pyproject.toml (e.g., manual pip install bypassed, or the daemon was + seeded from a stale image). """ import shutil import subprocess as sp + import sys if not branch: channel = os.environ.get("PINKYBOT_CHANNEL", "stable") @@ -8162,23 +8168,45 @@ async def admin_update(branch: str = "", dry_run: bool = False): except Exception: summary = "" - # Detect dependency changes + # Detect dependency changes — rebuild whenever pyproject.toml or uv.lock + # changed in the pull, or when force_deps=True is passed (escape hatch + # for installed-vs-pinned drift that git diff can't see). deps_rebuilt = False + deps_error = "" try: - changed = sp.check_output( - ["git", "diff", "--name-only", before_hash, after_hash, "--", "pyproject.toml"], - cwd=repo_dir, stderr=sp.DEVNULL, timeout=10, - ).decode().strip() - if changed: - venv_pip = str(Path(repo_dir) / ".venv" / "bin" / "pip") - if Path(venv_pip).exists(): - sp.check_output( - [venv_pip, "install", "-e", ".[all]", "--quiet"], - cwd=repo_dir, stderr=sp.STDOUT, timeout=120, - ) - deps_rebuilt = True - except Exception: - pass + if before_hash != after_hash: + changed = sp.check_output( + ["git", "diff", "--name-only", before_hash, after_hash, "--", + "pyproject.toml", "uv.lock"], + cwd=repo_dir, stderr=sp.DEVNULL, timeout=10, + ).decode().strip() + else: + changed = "" + + if changed or force_deps: + # Prefer project venv pip if present, else use the running daemon's + # interpreter (sys.executable). This works for both venv and + # system-python deployments — the prior `.venv/bin/pip`-only path + # silently skipped rebuilds on system-python hosts. + venv_pip = Path(repo_dir) / ".venv" / "bin" / "pip" + if venv_pip.exists(): + pip_cmd = [str(venv_pip), "install", "-e", ".[all]", "--quiet"] + else: + # PEP 668: system pythons (Homebrew, Debian) mark themselves + # externally-managed. --break-system-packages lets us install + # into the same env the daemon imports from. + pip_cmd = [ + sys.executable, "-m", "pip", "install", + "-e", ".[all]", "--quiet", "--break-system-packages", + ] + sp.check_output(pip_cmd, cwd=repo_dir, stderr=sp.STDOUT, timeout=180) + deps_rebuilt = True + except sp.CalledProcessError as e: + deps_error = f"pip install failed: {e.output.decode()[:500] if e.output else e}" + _log(f"admin: {deps_error}") + except Exception as e: + deps_error = f"deps rebuild failed: {e}" + _log(f"admin: {deps_error}") # Always rebuild frontend on update to keep compiled assets fresh frontend_rebuilt = False @@ -8209,6 +8237,7 @@ async def admin_update(branch: str = "", dry_run: bool = False): "release": target_tag if use_release_tags else None, "commits": summary.splitlines() if summary else [], "deps_rebuilt": deps_rebuilt, + "deps_error": deps_error or None, "frontend_rebuilt": frontend_rebuilt, "frontend_error": frontend_error or None, "restarting": before_hash != after_hash or deps_rebuilt, diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py index 216aa2e2..3d877174 100644 --- a/src/pinky_self/server.py +++ b/src/pinky_self/server.py @@ -1933,11 +1933,22 @@ def check_for_updates() -> str: return "\n".join(parts) @mcp.tool() - def update_and_restart(branch: str = "") -> str: - """Pull latest code, rebuild if needed, and restart the daemon.""" - url = "/admin/update" + def update_and_restart(branch: str = "", force_deps: bool = False) -> str: + """Pull latest code, rebuild if needed, and restart the daemon. + + force_deps: reinstall dependencies even when git HEAD didn't move. + Useful when installed package versions have drifted from + pyproject.toml (e.g., daemon seeded from a stale image, or a + previous deploy skipped the pip install step). + """ + params = [] if branch: - url += f"?branch={branch}" + params.append(f"branch={branch}") + if force_deps: + params.append("force_deps=true") + url = "/admin/update" + if params: + url += "?" + "&".join(params) result = _api("POST", url) if "error" in result: return f"Update failed: {result['error']}" @@ -1959,6 +1970,8 @@ def update_and_restart(branch: str = "") -> str: if result.get("deps_rebuilt"): parts.append("\nDependencies rebuilt.") + if result.get("deps_error"): + parts.append(f"\nDeps rebuild error: {result['deps_error']}") if result.get("frontend_rebuilt"): parts.append("Frontend rebuilt.") diff --git a/tests/test_pinky_self_tools.py b/tests/test_pinky_self_tools.py index 1d6cf86a..3f160f30 100644 --- a/tests/test_pinky_self_tools.py +++ b/tests/test_pinky_self_tools.py @@ -1473,6 +1473,62 @@ def test_error(self, srv): result = _tools(srv)["update_and_restart"]() assert "failed" in result.lower() + def test_force_deps_in_url(self, srv): + """force_deps=True must reach /admin/update as a query param.""" + captured = {} + + def _urlopen(req, timeout=30): + captured["url"] = req.full_url if hasattr(req, "full_url") else str(req) + body = json.dumps({ + "updated": True, + "before_hash": "a", "after_hash": "a", + "deps_rebuilt": True, "restarting": True, + }).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=_urlopen): + _tools(srv)["update_and_restart"](force_deps=True) + assert "force_deps=true" in captured["url"] + + def test_force_deps_with_branch(self, srv): + """force_deps and branch must combine cleanly in the URL.""" + captured = {} + + def _urlopen(req, timeout=30): + captured["url"] = req.full_url if hasattr(req, "full_url") else str(req) + body = json.dumps({ + "updated": True, + "before_hash": "a", "after_hash": "a", + "restarting": False, + }).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=_urlopen): + _tools(srv)["update_and_restart"](branch="beta", force_deps=True) + assert "branch=beta" in captured["url"] + assert "force_deps=true" in captured["url"] + + def test_deps_error_surfaced(self, srv): + """Backend deps_error string is surfaced to the caller.""" + with _ok({ + "updated": True, + "before_hash": "a", "after_hash": "b", + "deps_rebuilt": False, + "deps_error": "pip install failed: boom", + "restarting": True, + }): + result = _tools(srv)["update_and_restart"]() + assert "deps rebuild error" in result.lower() + assert "boom" in result + # ── restart_daemon ────────────────────────────────────────────────────────────